Store 컨벤션
Context-Action 프레임워크의 Store, TimeTravelStore, MutableStore 패턴에 대한 완전한 컨벤션입니다.
📋 목차
- Store 타입 개요
- Store (기본)
- TimeTravelStore
- MutableStore 패턴
- notifyPath/notifyPaths API
- 이벤트 루프 제어
- 성능 가이드라인
- 베스트 프랙티스
- 비즈니스 로직 분리
Store 타입 개요
Context-Action 프레임워크는 세 가지 전문화된 Store 구현을 제공합니다:
| Store 타입 | 구현 | 주요 기능 | 사용 사례 |
|---|---|---|---|
| Store | createStore() | 불변성 + Deep Freeze | 일반 상태, 폼, 설정 |
| TimeTravelStore | createTimeTravelStore() | Undo/Redo + Structural Sharing | 텍스트 에디터, 드로잉 앱 |
| MutableStore | undo/redo 없는 TimeTravelStore | Structural Sharing + 성능 | 고빈도 업데이트, 큰 트리 |
빠른 선택 가이드
// ✅ Store 사용: 일반 상태, 폼, 설정
const formStore = createStore('form', { name: '', email: '' });
// ✅ TimeTravelStore 사용: Undo/redo 기능
const editorStore = createTimeTravelStore('editor', { content: '' });
// ✅ MutableStore 패턴 사용: undo/redo 없는 고성능
// (mutable 모드의 TimeTravelStore, undo/redo 미사용)
const dashboardStore = createTimeTravelStore('dashboard', {
widgets: [...],
layout: {...}
}, { mutable: true });Store (기본)
개요
완전한 불변성 보장과 안전성 기능을 가진 표준 store입니다.
import { createStore } from '@context-action/react';
const userStore = createStore('user', { name: '', email: '' });기능
- Deep Freeze: 값이 동결되어 실수로 인한 변경 방지
- Copy-on-Write: 버전 기반 캐싱을 통한 효율적 복제
- RAF Batching: 여러 업데이트를 단일 프레임으로 배치
- 에러 복구: 문제가 있는 리스너 자동 제거
- 동시성 보호: 업데이트 큐로 경쟁 조건 방지
- notifyPath/notifyPaths: 수동 이벤트 제어 API
핵심 API
// Store 생성
const store = createStore('name', initialValue);
// 값 읽기
const value = store.getValue();
const snapshot = store.getSnapshot();
// 값 업데이트
store.setValue(newValue);
store.update(draft => { draft.prop = value; });
// 구독
const unsubscribe = store.subscribe(() => {
console.log('Store 업데이트됨');
});
// 수동 알림
store.notifyPath(['nested', 'property']);
store.notifyPaths([['path1'], ['path2']]);React 통합
import { useStoreValue } from '@context-action/react';
function UserComponent() {
const userStore = useUserStore('profile');
// ✅ 반응형 구독
const user = useStoreValue(userStore);
// ✅ 업데이트
const updateName = () => {
userStore.update(draft => { draft.name = 'New Name'; });
};
return <div>{user.name}</div>;
}사용 시기
- 일반 상태 관리
- 폼과 설정
- 캐시된 데이터
- 불변성 보장이 중요한 경우
useStoreValue()구독 사용 시
TimeTravelStore
개요
히스토리 관리를 통한 내장 undo/redo 기능을 가진 Store입니다.
import { createTimeTravelStore } from '@context-action/react';
const editorStore = createTimeTravelStore('editor',
{ content: '', cursor: 0 },
{ maxHistory: 50, mutable: true }
);기능
- Undo/Redo: 완전한 히스토리 탐색
- Structural Sharing: 변경되지 않은 부분은 같은 참조 유지
- 설정 가능한 히스토리:
maxHistory옵션 - 패치 기반 업데이트: JSON 패치를 통한 효율적 변경 추적
- notifyPath/notifyPaths: 수동 이벤트 제어 API
핵심 API
// 시간 여행 제어
editorStore.undo(); // 한 단계 뒤로
editorStore.redo(); // 한 단계 앞으로
editorStore.goTo(3); // 특정 위치로 이동
editorStore.reset(); // 초기 상태로 리셋
// 기능 확인
if (editorStore.canUndo()) { /* ... */ }
if (editorStore.canRedo()) { /* ... */ }
// UI를 위한 제어 가져오기
const { canUndo, canRedo, position, history } = editorStore.getTimeTravelControls();React 통합
⚠️ 중요: useStoreValue()가 아닌 useStorePath() 사용
import { useStorePath, useTimeTravelControls } from '@context-action/react';
function Editor() {
const editorStore = useEditorStore('document');
// ✅ 올바름: structural sharing을 위한 useStorePath
const content = useStorePath(editorStore, ['content']);
const cursor = useStorePath(editorStore, ['cursor']);
// ❌ 잘못됨: useStoreValue는 변경을 감지하지 못함
// const state = useStoreValue(editorStore);
// 시간 여행 제어
const { canUndo, canRedo, undo, redo } = useTimeTravelControls(editorStore);
return (
<div>
<textarea value={content} />
<button onClick={undo} disabled={!canUndo}>실행 취소</button>
<button onClick={redo} disabled={!canRedo}>다시 실행</button>
</div>
);
}구독 패턴
// TimeTravelStore는 structural sharing 사용
// 최상위 참조는 변경되지 않고, 중첩된 참조만 변경됨
// ❌ 잘못됨: 중첩된 변경 감지 못함
const state = useStoreValue(editorStore);
// ✅ 올바름: 중첩된 참조 변경 감지
const content = useStorePath(editorStore, ['content']);
const cursor = useStorePath(editorStore, ['cursor']);사용 시기
- 텍스트 에디터, 드로잉 애플리케이션
- 뒤로/앞으로 탐색이 있는 폼 마법사
- undo/redo가 필요한 모든 기능
- 상태 히스토리로 디버깅
- 히스토리 추적이 필수적인 경우
MutableStore 패턴
개요
정의: undo/redo 기능을 사용하지 않는 mutable: true TimeTravelStore
이 패턴은 히스토리 추적의 오버헤드 없이 structural sharing과 고성능을 제공하며, 여전히 기술적 기능은 유지합니다.
import { createTimeTravelStore } from '@context-action/react';
// MutableStore 패턴: undo/redo를 사용하지 않는 TimeTravelStore
const dashboardStore = createTimeTravelStore('dashboard', {
user: { name: 'John', settings: { theme: 'dark' } },
ui: { sidebar: { isOpen: true } }
}, {
mutable: true // structural sharing 활성화 (기본값)
// 참고: undo/redo 메서드는 존재하지만 사용하지 않음
});주요 특성
- Structural Sharing: 변경되지 않은 부분은 같은 참조 유지
- Undo/Redo 미사용: 히스토리 메서드는 사용 가능하지만 무시됨
- 성능 집중: 고빈도 업데이트에 최적화
- notifyPath/notifyPaths: 고급 이벤트 제어
별도 구현이 아닌 이유는?
- TimeTravelStore가 이미
mutable: true를 통해 structural sharing 제공 - 단순히
undo(),redo(),goTo()메서드를 호출하지 않으면 됨 - 코드 중복과 유지보수 오버헤드 방지
- 필요시 나중에 히스토리 활성화 가능
사용 패턴
// ✅ MutableStore 패턴: undo/redo 없는 TimeTravelStore
const appStore = createTimeTravelStore('app', {
user: { name: 'John', status: 'online' },
ui: { loading: false, progress: 0 }
}, { mutable: true });
// 선택적 재렌더링을 위한 structural sharing 사용
const userName = useStorePath(appStore, ['user', 'name']);
const uiLoading = useStorePath(appStore, ['ui', 'loading']);
// 효율적인 structural sharing으로 업데이트
appStore.update(draft => {
draft.user.name = 'Jane';
// ['user', 'name'] 경로만 재렌더링 트리거
// ui.loading 구독자는 재렌더링 안 됨
});
// ⚠️ undo/redo 메서드 사용하지 않음
// appStore.undo(); // MutableStore 패턴에서는 사용 가능하지만 사용 안 함
// appStore.redo(); // MutableStore 패턴에서는 사용 가능하지만 사용 안 함고급: 수동 이벤트 제어
// 🎯 패턴: 직접 변경 + notifyPath
async function loadUserData() {
// 1단계: 로딩 UI 알림 (상태 변경 없음)
appStore.notifyPath(['ui', 'loading']);
// 2단계: 데이터 가져오기
const userData = await fetchUserData();
// 3단계: 최종 데이터로 단일 업데이트
appStore.update(draft => {
draft.user = userData;
draft.ui.loading = false;
});
}
// 🎯 패턴: 외부 변경 + 알림
function setupWebSocket() {
ws.on('message', (data) => {
const state = appStore.getValue();
state.user.status = data.status; // 직접 변경
appStore.notifyPath(['user', 'status']);
});
}MutableStore 패턴 사용 시기
- 고빈도 업데이트 (애니메이션, 실시간 데이터)
- 선택적 재렌더링이 필요한 큰 상태 트리
- 성능에 민감한 애플리케이션
- 외부 시스템 통합 (WebSocket 등)
- undo/redo가 필요하지 않은 경우
성능 이점
// 전통적 접근: 여러 재렌더링
userStore.update(draft => { draft.loading = true; }); // 재렌더 1
const data = await fetch();
userStore.update(draft => { // 재렌더 2
draft.data = data;
draft.loading = false;
});
// 총: 2번 재렌더링
// notifyPath를 사용한 MutableStore 패턴: 단일 재렌더링
userStore.notifyPath(['loading']); // UI 업데이트 (재렌더 없음)
const data = await fetch();
userStore.update(draft => { // 재렌더 1
draft.data = data;
draft.loading = false;
});
// 총: 1번 재렌더링 (50% 감소)notifyPath/notifyPaths API
개요
상태 변경과 React 업데이트를 분리하는 수동 이벤트 제어 API입니다.
// 단일 경로 알림
store.notifyPath(['nested', 'property']);
// 여러 경로 알림 (RAF에서 배치)
store.notifyPaths([
['ui', 'loading'],
['ui', 'progress']
]);핵심 개념
전통적 상태 관리:
상태 변경 → React 재렌더링notifyPath 사용:
상태 변경 (선택적) → 수동 알림 → 선택적 React 재렌더링사용 사례
1. 상태 변경 없는 로딩 상태
async function loadData() {
// 로딩 UI 알림 (실제 상태 변경 없음)
store.notifyPath(['loading']);
const data = await fetchData();
// 실제 데이터로 단일 업데이트
store.setValue({ data, loading: false });
}2. 외부 시스템 통합
function setupWebSocket(store: IStore<AppState>) {
ws.onmessage = (event) => {
const state = store.getValue();
// 직접 변경 (vanilla JS)
state.messages.push(event.data);
// React에 알림
store.notifyPath(['messages']);
};
}3. 배치 알림
function updateMultipleMetrics() {
const state = store.getValue();
// 여러 속성 업데이트
state.ui.loading = false;
state.ui.progress = 100;
state.metrics.lastUpdate = Date.now();
// 단일 배치 알림
store.notifyPaths([
['ui', 'loading'],
['ui', 'progress'],
['metrics', 'lastUpdate']
]);
}RAF 배칭 동작
모든 notifyPath/notifyPaths 호출은 requestAnimationFrame에서 배치됩니다:
// 이 모든 호출은 단일 RAF 프레임으로 배치됨
store.notifyPath(['path1']);
store.notifyPath(['path2']);
store.notifyPaths([['path3'], ['path4']]);
// 결과: 단일 React 업데이트 사이클이벤트 루프 제어
패턴 1: Action Handler + notifyPath
문제: 로딩 + 데이터에 대한 여러 재렌더링
// ❌ 전통적: 2번 재렌더링
useActionHandler('loadUser', async (payload) => {
userStore.update(draft => { draft.loading = true; }); // 재렌더 1
const data = await fetchUser(payload.id);
userStore.update(draft => { // 재렌더 2
draft.user = data;
draft.loading = false;
});
});해결책: 수동 이벤트 제어
// ✅ 최적화: 1번 재렌더링 (50% 감소)
useActionHandler('loadUser', async (payload) => {
userStore.notifyPath(['loading']); // UI 업데이트만
const data = await fetchUser(payload.id);
userStore.update(draft => { // 단일 재렌더링
draft.user = data;
draft.loading = false;
});
});패턴 2: RefContext + notifyPath
직접 DOM 조작과 선택적 React 업데이트 결합:
function ProgressDashboard() {
const progressBar = useProgressRef('bar');
const dashboardStore = useAppStore('dashboard');
const updateProgress = useCallback((progress: number) => {
// 1단계: 직접 DOM (제로 React 오버헤드)
if (progressBar.target) {
progressBar.target.style.width = `${progress}%`;
}
// 2단계: 상태 구독자에게 알림
dashboardStore.notifyPath(['ui', 'progress']);
// 3단계: 영속성을 위한 store 업데이트
const state = dashboardStore.getValue();
state.ui.progress = progress;
}, [progressBar, dashboardStore]);
return (
<div>
<div ref={progressBar.setRef} className="progress-bar" />
<ProgressStats /> {/* 이것만 재렌더링 */}
</div>
);
}패턴 3: 무한 루프 방지
문제: 구독이 액션 트리거 → 액션이 store 업데이트 → 구독 트리거
// ❌ 무한 루프
useEffect(() => {
return store.subscribe(() => {
dispatch('onStoreChange', { data: store.getValue() });
});
}, []);
useActionHandler('onStoreChange', (payload) => {
store.setValue(processData(payload.data)); // 구독 트리거!
});해결책: notifyPath로 루프 끊기
// ✅ 해결책
useEffect(() => {
return store.subscribe(() => {
const value = store.getValue();
if (requiresProcessing(value)) {
dispatch('onStoreChange', { data: value });
}
});
}, []);
useActionHandler('onStoreChange', (payload) => {
const processed = processData(payload.data);
// 직접 변경 + 알림 (구독 트리거 없음)
const state = store.getValue();
state.processed = processed;
store.notifyPath(['processed']);
});성능 가이드라인
구독 전략
// ✅ Store: useStoreValue 사용
const userStore = createStore('user', { name: '' });
const user = useStoreValue(userStore);
// ✅ TimeTravelStore/MutableStore: useStorePath 사용
const editorStore = createTimeTravelStore('editor', { content: '' });
const content = useStorePath(editorStore, ['content']);
// ❌ 잘못됨: TimeTravelStore에 useStoreValue
const state = useStoreValue(editorStore); // 변경 감지 못함!업데이트 전략
// Store용 (불변)
userStore.setValue({ name: 'John', email: 'john@example.com' });
userStore.update(draft => { draft.name = 'John'; });
// TimeTravelStore/MutableStore용 (structural sharing)
editorStore.update(draft => {
draft.content = 'New content';
// ['content'] 경로만 재렌더링
});이벤트 제어 전략
// notifyPath 사용:
// 1. 로딩 상태
store.notifyPath(['loading']);
// 2. 외부 변경
const state = store.getValue();
state.prop = externalValue;
store.notifyPath(['prop']);
// 3. 배치 업데이트
store.notifyPaths([['path1'], ['path2']]);
// notifyPath 사용하지 않을 때:
// - 일반 상태 업데이트 (setValue/update 사용)
// - 전체 상태 구독이 필요한 경우베스트 프랙티스
1. 올바른 Store 타입 선택
// ✅ Store: 폼, 설정, 일반 상태
const settingsStore = createStore('settings', { theme: 'dark' });
// ✅ TimeTravelStore: Undo/redo 필요
const editorStore = createTimeTravelStore('editor', { content: '' });
// ✅ MutableStore 패턴: 고성능, undo/redo 불필요
const dashboardStore = createTimeTravelStore('dashboard', {
widgets: [...]
}, { mutable: true });2. 올바른 구독 패턴 사용
// ✅ Store
const value = useStoreValue(store);
// ✅ TimeTravelStore/MutableStore
const content = useStorePath(store, ['content']);
const cursor = useStorePath(store, ['cursor']);3. 성능을 위한 notifyPath 활용
// ✅ 재렌더링 감소
store.notifyPath(['loading']);
const data = await fetch();
store.setValue({ data, loading: false });
// ✅ 배치 알림
store.notifyPaths([['path1'], ['path2']]);
// ✅ 외부 통합
state.prop = externalValue;
store.notifyPath(['prop']);4. 무한 루프 방지
// ✅ 구독에서 notifyPath 사용
store.subscribe(() => {
const state = store.getValue();
state.processed = process(state.raw);
store.notifyPath(['processed']);
});
// ❌ 구독에서 setValue 사용하지 않음
store.subscribe(() => {
store.setValue(process(store.getValue())); // 루프!
});5. 최대 효율을 위한 패턴 결합
// ✅ RefContext + notifyPath + MutableStore
function OptimizedComponent() {
const domRef = useProgressRef('element');
const store = useDashboardStore('metrics');
const update = useCallback((value: number) => {
// 직접 DOM (제로 오버헤드)
if (domRef.target) {
domRef.target.style.width = `${value}%`;
}
// 구독자에게 알림
store.notifyPath(['progress']);
// 상태 영속화
const state = store.getValue();
state.progress = value;
}, [domRef, store]);
return <div ref={domRef.setRef} />;
}비즈니스 로직 분리
UI 컴포넌트와 상태 관리로부터 비즈니스 로직을 분리하는 것은 유지보수성, 테스트 가능성, 확장성에 매우 중요합니다.
핵심 원칙
- 순수 비즈니스 로직: React와 store 구현으로부터 독립적
- 상태 머신: 복잡한 비동기 프로세스를 위한 명시적 상태 전이
- 진행률 분리:
notifyPath를 사용하여 상태 변경과 진행률 업데이트 분리 - 모듈화 설계: 테스트 가능한 비즈니스 로직 모듈
패턴 개요
Context-Action 프레임워크는 다음을 통해 비즈니스 로직 분리를 지원합니다:
- 비즈니스 로직 모듈 - React와 독립적인 순수 클래스/함수
- 비동기 프로세스 상태 머신 - 상태 주도 비동기 작업
- 진행률 전용 업데이트 -
notifyPath를 통한 분리된 진행률 추적 - 모듈화 통합 - 조합 가능한 비즈니스 로직 모듈
- 상태 머신을 통한 에러 핸들링 - 강력한 에러 복구
- 다중 파일 큐 관리 - 복잡한 워크플로우
빠른 예시
// 순수 비즈니스 로직 모듈
class FileUploadService {
validateFile(file: File): { valid: boolean; error?: string } {
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
return { valid: false, error: '파일이 너무 큽니다' };
}
return { valid: true };
}
async uploadFile(file: File): Promise<UploadResult> {
// 순수 비동기 로직
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
return response.json();
}
}
// Action handler와 통합
function FileUploadLogic({ children }) {
const uploadService = useMemo(() => new FileUploadService(), []);
const fileStore = useFileStore('upload');
useFileActionHandler('uploadFile', useCallback(async (payload) => {
// 검증
const validation = uploadService.validateFile(payload.file);
if (!validation.valid) {
fileStore.setValue({ error: validation.error });
return;
}
// 비즈니스 로직 실행
try {
fileStore.setValue({ status: 'uploading', progress: 0 });
const result = await uploadService.uploadFile(payload.file);
fileStore.setValue({ status: 'complete', result });
} catch (error) {
fileStore.setValue({ status: 'error', error });
}
}, [uploadService, fileStore]));
return children;
}종합 가이드
상세한 패턴, 테스트 전략, 고급 예시는 다음을 참조하세요: 📘 비즈니스 로직 분리 가이드
이 전용 가이드는 다음을 다룹니다:
- 완전한 예시를 포함한 6가지 상세 패턴
- 독립적인 비즈니스 로직 테스트
- 성능 특성과 최적화
- Store 및 action과의 통합 전략
- 실제 구현 예시
비교 표
┌──────────────────────────┬───────────────┬──────────────────┬──────────────────┐
│ 기능 │ Store │ TimeTravelStore │ MutableStore │
├──────────────────────────┼───────────────┼──────────────────┼──────────────────┤
│ 구현 │ createStore │ createTimeTravel │ createTimeTravel │
│ 불변성 │ ✅ Deep Freeze │ ❌ Mutable Mode │ ❌ Mutable Mode │
│ Structural Sharing │ ❌ 없음 │ ✅ 있음 │ ✅ 있음 │
│ Undo/Redo │ ❌ 없음 │ ✅ 있음 (사용) │ ✅ 있음 (무시) │
│ notifyPath/notifyPaths │ ✅ 있음 │ ✅ 있음 │ ✅ 있음 │
│ useStoreValue() │ ✅ 작동 │ ❌ 업데이트 안됨 │ ❌ 업데이트 안됨 │
│ useStorePath() │ ✅ 작동 │ ✅ 필수 │ ✅ 필수 │
│ RAF Batching │ ✅ 있음 │ ✅ 있음 │ ✅ 있음 │
│ getValue() 복제 │ ✅ 기본 켜짐 │ ❌ 기본 꺼짐 │ ❌ 기본 꺼짐 │
│ 수동 이벤트 제어 │ ✅ 있음 │ ✅ 있음 │ ✅ 있음 │
│ 외부 변경 │ ⚠️ 안전하지 않음│ ✅ 안전 │ ✅ 안전 │
│ 사용 사례 │ 일반 상태 │ Undo/Redo 앱 │ 고성능 │
└──────────────────────────┴───────────────┴──────────────────┴──────────────────┘관련 문서
- Main Conventions - 전체 프레임워크 컨벤션
- Hooks Reference - 완전한 hooks 문서
- Architecture Guide - 프레임워크 아키텍처
- Infinite Loop Issues - 문제 해결 가이드