콘텐츠로 이동

이 페이지는 Hurum의 내부 구조를 설명해요. Hurum을 사용하는 데 이 정보가 필요하지는 않지만, 디버깅, 기여, 또는 통합을 구축할 때 도움이 돼요.

Hurum은 엄격한 의존성 방향을 가진 두 패키지로 제공돼요:

@hurum/react → @hurum/core
(peer dep) (zero deps)

프레임워크에 구애받지 않는 상태 머신 런타임이에요. 런타임 의존성이 없어요. CJS + ESM 듀얼 빌드.

모듈역할
events.tsEvents() / Event() 팩토리, Event 타입 브랜딩
command-executor.tsCommandExecutor() 팩토리, passthrough 단축키, Executor 컨텍스트
intent.tsIntent() / Intents() / Intent.all() / Intent.allSettled(), PreparedIntent
store.tsStore() 빌더, createStoreInstance(), Nested 관리, relay, Computed, 구독
computed.ts프록시 기반 의존성 추적, 구조적 동등성
nested.tsNested() / Nested.array() / Nested.map() 마커
selector.tsSelector 타입과 isSelector 가드
middleware.tsMiddleware / MiddlewareFactory 타입
middleware/내장 middleware: logger, persist, devtools, undoRedo
types.ts유틸리티 타입: StoreOf, StateOf, RawStateOf, DepsOf, DetectConflicts

얇은 React 바인딩 레이어예요. React 18+와 @hurum/core에 peer dependency를 가져요.

모듈역할
use-store.tsuseStore() 훅 — 컨텍스트 인식: StoreProvider에서 스코프된 인스턴스를 해석하거나 싱글턴으로 폴백
use-selector.tsuseSelector() — 커스텀 Selector를 사용하는 useSyncExternalStore
hooks.tsProxy를 통한 내부 use.* 필드 훅
provider.tsxStoreProvider 컴포넌트 + React 컨텍스트
with-provider.tsxwithProvider(def, Component) HOC
singleton.ts폴백을 위한 싱글턴 인스턴스 관리

Store()는 빌더를 반환해요. 각 체이닝된 메서드 (.on(), .intents(), .computed(), .middleware(), .nested(), .deps())는 내부 BuilderConfig에 설정을 추가해요. 빌더는 불변이에요 — 각 호출은 새 빌더 인스턴스를 반환해요.

const def = Store({ state: { count: 0 } })
.on(CounterEvent, { incremented: (s, p) => ({ ...s, count: s.count + p.amount }) })
.intents(CounterIntents)
.computed({ doubled: (s) => s.count * 2 })

마지막 .intents() 또는 .computed() 호출은 StoreDefinition을 생성해요 — 인스턴스를 생성할 수 있는 동결된 설정 객체예요.

def.create(options?)createStoreInstance()를 호출하며, 이 함수는:

  1. 초기 상태를 머지options.initialState가 정의의 기본 상태와 딥 머지돼요.
  2. Nested Store 초기화 — 상태의 각 Nested, Nested.array, Nested.map 마커에 대해 자식 StoreDefinition을 사용해서 자식 StoreInstance를 생성해요.
  3. Nested 기본값 해석 — Nested single 슬롯은 자식 Store의 초기 상태로 채워져요 (null이 아닌).
  4. Nested 동기화 설정 — 자식 상태 변경이 부모의 결합된 상태를 업데이트하도록 자식 Store를 구독해요.
  5. Executor 인덱스 구축 — 디스패치 시 빠른 조회를 위해 각 CommandExecutor에 매핑해요.
  6. Middleware 초기화 — Store 인스턴스로 각 MiddlewareFactory.create()를 호출해요.
  7. 초기 Computed 값 계산 — 초기 상태에 대해 모든 Computed 함수를 실행해요.

store.send(intent) 호출 시:

send(intent)
├─ middleware.onIntentStart(intent)
├─ resolve intent → Command[]
├─ for each Command (sequential by default):
│ ├─ find Executor for Command
│ ├─ executor(command, { deps, emit, getState, signal, scope })
│ │ ├─ emit(event) ← synchronous
│ │ │ ├─ middleware.onEvent(event)
│ │ │ ├─ store.on[event.type](state, payload) → newState
│ │ │ ├─ middleware.onStateChange(prev, next)
│ │ │ ├─ processRelay(event, state)
│ │ │ │ └─ forwardEventToNestedChildren(relayedEvents)
│ │ │ ├─ recompute affected computed values
│ │ │ └─ notify subscribers
│ │ └─ (next emit...)
│ └─ if executor throws → middleware.onError(error)
└─ middleware.onIntentEnd(intent)

핵심 불변 조건: emit()은 동기적이에요. 상태가 업데이트되고, Computed 값이 재계산되고, 구독자에게 알림이 전달된 후에 emit()이 반환돼요.

store.dispose():

  1. 모든 Nested 자식 Store를 재귀적으로 해제해요.
  2. 모든 구독을 제거해요.
  3. 인스턴스를 해제됨으로 표시해요.
  4. 이후 send() 호출은 예외를 던져요. 이후 emit() 호출은 조용히 무시돼요.

각 Nested 타입에는 대응하는 내부 관리자가 있어요:

Nested 타입관리자스토리지
Nested(ChildStore)NestedSingleManager단일 StoreInstance
Nested.array(ChildStore)NestedArrayManager항목 ID로 키가 지정된 Map<string, StoreInstance>
Nested.map(ChildStore)NestedMapManager맵 키로 키가 지정된 Map<string, StoreInstance>

부모 상태는 모든 자식의 결합된 상태를 포함해요. 자식의 상태가 변경될 때:

  1. 자식이 구독을 통해 부모에게 알려요.
  2. 부모가 recomputeFromNestedChange()를 호출해요.
  3. 부모의 결합된 상태가 재구축돼요.
  4. 부모의 Computed 값이 재계산돼요.
  5. 부모의 구독자에게 알려요.

Event는 두 방향으로 흘러요:

하향 (relay): 부모 relay 핸들러가 forwardEventToNestedChildren()으로 자식 Store에 전달될 Event를 생성할 수 있어요. relay에는 무한 순환을 방지하기 위한 깊이 제한 (MAX_RELAY_DEPTH = 5)이 있어요.

상향 (버블링): 자식 Store가 Event를 처리하면 부모에게 알려요. 부모가 해당 Event 타입에 대한 relay 핸들러가 있으면 반응할 수 있어요.

raw 상태가 변경되면 (예: 배열에 새 항목 출현) syncNestedArray() / syncNestedMap()이 현재 인스턴스와 새 상태를 비교해요:

  • 새 키 — 새 자식 인스턴스 생성
  • 제거된 키 — 제거된 자식 인스턴스 해제
  • 기존 키 — 자식 인스턴스 유지 (상태는 자체 Event 핸들러가 관리)

Computed 값은 프록시 기반 의존성 추적을 사용해요:

  1. 첫 실행 시 Proxy가 상태 객체를 감싸요.
  2. Computed 함수가 프록시에 대해 실행돼요.
  3. 프록시에 대한 모든 속성 접근이 의존성으로 기록돼요.
  4. 이후 상태 변경 시 의존성이 변경된 Computed 함수만 재계산돼요.

구조적 동등성: 재계산 후 새 값이 이전 값과 structuralEqual()로 비교돼요. 동일하면 불필요한 구독자 알림을 방지하기 위해 이전 참조가 유지돼요.

Middleware는 생명주기 훅을 받아요:

type Middleware = {
onIntentStart?(intent): void
onIntentEnd?(intent): void
onEvent?(event): void
onStateChange?(prev, next): void
onError?(error): void
}

MiddlewareFactorynameMiddleware를 반환하는 create(store) 메서드를 가져요. 내장 팩토리:

팩토리목적
logger()Event, 상태 변경, 오류를 콘솔에 로깅
persist()상태 영속화 및 복원 (localStorage, 커스텀 스토리지)
devtools()브라우저 DevTools 확장 프로그램에 연결
undoRedo()실행 취소/다시 실행을 위한 상태 이력 추적

useStore(def)는 React 컴포넌트에서 Store에 접근하기 위한 주요 훅이에요:

  1. React 컨텍스트로 주어진 정의에 대한 StoreProvider가 컴포넌트 트리에 있는지 확인해요.
  2. 발견되면 스코프된 인스턴스를 감싸는 핸들을 반환해요.
  3. 발견되지 않으면 글로벌 싱글턴으로 폴백해요 (첫 접근 시 지연 생성).

반환된 UseStoreReturn 객체는 다음을 제공해요:

  • use.* — 필드별 훅을 생성하는 Proxy. store.use.count()useSyncExternalStorestate.count를 구독하는 훅을 반환해요.
  • send — Store 인스턴스의 send 프록시로, Intent 이름 단축키가 유지돼요.
  • useSelector(fn)useSyncExternalStore를 사용하는 커스텀 Selector 훅.
  • getState, subscribe, cancel, cancelAll, dispose, scope — 기반 Store 인스턴스 메서드에 대한 직접 접근.

useStore(instance)는 주어진 StoreInstance를 직접 감싸는 오버로드로, 컨텍스트 해석을 우회해요.

StoreProviderof prop으로 Store 정의를 받고, 선택적으로 기존 store 인스턴스를 받아요. 인스턴스가 제공되지 않으면 선택적 initialStatedeps props를 사용해서 자동으로 생성해요. React 컨텍스트로 인스턴스를 자식에게 제공해요.

  • 싱글턴 (StoreProvider가 없을 때의 폴백): 첫 접근 시 생성되는 모듈 수준 인스턴스. 클라이언트 전용.
  • 스코프 (<StoreProvider> + useStore()): Provider당 인스턴스. SSR, 테스팅, 다중 인스턴스 시나리오에 필수.
확장메커니즘
커스텀 사이드 이펙트CommandExecutor 작성
횡단 관심사MiddlewareFactory 작성
프레임워크 바인딩store.subscribe()StoreInstance 구독
커스텀 파생 상태.computed()에 Computed 함수 추가
Store 간 조율Executor에서 relayscope 사용

Hurum은 의도적으로 플러그인 시스템이나 훅 레지스트리가 없어요. 모든 확장은 아키텍처의 일급 개념이에요.