상태 관리

35줄 커스텀 스토어부터 400개 필드 AppState까지 — Redux, Zustand 없이 모든 컴포넌트를 동기화하는 방법.

01

개요

Claude Code의 상태 관리는 외부 라이브러리 없이 3개 레이어로 구성됩니다:

이 구조를 보완하는 두 가지 핵심 메커니즘:

02

createStore 패턴

전체 상태 관리의 기반이 되는 createStore<T>는 놀라울 정도로 간결합니다:

// createStore — 35줄 커스텀 스토어 (간소화)
function createStore<T>(
  initialState: T,
  onChange?: (prev: T, next: T) => void
) {
  let state = initialState
  const listeners = new Set<() => void>()

  return {
    getState(): T { return state },

    setState(updater: (prev: T) => T) {
      const next = updater(state)
      if (Object.is(state, next)) return  // 조기 반환!
      const prev = state
      state = next
      onChange?.(prev, next)
      listeners.forEach(l => l())
    },

    subscribe(listener: () => void) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    }
  }
}

핵심 설계 결정:

03

AppState 형태

AppState는 약 400개 필드를 가진 거대한 타입으로, 6개 논리 범주로 구성됩니다:

// AppState 타입 패턴 (간소화)
type AppState = DeepImmutable<{
  // 세션 코어
  sessionId: SessionId
  model: ModelId
  messages: Message[]
  totalCostUSD: number

  // UI 상태
  expandedView: boolean
  verbose: boolean
  inputMode: InputMode

  // 권한
  permissionMode: PermissionMode
  approvedTools: Set<string>
  // ... 약 390개 추가 필드
}> & {
  // 가변 필드 — DeepImmutable에서 제외
  mutableMessages: MutableMessage[]
}

DeepImmutable<...> & { 가변필드 } 패턴은 대부분의 상태를 불변으로 보호하면서, 성능상 가변성이 필요한 필드(예: 대화 히스토리의 스트리밍 업데이트)를 명시적으로 허용합니다.

04

onChangeAppState

onChangeAppStatecreateStoreonChange 콜백으로 등록되어 모든 상태 전환에서 실행되는 단일 관찰자입니다.

처리하는 사이드 이펙트:

딥 다이브 — onChangeAppState 도입 배경

이전에는 권한 모드를 변경하는 8개 이상의 코드 경로 중 2개만 CCR에 동기화하는 문제가 있었습니다. 일부 경로에서 권한 모드를 변경하면 UI에는 반영되지만 CCR에는 전파되지 않아, 실제 도구 실행 시 예상과 다른 권한이 적용되는 버그가 발생했습니다.

onChangeAppState를 도입하여 모든 상태 변경이 하나의 관찰자를 통과하도록 만들었습니다. 어떤 경로에서 권한 모드를 변경하든 자동으로 CCR에 동기화됩니다.

05

React 훅 레이어

React 컴포넌트가 AppState에 접근하는 세 가지 훅:

AppStateProvider는 이 스토어를 React 트리에 주입하는 얇은 브리지입니다. 동시에 notification, voice, mailbox, speculation 같은 React 전용 provider들은 따로 감싸며, 이런 상태를 전부 AppState로 밀어 넣지 않습니다. 즉 Provider 레이어는 "공용 도메인 상태"와 "UI 전용 보조 상태"를 나누는 경계 역할도 합니다.

주의사항 — 선택자 규칙

선택자가 매 호출마다 새 객체를 반환하면, 관련 없는 업데이트에도 매번 다시 렌더될 수 있습니다:

// 위험! 매번 새 객체 생성 → Object.is 항상 false → 스토어 변경마다 재렌더
useAppState(s => ({ a: s.a, b: s.b }))

// 안전한 대안 1: 개별 값 구독
const a = useAppState(s => s.a)
const b = useAppState(s => s.b)

// 안전한 대안 2: selectors.ts의 순수 selector 재사용

Object.is는 참조 비교이므로, { a: s.a, b: s.b }는 값이 같더라도 매번 새 객체이므로 항상 불일치로 판단됩니다. 그래서 인라인 객체/배열 selector보다는 primitive를 직접 고르거나, selectors.ts에 둔 재사용 가능한 순수 selector를 쓰는 쪽이 맞습니다.

06

선택자 & 전환 헬퍼

selectors.tsPick<AppState, ...>를 받는 순수 함수 모음입니다. 전체 AppState가 아닌 필요한 필드만 받기 때문에:

여기서 중요한 실무 규칙은, selector가 "새 구조를 조립하는 곳"이 아니라 "이미 있는 상태에서 읽어내는 곳"에 가깝다는 점입니다. 새 객체를 만들어야 한다면 호출부 인라인보다 별도 selector 함수로 빼서 의존 필드를 명확히 드러내는 편이 낫습니다.

teammateViewHelpers.ts는 팀메이트 에이전트의 유지/퇴거 생명주기를 관리하는 전환 헬퍼입니다. 팀메이트의 존재 여부와 상태 전이는 React 화면만의 문제가 아니라 작업 큐, 에이전트 상태, 비React 소비자도 함께 읽어야 하는 도메인 정보이므로, 단순 UI Context가 아니라 상태 전환 헬퍼 쪽에 가깝게 배치됩니다.

07

전체 데이터 흐름 다이어그램 (Full Data Flow Diagram)

상태 관리의 전체 데이터 흐름을 하나의 다이어그램으로 정리합니다. 사용자 인터랙션 또는 API 응답이 스토어를 업데이트하고, 사이드 이펙트를 거쳐 UI에 반영되기까지의 과정입니다.

flowchart LR A["사용자 액션 / API 응답"] --> B["store.setState(updater)"] B --> C{"Object.is 체크"} C -->|"같은 참조"| D["조기 반환 — 변경 없음"] C -->|"다른 참조"| E["state = next"] E --> F["onChangeAppState(prev, next)"] F --> G["사이드 이펙트 실행"] G --> G1["CCR 동기화"] G --> G2["설정 영속화"] G --> G3["모델 저장"] E --> H["listeners.forEach(l => l())"] H --> I["React useSyncExternalStore"] I --> J["컴포넌트 재렌더"]

핵심 포인트: setState는 항상 Object.is로 변경 여부를 판단한 후에만 onChangeAppState와 리스너 통지를 수행합니다. 이 체크가 불필요한 사이드 이펙트와 재렌더를 원천 차단하는 게이트키퍼 역할을 합니다.

08

컨텍스트 vs 상태 (Context vs State)

Claude Code에서 모든 공유 데이터가 AppState에 사는 것은 아닙니다. React Context에 배치되는 데이터와 AppState에 배치되는 데이터의 경계를 이해하는 것이 중요합니다.

React Context에 사는 데이터

아래 데이터는 AppState가 아닌 전용 React Context에 배치됩니다:

경계 판단 기준

AppState에 배치되는 데이터와 Context에 배치되는 데이터의 경계는 다음 기준으로 결정됩니다:

정리하면, notification이나 speculation은 "앱 전체가 알아야 하는 도메인 상태"라기보다 특정 Provider가 소유한 UI 협조 상태입니다. 반대로 permissionMode, model, teammate 상태 같은 값은 React 밖에서도 읽히고, 선택자와 전환 헬퍼의 대상이 되므로 AppState에 남습니다.

// AppState — 영속적, React 외부 접근, 사이드 이펙트 필요
type AppState = {
  sessionId: SessionId           // 세션 코어 — 영속
  permissionMode: PermissionMode // 권한 — CCR 동기화 필요
  model: ModelId                 // 설정 — 디스크 저장 필요
  messages: Message[]            // 대화 히스토리 — 백엔드 소비
}

// React Context — 일시적, UI 전용, 사이드 이펙트 불필요
const ModalContext = createContext<ModalState>(...)
const NotificationContext = createContext<Notification[]>(...)
const VoiceContext = createContext<VoiceState>(...)
주의사항 — 잘못된 배치의 위험

일시적 UI 데이터(예: 모달 표시 여부)를 AppState에 넣으면, 모든 모달 열기/닫기가 onChangeAppState를 트리거하여 불필요한 CCR 동기화나 디스크 쓰기를 유발합니다. 반대로 영속이 필요한 데이터를 Context에 넣으면 세션 복원 시 유실됩니다. 경계를 올바르게 판단하는 것이 성능과 정확성 모두에 영향을 미칩니다.

09

핵심 요약

핵심 포인트

  • 상태 관리는 3개 레이어로 구성됩니다: Primitive(createStore, 35줄) → Domain(AppState + AppStateStore) → React(Provider + hooks)
  • createStoreObject.is 동등성 체크로 같은 참조 반환 시 업데이트/사이드 이펙트/리스너 통지를 모두 건너뜁니다
  • AppStateDeepImmutable<...> & { 가변필드 } 패턴으로 불변성과 성능을 균형 잡습니다
  • onChangeAppState는 모든 상태 전환의 단일 관찰자로, 8개 이상의 변경 경로에서 2개만 동기화하던 문제를 해결합니다
  • useAppState(s => ({...}))처럼 새 객체를 반환하는 선택자는 Object.is 참조 비교로 인해 스토어 변경마다 불필요한 재렌더를 유발합니다
  • AppStateStore.ts.tsx가 아닌 .ts인 이유는 비React 소비자가 React 의존성 없이 임포트할 수 있도록 하기 위해서입니다
  • 선택자는 Pick<AppState, ...>를 받는 순수 함수로, 테스트와 재사용이 용이합니다
10

지식 확인

퀴즈 — 5문제

Q1. updater가 같은 참조를 반환할 때 setState의 동작은?

  • A) 안전을 위해 리스너를 통지
  • B) 조기 반환 — 업데이트, 사이드 이펙트, 리스너 모두 없음
  • C) onChange를 호출하되 리스너는 건너뜀
  • D) 개발 모드에서 경고 출력
Object.is(state, next)true이면 즉시 반환합니다. 상태 변경, onChange 콜백, 리스너 통지 어느 것도 실행되지 않아 불필요한 재렌더를 원천 차단합니다.

Q2. AppStateStore.ts.tsx가 아닌 .ts인 이유는?

  • A) 원래 JavaScript 파일이었기 때문
  • B) .ts가 더 빠르기 때문
  • C) 비React 소비자가 React 의존성 없이 임포트할 수 있도록
  • D) TypeScript가 요구하기 때문
.tsx 파일은 JSX를 포함할 수 있어 React 관련 타입 체크가 활성화됩니다. .ts로 유지하면 Node.js 백엔드 로직이나 테스트 등 React 없는 환경에서도 의존성 없이 스토어를 임포트할 수 있습니다.

Q3. useAppState(s => ({ a: s.a, b: s.b }))의 위험은?

  • A) 런타임 오류가 발생
  • B) Object.is 비교에서 새 객체는 항상 불일치 — 모든 업데이트마다 재렌더
  • C) 메모리 누수가 발생
  • D) 컴파일 오류가 발생
선택자가 매번 { a: s.a, b: s.b }라는 새 객체를 생성하면, 값이 동일하더라도 Object.is는 참조가 다르므로 항상 false를 반환합니다. 결과적으로 어떤 상태 변경이든 이 컴포넌트의 재렌더를 트리거합니다.

Q4. onChangeAppState 도입 전 권한 모드 동기화의 문제는?

  • A) 동기 대신 비동기로 처리됨
  • B) 8개 이상의 변경 경로 중 2개만 CCR에 동기화
  • C) globalConfig에 저장됨
  • D) SDK에 노출되지 않음
권한 모드를 변경하는 경로가 8개 이상 있었지만, 그 중 2개만 CCR에 동기화를 수행했습니다. 나머지 경로에서 변경하면 UI와 CCR이 불일치하여 예상과 다른 권한이 적용되는 버그가 발생했습니다.

Q5. 권한 모드가 default에서 bubble로 변경될 때 onChangeAppState의 동작은?

  • A) CCR과 SDK 모두에 통지
  • B) 둘 다 통지하지 않음
  • C) SDK만 통지 — toExternalPermissionModebubbledefault로 매핑
  • D) 오류가 발생
toExternalPermissionMode()는 내부 권한 모드를 외부 API에서 인식 가능한 모드로 매핑합니다. bubbledefault로 매핑되므로, 외부 관점에서는 변경이 없어 CCR과 SDK 모두 통지하지 않습니다.
0 / 5