콘텐츠로 이동

의존성 주입

Executor는 외부 서비스가 필요해요: API 클라이언트, 리포지토리, 분석 트래커, 클럭 등. Hurum에서는 Executor의 타입 매개변수로 이런 의존성을 선언하고, Store를 생성할 때 구체적인 인스턴스를 제공해요. 이렇게 하면 모킹 프레임워크 없이도 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'),
},
})

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 객체를 받지만, 자신이 선언한 키만 접근해요.

의존성 주입의 가장 큰 장점: 모킹 프레임워크 없이 테스트에서 구현체를 교체할 수 있어요.

import { TestStore } from '@hurum/core'
// Create a simple in-memory implementation
const mockRepo: PurchaseRepository = {
async save(purchase) {
return Result.ok({ ...purchase, id: 'saved-1' })
},
async findById(id) {
return { id, name: 'Test Purchase', amount: 100 }
},
}
// Pass it directly
const 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 production
const store = TimerStore.create({
deps: { clock: { now: () => Date.now() } },
})
// In tests
let fakeTime = 1000
const store = TestStore(TimerStore, {
deps: { clock: { now: () => fakeTime } },
})
store.send.tick({})
expect(store.getState().lastTick).toBe(1000)
fakeTime = 2000
store.send.tick({})
expect(store.getState().lastTick).toBe(2000)

이 패턴은 모든 비결정적 입력에 적용돼요: 난수, UUID, 피처 플래그, 로케일 설정 등.

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 — interface
interface PurchaseRepository {
save(purchase: Purchase): Promise<Result<Purchase, SaveError>>
}
// Bad — concrete class
class HttpPurchaseRepository {
save(purchase: Purchase): Promise<Result<Purchase, SaveError>> { ... }
}
// The executor declares the interface, not the implementation
const [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를 파생해요.