빠른 시작
카운터를 만들어볼게요. 가장 단순한 예제이지만, 전체 Hurum 파이프라인을 거쳐요 — 복잡한 비동기 결제 흐름에서도 사용하는 바로 그 파이프라인이에요.
카운터, 단계별로
섹션 제목: “카운터, 단계별로”-
Event 정의 — 무엇이 일어날 수 있는가
Event는 이미 일어난 일을 기록해요 — 과거 시제로 생각하세요. ‘카운터를 증가시켜’ 가 아니라 ‘카운터가 증가되었다’예요. 이 구분이 중요해요: Event는 명령이 아니라 사실이에요.
counter/events.ts import { Events, Event } from '@hurum/core'export const CounterEvent = Events('Counter', {incremented: Event<{ amount: number }>(),decremented: Event<{ amount: number }>(),reset: Event<{}>(),})Events('Counter', { ... })는 네임스페이스 그룹을 생성해요. 각 Event는 타입이 지정된 페이로드를 가져요 —incremented는{ amount: number }를 전달하고,reset은 아무것도 전달하지 않아요. -
CommandExecutor 정의 — 어떻게 일어나는가
CommandExecutor는 사이드 이펙트가 사는 곳이에요 — API 호출, localStorage, 타이머 같이 외부 세계와 소통하는 모든 것이요.
CommandExecutor호출은[Command, Executor]튜플을 반환해요: Command는 Intent가 사용하는 참조 토큰이고, Executor는 실제 일을 하는 함수예요.counter/executors.ts import { CommandExecutor } from '@hurum/core'import { CounterEvent } from './events'export const [IncrementCommand, IncrementExecutor] = CommandExecutor<{ amount: number }>((command, { emit }) => {emit(CounterEvent.incremented(command))})export const [DecrementCommand, DecrementExecutor] = CommandExecutor<{ amount: number }>((command, { emit }) => {emit(CounterEvent.decremented(command))})export const [ResetCommand, ResetExecutor] = CommandExecutor<{}>((command, { emit }) => {emit(CounterEvent.reset(command))})각
CommandExecutor호출은[Command, Executor]튜플을 반환해요. executor는 Command 페이로드와 컨텍스트 객체를 받아요 — 여기서는emit만 사용하지만, 실제 executor는deps,getState,signal,scope도 사용해요. -
Intent 정의 — 사용자가 하고 싶은 것
Intent는 선언적 매핑이에요: ‘사용자가 X를 하면, 커맨드 Y와 Z를 실행해.’ Intent는 사용자가 한 일(버튼 클릭, 폼 제출)을 설명하지, 시스템이 할 일을 설명하지 않아요. 이 네이밍 규칙이 코드를 자체 문서화해줘요.
counter/intents.ts import { Intents, Intent } from '@hurum/core'import { IncrementCommand, DecrementCommand, ResetCommand } from './executors'export const CounterIntents = Intents('Counter', {plusButtonClicked: Intent(IncrementCommand),minusButtonClicked: Intent(DecrementCommand),resetButtonClicked: Intent(ResetCommand),})이름에 주목하세요:
increment가 아니라plusButtonClicked예요. Intent는 시스템이 해야 할 일이 아니라 사용자가 한 일을 설명해요. 이 구분은 하나의 사용자 액션이 여러 Command를 트리거할 때 중요해져요. -
Store 정의 — 상태 + 전이 + 모든 것
Store는 중심 단위예요. 초기 상태,
.on()핸들러(이벤트에 반응하는 순수 함수),.computed()값(파생 상태), 그리고 위에서 정의한 intent와 executor를 하나로 엮어요.counter/store.ts import { Store } from '@hurum/core'import { CounterEvent } from './events'import { CounterIntents } from './intents'import { IncrementExecutor, DecrementExecutor, ResetExecutor } from './executors'export const CounterStore = Store({ state: { count: 0, multiplier: 2 } }).on(CounterEvent, {incremented: (state, { amount }) => ({...state,count: state.count + amount,}),decremented: (state, { amount }) => ({...state,count: state.count - amount,}),reset: (state) => ({...state,count: 0,}),}).computed({doubled: (state) => state.count * 2,product: (state) => state.count * state.multiplier,}).intents(CounterIntents).executors(IncrementExecutor, DecrementExecutor, ResetExecutor).on()핸들러는 순수 함수예요:(state, payload) => newState. 사이드 이펙트 없음..computed()값은 상태에서 파생되며 의존하는 값이 변경되면 즉시 재계산돼요. -
React에서 사용 — 상태 읽기와 Intent 보내기
@hurum/react에서useStore를 import한 후, 컴포넌트에서store.use.*훅을 사용하세요.counter/Counter.tsx import { useStore } from '@hurum/react'import { CounterStore } from './store'import { CounterIntents } from './intents'function Counter() {const store = useStore(CounterStore)const count = store.use.count()const doubled = store.use.doubled()const product = store.use.product()return (<div><p>Count: {count}</p><p>Doubled: {doubled}</p><p>Product: {product}</p><button onClick={() => store.send.plusButtonClicked({ amount: 1 })}>+1</button><button onClick={() => store.send.minusButtonClicked({ amount: 1 })}>-1</button><button onClick={() => store.send.resetButtonClicked({})}>Reset</button></div>)}store.use.count()는count필드만 구독해요 —multiplier가 변경되어도count가 변경되지 않으면 이 컴포넌트는 리렌더되지 않아요. 각use.*훅은 기본적으로 세분화(granular)되어 있어요.store.send.plusButtonClicked({ amount: 1 })는store.send(CounterIntents.plusButtonClicked({ amount: 1 }))의 축약형이에요. 둘 다 동작해요 — 선호하는 방식을 사용하세요. -
스코프 인스턴스를 위한 Provider (선택)
기본적으로 Provider 외부에서
useStore(CounterStore)는 전역 싱글턴에서 동작해요. 독립적인 여러 인스턴스가 필요하다면 (예: 같은 페이지에 두 개의 카운터),StoreProvider를 사용하세요:counter/ScopedCounter.tsx import { useStore, StoreProvider } from '@hurum/react'import { CounterStore } from './store'function ScopedCounter() {const store = useStore(CounterStore)const count = store.use.count()return (<div><p>Count: {count}</p><button onClick={() => store.send.plusButtonClicked({ amount: 1 })}>+1</button></div>)}// 각 StoreProvider는 독립적인 Store 인스턴스를 가집니다function App() {const storeA = CounterStore.create()const storeB = CounterStore.create()return (<><StoreProvider of={CounterStore} store={storeA}><ScopedCounter /></StoreProvider><StoreProvider of={CounterStore} store={storeB}><ScopedCounter /></StoreProvider></>)}StoreProvider내부에서useStore(CounterStore)는 스코프된 인스턴스를 반환해요. Provider 외부에서는 전역 싱글턴으로 폴백해요.
무슨 일이 일어났나?
섹션 제목: “무슨 일이 일어났나?”“+1”을 클릭했을 때 실행된 정확한 순서예요:
1. onClick → store.send.plusButtonClicked({ amount: 1 })2. Intent maps to IncrementCommand → Store matches IncrementExecutor3. IncrementExecutor runs → emit(CounterEvent.incremented({ amount: 1 }))4. Store.on[CounterEvent.incremented.type] runs → state becomes { count: 1, multiplier: 2 }5. Computed recalculates: doubled = 2, product = 26. React subscribers notified → component re-renders모든 상태 변경이 거치는 같은 경로예요. 비동기 작업의 경우, 유일한 차이점은 executor 내부에서 일어나는 일뿐이에요 — 나머지 파이프라인은 동일해요.
다음 단계
섹션 제목: “다음 단계”파이프라인의 동작을 확인했으니, 각 구성 요소가 어떻게 작동하는지 자세히 알아보세요:
- 데이터 흐름 — 파이프라인의 모든 단계 심층 분석
- Event — Event의 동작 방식과 왜 사실인가
- CommandExecutor — 사이드 이펙트 경계
- Store — 상태, 전이, Computed, 빌더 API