마이그레이션 가이드: MVVM에서 Context-Layered로
이 문서는 기존 MVVM 또는 단순 React Context 구조에서 Context-Layered Architecture로 이동할 때의 기준점을 설명합니다. 핵심은 “한 번에 전부 바꾸는 것”이 아니라, 책임 분리 기준에 맞춰 점진적으로 재배치하는 것입니다.
가장 큰 차이
| 관점 | 기존 MVVM/단순 구조 | Context-Layered |
|---|---|---|
| 초점 | 개념적 계층 | 구현 가능한 책임 계층 |
| 비즈니스 로직 | ViewModel이나 컴포넌트에 섞임 | business와 handlers로 분리 |
| 의존성 주입 | context 또는 import에 의존 | props 기반 DI 가능 |
| 실행 흐름 | 컴포넌트 중심 | handler 중심 |
| 테스트 | 컴포넌트 통합 테스트 위주 | 레이어별 테스트 가능 |
이전 구조와 이후 구조
이전
src/components/checkout/
├── CheckoutModel.tsx
├── CheckoutViewModel.tsx
├── CheckoutView.tsx
└── CheckoutPerformance.tsx이후
src/pages/checkout/
├── contexts/
├── business/
├── handlers/
├── actions/
├── hooks/
├── views/
└── CheckoutPage.tsx현재 패키지 경계 migration
tool protocol은 framework-neutral 별도 package가 되었습니다. action schema나 MCP/provider adapter가 필요한 경우에만 설치합니다.
npm install @context-action/react @context-action/core @context-action/tool-protocol zoddefineAction, createActionSchema, listAllTools와 protocol type은 @context-action/tool-protocol에서 import합니다. @context-action/react/tools는 createToolContext와 해당 React hook을, @context-action/react는 기본 action/store entry를, @context-action/core는 action runtime을 소유합니다. 기존 Core/React re-export는 제거되었습니다.
Durable mutation recovery는 별도의 선택적 package로 분리되었습니다. durable operation record, 프로세스 간 claim, HTTP/queue side-effect adapter가 필요한 애플리케이션만 @context-action/tool-durable-operations를 추가합니다. provider-neutral schema와 discovery만 사용하는 경우에는 필요하지 않습니다.
action context factory는 명시적인 context name을 요구합니다.
createActionContext<AppActions>('Checkout', { registry: { debug: true } });void action의 dispatch option은 payload와 혼동되지 않도록 두 번째 인자에 둡니다.
await register.actions.reset(undefined, { debounce: 100 });@context-action/react는 React 19.2 이상을 요구합니다. Store·Action API는 @context-action/react에서 import하며, 이전의 react18 호환 entry point는 더 이상 export하지 않습니다. @context-action/mutative-core / @context-action/mutative fork는 계속 유지 관리하고 upstream 동기화는 해당 package의 UPSTREAM.md에 기록합니다.
권장 마이그레이션 순서
1. Context 정의부터 분리
먼저 액션과 스토어 타입, provider 생성 코드를 contexts/로 옮깁니다. 이 단계에서는 동작 변경보다 “경계 분리”가 목적입니다.
2. 순수 로직을 business/로 이동
다음 항목을 순수 함수로 떼어냅니다.
- validation
- 계산 규칙
- 상태 전이 규칙
- 도메인별 파생 값 계산
이 단계가 끝나면 UI와 handler에서 계산 코드가 눈에 띄게 줄어듭니다.
3. side effect를 handlers/로 이동
API 호출, store 업데이트, ref focus, 로그 기록 같은 실행 흐름은 handler에 둡니다.
useCheckoutHandler('submit', async (payload) => {
const current = checkoutStore.getValue();
const result = createOrderDraft(payload, current);
if (result.success) {
checkoutStore.setValue(result.nextState);
await apiClient.save(result.order);
}
});4. view에서 직접 dispatch 코드를 actions/로 감싸기
export function useCheckoutActions() {
const dispatch = useCheckoutDispatch();
return {
submit: (order: OrderData) => dispatch('submit', order),
reset: () => dispatch('reset'),
};
}이렇게 하면 view가 action 이름과 payload 모양에 덜 직접적으로 결합됩니다.
5. store 직접 접근을 hooks/로 정리
view가 여러 store를 직접 읽기 시작하면 다시 복잡도가 올라갑니다. view용 데이터 접근은 hooks/에 모으는 것이 좋습니다.
6. 최종적으로 Page에서 조립
provider 구성, Handler Registry 마운트, 외부 의존성 주입은 페이지에서 담당하게 정리합니다. 실제 handler hook 호출은 Registry 내부에 둡니다. Ref 경계를 사용하는 경우의 표준 순서는 Action Provider → Store Provider → Ref Provider → Handler Registry → View이며, Ref를 사용하지 않으면 Registry가 Store Provider 바로 아래에 옵니다.
마이그레이션 체크리스트
- context 생성 코드가
contexts/에 모였는가 - 검증과 계산 로직이
business/로 분리되었는가 - API 호출과 store 업데이트가
handlers/에 모였는가 - view는 hook과 action만 사용하도록 단순화되었는가
- 테스트가 컴포넌트 하나에 몰리지 않고 레이어별로 분산되었는가
흔한 실수
views/에서 store를 직접 update하기handlers/안에 검증 규칙을 길게 쓰기actions/없이 view에서 dispatch 이름을 직접 남발하기contexts/에 runtime logic을 넣기