설계 결정
Hurum의 모든 API에는 이유가 있어요. 이 페이지에서는 7가지 핵심 설계 결정, 무엇이 선택되었는지, 왜 그런지, 그리고 무엇이 기각되었는지를 문서화해요.
1. 왜 Effect 타입이 없나요?
섹션 제목: “1. 왜 Effect 타입이 없나요?”결론: 별도의 Effect 개념이 없어요. CommandExecutor가 곧 이펙트 경계예요.
일부 아키텍처 (Elm, Redux-Saga)에서는 이펙트 또는 사이드 이펙트를 별도의 타입으로 모델링해요 — 수행될 작업에 대한 선언적 기술이에요. Hurum은 이 레이어를 건너뛰어요. CommandExecutor는 Command를 받아 직접 사이드 이펙트 (API 호출, 타이머 등)를 수행하고, 결과로 Event를 발행해요.
이는 사이드 이펙트가 이를 해석하는 코드와 함께 위치한다는 뜻이에요. “무엇이 일어나야 하는가”와 “실행하기” 사이에 간접 레이어가 없어요. Executor 함수가 기술이자 실행 그 자체예요.
| 기각된 대안 | 기각 이유 |
|---|---|
Elm 스타일 Cmd 타입 (선언적 이펙트) | TypeScript 앱에서 명확한 이점 없이 간접 레이어를 추가해요. Executor를 직접 테스트하는 것이 이펙트 기술 + 인터프리터를 테스트하는 것보다 간단해요. |
| Redux-Saga 제너레이터 | 제너레이터 기반 제어 흐름은 타입을 정의하기 어렵고, 디버깅하기 어렵고, 대부분의 TypeScript 개발자에게 익숙하지 않아요. |
| Executor가 반환하는 별도의 Effect 타입 | 모든 Executor가 emit()을 직접 호출하는 대신 데이터를 반환하도록 강제해요. 비동기 흐름이 어색해져요 (하나의 Executor에서 여러 이펙트). |
2. 왜 Event가 일급 객체인가요?
섹션 제목: “2. 왜 Event가 일급 객체인가요?”결론: Event와 Command는 근본적으로 다른 개념이에요. Command는 명령형이에요 (“이것을 해라”). Event는 선언적 사실이에요 (“이것이 일어났다”). 둘 다 일급 객체예요.
많은 상태 관리 라이브러리가 액션과 Event를 혼동해요. Redux에서 “액션”은 무언가를 하라는 명령이면서 동시에 무엇이 일어났는지의 기록이에요. Hurum은 이를 분리해요:
- Command는 Intent에서 Executor로 흘러요. 사용자의 의도를 나타내요.
- Event는 Executor에서 Store로 흘러요. 달성된 사실을 나타내요.
이 분리는 데이터 흐름을 단방향이고 모호하지 않게 만들어요. 타입을 보면 파이프라인의 어디에 있는지 항상 알 수 있어요: Command이면 사이드 이펙트가 아직 일어나지 않은 거고, Event이면 이미 일어난 거예요.
| 기각된 대안 | 기각 이유 |
|---|---|
| 양쪽 모두를 위한 단일 “액션” 타입 (Redux 스타일) | 모호해요. 액션이 요청인가 사실인가? 누락된 구별을 보완하는 REQUEST/SUCCESS/FAILURE 접두사 같은 패턴으로 이어져요. |
| Event만, Command 없음 | Executor는 무엇을 해야 할지 알기 위해 입력이 필요해요. Command 없이는 Intent가 사이드 이펙트를 직접 기술해야 하므로 Intent 선언이 구현에 결합돼요. |
3. 왜 @hurum/core와 @hurum/react를 분리하나요?
섹션 제목: “3. 왜 @hurum/core와 @hurum/react를 분리하나요?”결론: Store는 프레임워크에 구애받지 않아요. React 바인딩은 별도 패키지의 얇은 레이어예요.
@hurum/core는 런타임 의존성이 없어요. Node.js, Deno, 브라우저 스크립트 또는 어떤 JavaScript 런타임에서도 작동해요. @hurum/react는 React 전용 바인딩 (useStore, Provider, useSyncExternalStore)을 peer dependency로 추가해요.
이 분리는 다음을 보장해요:
- 핵심 로직을 React 없이 테스트할 수 있어요.
- 다른 프레임워크 바인딩 (Vue, Svelte, Solid)을 React를 포함하지 않고 구축할 수 있어요.
- 서버 사이드 코드가 React를 번들링하지 않고 Store를 사용할 수 있어요.
| 기각된 대안 | 기각 이유 |
|---|---|
| 선택적 React가 포함된 단일 패키지 | 이 패턴에서 트리 쉐이킹이 신뢰할 수 없어요. 선택적 peer deps는 혼란스러운 설치 경고를 유발해요. |
| 프레임워크 어댑터 패턴의 React 우선 | React 가정을 코어에 내장해요 (예: 불변성 패턴, 훅 생명주기). |
4. Nested Store는 어떻게 통신하나요?
섹션 제목: “4. Nested Store는 어떻게 통신하나요?”결론: 부모가 중재하는 relay. 글로벌 이벤트 버스 없음, 직접적인 자식 간 통신 없음.
자식 Store가 Event를 발행하면 부모로 버블링돼요. 부모는 .relay() 핸들러로 반응해서 자식 Event를 부모 Event로 변환하거나 다른 자식에게 전달할 수 있어요. 자식은 형제와 직접 대화하지 않아요.
이는 통신 그래프를 메시가 아닌 트리로 유지해요. 모든 크로스 Store 상호작용이 부모의 relay 설정에서 보여요.
| 기각된 대안 | 기각 이유 |
|---|---|
| 글로벌 이벤트 버스 | 보이지 않는 결합. 어떤 Store든 다른 Store의 어떤 Event든 들을 수 있어요. 의존성이 암시적이 되어 추적이 불가능해져요. |
| 직접적인 자식 간 참조 | 캡슐화를 깨뜨려요. 자식은 형제에 대해 알 필요가 없어요. |
| 통신을 위한 공유 컨텍스트/DI | 의존성 주입 메커니즘에 과부하를 줘요. 의존성은 서비스를 위한 것이지 Store 간 조율을 위한 것이 아니에요. |
5. Intent 실행 모드는 어떻게 설계되었나요?
섹션 제목: “5. Intent 실행 모드는 어떻게 설계되었나요?”결론: 기본은 순차예요. Intent.all() (fail-fast)과 Intent.allSettled() (독립)로 옵트인 병렬 실행.
대부분의 사용자 액션은 순차적이에요: 검증, 저장, 확인. 순차를 기본으로 만들면 (Intent(A, B, C)) 일반적인 경우에 추가 문법이 필요 없어요.
병렬 실행이 필요할 때 (여러 독립 리소스 로딩) Intent.all()은 fail-fast 의미론 (하나 실패 시 모두 중단)을, Intent.allSettled()는 독립 의미론 (각각 완료까지 실행)을 제공해요.
| 기각된 대안 | 기각 이유 |
|---|---|
| 기본 병렬 | 대부분의 실제 Intent는 순차적이에요. 기본 병렬이면 일반적인 경우에 명시적 시퀀싱이 필요해요. |
| 단일 병렬 모드 | all과 allSettled는 진정으로 다른 필요를 충족해요. 독립 위젯을 로딩하는 대시보드가 하나 실패했다고 모든 위젯을 중단해서는 안 돼요. 결제 흐름은 검증 실패 시 중단해야 해요. |
| Intent가 아닌 Executor의 실행 모드 | 동일한 Command가 순차 Intent와 병렬 Intent 모두에 나타날 수 있어요. 실행 모드는 Command가 아닌 Intent의 속성이에요. |
6. 기존 아키텍처에서 어떻게 마이그레이션하나요?
섹션 제목: “6. 기존 아키텍처에서 어떻게 마이그레이션하나요?”결론: 먼저 어댑터 패턴, 그 다음 점진적 마이그레이션.
Hurum은 빅뱅 재작성을 요구하지 않아요. 권장 마이그레이션 경로:
- 기존 Store를 래핑해서 Hurum 호환 인터페이스를 노출하는 얇은 어댑터를 만들어요.
- 새 기능을 Hurum Store로 작성해요.
- 가장 격리된 것부터 시작해서 기존 기능을 하나씩 마이그레이션해요.
- 모든 기능이 마이그레이션되면 어댑터를 제거해요.
이것이 가능한 이유는 Hurum Store가 자체 완결적이기 때문이에요. Hurum Store와 레거시 Redux Store가 동일 애플리케이션에 공존할 수 있어요. 데이터는 암시적 글로벌 상태가 아닌 명시적 어댑터로 흘러요.
| 기각된 대안 | 기각 이유 |
|---|---|
| 빅뱅 마이그레이션 | 프로덕션 애플리케이션에 너무 위험해요. 모든 상태 관리를 한 번에 재작성해야 해요. |
| Hurum을 Redux처럼 보이게 하는 호환 레이어 | 목적을 훼손해요. Hurum의 데이터 흐름은 의도적으로 달라요. Redux 의미론 뒤에 숨기면 아키텍처 이점을 잃어요. |
| 자동 마이그레이션 도구 | Redux 액션과 Hurum의 Command/Event 분리 간 개념적 매핑은 기계적이지 않아요. 도메인에 대한 이해가 필요해요. |
7. 왜 이런 이름들인가요?
섹션 제목: “7. 왜 이런 이름들인가요?”결론: 각 이름은 정밀성과 최소한의 모호성을 위해 선택되었어요.
| 이름 | 왜 이 이름인가 | 대체된 것 |
|---|---|---|
| Intent | 기술적 지시가 아닌 사용자 의도를 나타내요. “Submit clicked”는 Intent예요. | Action, Dispatch, Thunk |
| Command | 특정 Executor에 대한 명령적 지시. 사이드 이펙트를 트리거한다는 것이 명확해요. | Action creator, Saga trigger |
| CommandExecutor | Command를 실행해요. 이름이 정확히 무엇을 하는지 말해요. | Middleware, Saga, Epic, Thunk |
| Event | 과거 시제의 사실. “Saved”, “Failed”, “Loaded”. 이벤트 소싱에서 보편적으로 이해돼요. | Action, Mutation |
| Store | 상태 관리 전반의 표준 용어. 이름을 바꿀 이유가 없어요. | Store, Atom, Signal |
| Computed | raw 상태에서 계산되는 파생 상태. 직접적이고 익숙해요. | Selector, Derived, Getter |
| passthrough | Command가 사이드 이펙트 없이 Event로 “통과”해요. 타이밍을 암시하지 않고 설명적이에요. | sync, direct, identity |
passthrough에 대해 기각된 이름 sync는 실제 의미가 “사이드 이펙트 없음”인데 동기적 타이밍 (비동기의 반대)을 암시하기 때문에 폐기되었어요. passthrough는 타이밍이 아닌 데이터 흐름을 설명해요.