의존성 주입
Executor는 외부 서비스가 필요해요: API 클라이언트, 리포지토리, 분석 트래커, 클럭 등. Hurum에서는 Executor의 타입 매개변수로 이런 의존성을 선언하고, Store를 생성할 때 구체적인 인스턴스를 제공해요. 이렇게 하면 모킹 프레임워크 없이도 Executor를 순수하고 테스트 가능하게 유지할 수 있어요.
작동 방식
섹션 제목: “작동 방식”Executor가 필요한 것을 선언해요
섹션 제목: “Executor가 필요한 것을 선언해요”CommandExecutor의 두 번째 타입 매개변수가 deps의 형태를 정의해요:
import { CommandExecutor } from '@hurum/core'
interface PurchaseRepository { save(purchase: Purchase): Promise<Result<Purchase, SaveError>> findById(id: string): Promise<Purchase>}
const [SaveCommand, SaveExecutor] = CommandExecutor< { purchase: Purchase }, { purchaseRepository: PurchaseRepository }>(async (command, { deps, emit }) => { const result = await deps.purchaseRepository.save(command.purchase) result.match( (saved) => emit(PurchaseEvent.saved({ purchase: saved })), (error) => emit(PurchaseEvent.saveFailed({ error })), )})Executor는 인터페이스만 알고 있어요. purchaseRepository가 REST API와 통신하는지, GraphQL 엔드포인트와 통신하는지, 인메모리 페이크인지 전혀 알지 못해요.
Store가 구체적인 인스턴스를 제공해요
섹션 제목: “Store가 구체적인 인스턴스를 제공해요”Store는 .deps<T>()로 전체 deps 타입을 선언해요. 생성 시에 구현체를 제공해요:
import { Store } from '@hurum/core'
const PurchaseStore = Store({ state: { purchase: null as Purchase | null, saving: false, error: null as SaveError | null, },}) .executors(SaveExecutor, LoadExecutor) .deps<{ purchaseRepository: PurchaseRepository currencyService: CurrencyService }>()Store 인스턴스를 생성할 때:
const store = PurchaseStore.create({ deps: { purchaseRepository: new HttpPurchaseRepository(), currencyService: new CurrencyService('USD'), },})여러 Executor, 병합된 deps
섹션 제목: “여러 Executor, 병합된 deps”Store에 서로 다른 deps를 가진 여러 Executor가 있으면, Store의 deps 타입은 모든 Executor가 필요로 하는 것의 합집합이에요:
// SaveExecutor needs { purchaseRepository: PurchaseRepository }// LoadExecutor needs { purchaseRepository: PurchaseRepository }// ConvertExecutor needs { currencyService: CurrencyService }
const PurchaseStore = Store({ state: { ... } }) .executors(SaveExecutor, LoadExecutor, ConvertExecutor) .deps<{ purchaseRepository: PurchaseRepository currencyService: CurrencyService }>()각 Executor는 런타임에 전체 deps 객체를 받지만, 자신이 선언한 키만 접근해요.
테스트에서 deps 교체
섹션 제목: “테스트에서 deps 교체”의존성 주입의 가장 큰 장점: 모킹 프레임워크 없이 테스트에서 구현체를 교체할 수 있어요.
import { TestStore } from '@hurum/core'
// Create a simple in-memory implementationconst mockRepo: PurchaseRepository = { async save(purchase) { return Result.ok({ ...purchase, id: 'saved-1' }) }, async findById(id) { return { id, name: 'Test Purchase', amount: 100 } },}
// Pass it directlyconst store = TestStore(PurchaseStore, { deps: { purchaseRepository: mockRepo, currencyService: { convert: (amount) => amount * 1.1 }, },})
store.send.saveClicked({ purchase: testPurchase })await store.waitForIdle()
expect(store.getState().saving).toBe(false)expect(store.getState().purchase?.id).toBe('saved-1')jest.mock(), vi.mock(), 모듈 패칭이 필요 없어요. 인터페이스를 만족하는 평범한 객체면 충분해요.
클럭 패턴
섹션 제목: “클럭 패턴”시간 의존적인 로직은 테스트하기 어렵기로 유명해요. 클럭 의존성을 주입해서 테스트에서 시간을 제어할 수 있어요:
interface Clock { now(): number}
const [TimerCommand, TimerExecutor] = CommandExecutor< {}, { clock: Clock }>((_, { deps, emit }) => { emit(TimerEvent.ticked({ timestamp: deps.clock.now() }))})
// In productionconst store = TimerStore.create({ deps: { clock: { now: () => Date.now() } },})
// In testslet fakeTime = 1000const store = TestStore(TimerStore, { deps: { clock: { now: () => fakeTime } },})
store.send.tick({})expect(store.getState().lastTick).toBe(1000)
fakeTime = 2000store.send.tick({})expect(store.getState().lastTick).toBe(2000)이 패턴은 모든 비결정적 입력에 적용돼요: 난수, UUID, 피처 플래그, 로케일 설정 등.
Nested Store의 deps
섹션 제목: “Nested Store의 deps”Nested Store를 사용할 때, 자식 Store는 자체 deps를 가질 수 있어요. .childDeps()를 써서 부모의 deps에서 자식 deps를 파생해요:
const ItemStore = Store({ state: { id: '', name: '', price: 0 },}) .executors(UpdateItemExecutor) .deps<{ itemRepository: ItemRepository }>()
const PurchaseStore = Store({ state: { items: Nested.array(ItemStore), total: 0, },}) .executors(SaveExecutor) .deps<{ purchaseRepository: PurchaseRepository itemRepository: ItemRepository }>() .childDeps('items', (parentDeps) => ({ itemRepository: parentDeps.itemRepository, }))childDeps 매퍼는 자식 Store 인스턴스가 생성될 때 실행돼요. 자식은 부모에서 파생된 자체 deps를 받으므로, 최상위 레벨에서 deps를 한 번만 제공하면 돼요.
인터페이스 우선 설계
섹션 제목: “인터페이스 우선 설계”deps를 구체적인 클래스가 아닌 인터페이스로 정의하세요. 이렇게 하면 교체가 간편해져요:
// Good — interfaceinterface PurchaseRepository { save(purchase: Purchase): Promise<Result<Purchase, SaveError>>}
// Bad — concrete classclass HttpPurchaseRepository { save(purchase: Purchase): Promise<Result<Purchase, SaveError>> { ... }}
// The executor declares the interface, not the implementationconst [SaveCmd, SaveExec] = CommandExecutor< { purchase: Purchase }, { purchaseRepository: PurchaseRepository } // interface, not class>(...)이렇게 하면 HttpPurchaseRepository, GraphQLPurchaseRepository, InMemoryPurchaseRepository 모두 동일한 계약을 만족해요.
핵심 포인트
섹션 제목: “핵심 포인트”- Executor는 deps를 두 번째 타입 매개변수로 선언해요.
context.deps로 의존성에 접근해요. - Store는
.deps<T>()로 결합된 deps 타입을 선언해요.Store.create({ deps })로 구체적인 인스턴스를 제공해요. - 테스트에서 deps를 직접 교체해요.
TestStore()에 목 또는 페이크 구현체를 전달해요. 모킹 프레임워크가 필요 없어요. - 구체적인 클래스가 아닌 인터페이스를 사용하세요. Executor를 구현 세부사항으로부터 분리해요.
- 비결정적 입력을 주입하세요. 클럭, 난수 생성기, 피처 플래그 모두 deps로 주입해서 테스트를 결정적으로 유지해요.
- 자식 Store의 deps는 부모에서 흘러와요.
.childDeps()를 써서 부모의 deps 객체에서 자식 deps를 파생해요.