# 03. 상태 관리 패턴 가이드

## 초심자용 한눈에 보기

이 문서는 화면에 보이는 상태(입력값, 조회 결과, 로딩 등)를 어디에 둘지 정리합니다.

### 핵심 용어 빠르게 정리

| 용어          | 쉬운 뜻                                       |
| ------------- | --------------------------------------------- |
| `상태(state)` | 지금 화면에서 중요한 현재 값                  |
| `캐시`        | 같은 값 재사용을 위해 잠깐 저장해 두는 보관소 |
| `mutation`    | 서버에 데이터를 바꾸는 요청(저장/수정/삭제)   |
| `rollback`    | 실패 시 변경 전 상태로 되돌리는 처리          |
| `URL 상태`    | 주소창 파라미터로 관리되는 상태               |

| 분류            | 핵심 기술                                                                                                                                   | 상태          | Stable    |
| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :------------ | :-------- |
| **연관 가이드** | [02. React 19](./02_React19_실무_가이드.md), [05. API 통신](./05_API_통신_및_모킹_가이드.md), [08. 성능 최적화](./08_성능_최적화_가이드.md) | **도구 원칙** | 벤더 중립 |
| **핵심 테마**   | Server vs Client State, Slice Pattern, URL as State, TC39 Signals, Standard Schema                                                          | **Update**    | 최신 기준 |

---

> **"상태 관리는 어디에 저장할지가 아니라, 어떻게 동기화할지의 문제다."**
> 본 가이드는 서버 상태(Server State)와 UI 상태(UI State)를 명확히 분리하고, 가장 효율적인 업데이트 라이프사이클을 설계하는 표준을 제시합니다.
>
> **현재 공식 문서 기준 라이브러리 버전**
>
> - **TanStack Query v5** (안정): React Suspense 1급 시민, `streamedQuery`, 부분 dehydration, Pending Query 스트리밍(v5.40+)
> - **Zustand v5** (안정, React 18+ 전용 / 네이티브 `useSyncExternalStore` 사용)
> - **Jotai v2** (안정): atom 캐싱/성능 개선, `atomWithStorage` 등 utils
> - **XState v5** (안정): "actor" 중심 모델, `setup()` 패턴, `actor.select()` 추가
> - **TC39 Signals Proposal**: 현재 공식 문서 기준 **Stage 1** (Stage 2 진입 논의 중). 표준화 전까지는 라이브러리별 Signal API 채택은 신중히.

---

## 추천 항목 (실무 우선순위)

- **시작 추천**: 상태는 `UI 상태`/`서버 상태`/`라우터 상태`로 소유권을 먼저 나눕니다.
- **안정 추천**: 캐시 무효화 기준과 rollback 조건을 한쪽으로 모아 팀 룰로 지정하세요.
- **운영 추천**: `mutation` 실패 시 사용자 체감이 심한 경로부터 복원 버튼/토스트 UX를 먼저 운영하세요.

## 추천 항목 고도화 체크

- `첫 적용` — 서버 상태, URL state, UI state 분리 중 하나를 실제 PR이나 운영 이슈에 붙이고, 변경 전 기준을 먼저 적는다.
- `증거 정리` — 쿼리 키 목록, optimistic update 테스트, URL 복원 테스트를 같은 작업 기록에 남긴다.
- `재점검` — 상태 중복 수, stale cache 이슈, 뒤로가기 회귀가 나아졌는지 30일 안에 확인하고 기준을 유지, 수정, 폐기 중 하나로 판정한다.

## 추천 항목 실행 기록 템플릿

- `작업` : 서버 상태, URL state, UI state 분리 적용 범위를 어느 화면, 패키지, 문서에 둘지 적는다.
- `증거` : 쿼리 키 목록, optimistic update 테스트, URL 복원 테스트 중 실제로 남긴 항목만 링크한다.
- `판정` : 유지/수정/폐기 중 하나와 이유를 한 문장으로 남긴다.
- `다음 점검` : 상태 중복 수, stale cache 이슈, 뒤로가기 회귀를 다시 볼 날짜와 담당자를 지정한다.

## 문서 책임 범위

## 0. 먼저 알고 가기 (30초 요약)

이 문서를 급하게 훑어볼 때는 아래 세 가지만 먼저 기억하세요.

1. **상태는 ‘누가 소유하는지’로 나눈다.**
   - 서버에서 온 데이터는 서버 상태로, 화면 조작은 UI 상태로, 공유가 필요한 값은 URL 상태로 분리합니다.
2. **캐시는 빠름이 목적이 아니라 정합성이 목적이다.**
   - mutation이 성공/실패했을 때 어떤 캐시를 새로고침할지 미리 적어둡니다.
3. **실패 복구(rollback)가 없는 낙관적 업데이트는 위험하다.**
   - 사용자가 “저장됨”처럼 보이는 순간, 오류가 나면 원래 값으로 확실히 복구되어야 합니다.

## 쉬운 말로 보는 용어 정리 (이 문서 중심)

| 용어                 | 평소 말투로 바꿔 말하면                       |
| -------------------- | --------------------------------------------- |
| `Server State`       | API에서 들어온 데이터 상태                    |
| `UI State`           | 지금 화면 안에서 쓰이는 상태                  |
| `query key`          | 같은 종류의 데이터를 불러오고 관리하는 이름표 |
| `cache invalidation` | 예전 캐시를 지우고 새로 받는 행위             |
| `optimistic update`  | 결과가 오기 전에 일단 화면을 먼저 바꾸는 방식 |

| 이 문서가 결정하는 것                                   | 단일 출처로 따르는 문서                                                                         |
| :------------------------------------------------------ | :---------------------------------------------------------------------------------------------- |
| 서버 상태, URL 상태, UI 상태, 전역 상태의 소유권 분류   | [04. 아키텍처](./04_아키텍처_설계_패턴.md), [05. API 통신](./05_API_통신_및_모킹_가이드.md)     |
| query key, cache invalidation, optimistic rollback 기준 | [05. API 통신](./05_API_통신_및_모킹_가이드.md), [09. 관측성](./09_장애_대응_및_관측성_표준.md) |
| 상태 변경이 성능과 UX에 미치는 영향 검증                | [08. 성능](./08_성능_최적화_가이드.md), [07. 테스팅](./07_테스팅_가이드.md)                     |
| AI가 제안한 상태 분리/통합안의 검증 책임                | [18. AI 개발 워크플로우](./18_AI_개발_워크플로우_종합.md)                                       |

---

## 0. 모든 프론트엔드 그룹 공통 Baseline

상태 관리는 라이브러리 선택보다 **상태의 소유권과 동기화 규칙**을 먼저 표준화합니다.

| 기준                 | 최소 적용                                                                                                  |
| :------------------- | :--------------------------------------------------------------------------------------------------------- |
| **상태 분류**        | 서버 상태, URL 상태, 폼 상태, 세션 상태, UI 상태를 구분하고 저장 위치를 문서화합니다.                      |
| **서버 상태 원칙**   | 서버에서 온 데이터는 전역 클라이언트 스토어에 복사하지 않고, Query cache 같은 서버 상태 도구가 소유합니다. |
| **URL 우선 원칙**    | 검색어, 필터, 정렬, 페이지 번호처럼 공유/복원되어야 하는 상태는 URL에 둡니다.                              |
| **캐시 무효화 규칙** | mutation마다 영향을 받는 query key, 낙관적 업데이트, 실패 시 rollback을 명시합니다.                        |
| **전역 스토어 제한** | 인증 세션, 앱 설정, cross-page UI처럼 진짜 전역인 상태만 전역 스토어에 둡니다.                             |
| **관측 가능성**      | 상태 변경으로 인한 주요 장애는 로그/트레이스/사용자 행동 재현으로 추적할 수 있어야 합니다.                 |

### 0.0 상태 계층 분리 흐름

```mermaid
flowchart LR
  A[사용자 동작] --> B{상태 종류}
  B -->|URL 공유/복원 필요| C[URL State]
  B -->|서버 데이터| D[Server State Cache]
  B -->|폼/컴포넌트 로컬| E[UI State]
  B -->|인증/전역 설정| F[Global Store]
  C --> G[라우터 동기화]
  D --> H[QueryClient / 캐시]
  H --> I[컴포넌트 렌더]
  E --> I
  F --> I
  I --> J{실패/타임아웃}
  J -->|재시도| D
  J -->|복구 불가| E
```

### 0.1 교차 검증 매트릭스

| 권고                                                 | 1차 출처                                                 | 실행 증거                                   | 운영 증거                             | 철회 조건                                                             |
| :--------------------------------------------------- | :------------------------------------------------------- | :------------------------------------------ | :------------------------------------ | :-------------------------------------------------------------------- |
| 서버 상태를 전역 클라이언트 스토어에 복사하지 않는다 | 서버 상태 라이브러리 공식 문서, React Suspense/캐시 정책 | query key factory, mutation rollback 테스트 | 중복 fetch, stale UI, cache hit ratio | 전역 스토어가 서버 원본보다 일관성을 더 잘 보장한다는 근거가 있을 때  |
| 공유/복원 가능한 상태는 URL에 둔다                   | WHATWG URL/History, 라우터 공식 문서                     | deep link E2E, back/forward 테스트          | 검색/필터 이탈률, support ticket      | 개인정보/보안 정보가 URL에 노출될 위험이 있을 때                      |
| mutation은 무효화 범위와 rollback을 함께 정의한다    | 데이터 동기화 도구 공식 문서                             | 실패 시 optimistic rollback 테스트          | mutation 실패율, 재시도 후 불일치율   | 서버가 idempotency와 conflict resolution을 보장하지 않을 때           |
| Signals 계열 API는 표준화 전까지 실험으로 둔다       | TC39 proposal stage, 프레임워크 공식 roadmap             | adapter 격리, fallback 구현                 | re-render cost, migration cost        | TC39 stage/브라우저/프레임워크 지원이 production baseline에 도달할 때 |

### 0.2 운영 게이트

| Gate                      | Evidence                                | Owner         | Rollback                                 |
| :------------------------ | :-------------------------------------- | :------------ | :--------------------------------------- |
| State classification gate | state inventory, owner map              | Feature owner | 전역 store 변경 revert, local state 복원 |
| Query key gate            | query key factory, invalidation test    | API/FE owner  | affected query invalidation 수동 추가    |
| Optimistic update gate    | failure replay test, rollback assertion | Feature owner | optimistic path flag off                 |
| URL state gate            | deep link/back-forward E2E              | Route owner   | URL sync 제거 후 local state로 축소      |

---

## 1. 서버 상태 관리: TanStack Query v5

### 왜 중요한가

서버 데이터는 로컬 스토어(Zustand 등)에 복사하지 마세요. 캐싱과 비동기 처리는 TanStack Query에 전적으로 맡깁니다. 서버 상태의 특성(비동기, 공유, 캐시 만료 등)에 최적화된 전용 도구를 쓰는 것이 핵심입니다.

> **일상 비유**: 도서관 사서가 있는데 굳이 책 사본을 자기 가방에도 들고 다니면 정보가 어긋납니다. 사서(서버)가 보유한 책(데이터)의 사본을 매번 끄집어내 가방(전역 store)에 넣지 말고, 사서에게 그때그때 요청(query)하세요.

> **v5 라인 핵심 변화**: (1) Suspense 1급 지원(`useSuspenseQuery`/`useSuspenseInfiniteQuery`/`useSuspenseQueries`), (2) API 단일 객체 인자로 통일, (3) Infinite Query `maxPages` 옵션, (4) Pending Query를 dehydrate하여 SSR 스트리밍 가능 (v5.40+), (5) `streamedQuery`로 ReadableStream/SSE를 캐시에 누적 가능.

#### 서버 vs 클라이언트 vs URL 상태 결정 트리

```mermaid
flowchart TD
  A[상태가 어디서 왔나?] --> B{서버가 진실의 원천?}
  B -- 예 --> C[Server State<br/>TanStack Query 캐시]
  B -- 아니오 --> D{공유/북마크/뒤로가기가 필요?}
  D -- 예 --> E[URL State<br/>useSearchParams/nuqs]
  D -- 아니오 --> F{여러 라우트에서 공유?}
  F -- 예 --> G{진짜 전역?<br/>인증/테마/플래그}
  G -- 예 --> H[Global Store<br/>Zustand 슬라이스]
  G -- 아니오 --> I[Context 또는 부모 lift]
  F -- 아니오 --> J[Local UI State<br/>useState/useReducer]
  C -. mutation .-> K[invalidate + optimistic]
  E -. 동기화 .-> L[router push/replace]
```

#### staleTime/gcTime 상태 변화

```mermaid
stateDiagram-v2
  [*] --> Fetching: queryFn 실행
  Fetching --> Fresh: 성공 응답
  Fresh --> Stale: staleTime 경과
  Stale --> BackgroundRefetch: 마운트 또는 focus
  BackgroundRefetch --> Fresh: 새 데이터 도착
  Fresh --> Inactive: 컴포넌트 언마운트
  Stale --> Inactive: 컴포넌트 언마운트
  Inactive --> [*]: gcTime 경과<br/>(가비지 컬렉션)
  Inactive --> Fresh: 같은 key 재구독<br/>(아직 GC 전)
  Fetching --> ErrorState: 실패
  ErrorState --> Fetching: retry 또는 invalidate
```

### 1.1 staleTime vs gcTime 이해하기

이 두 값의 차이를 정확히 이해하면 불필요한 네트워크 요청과 메모리 낭비를 함께 줄일 수 있습니다.

```
시간 흐름 ──────────────────────────────────────────────────────►

[쿼리 fetch 완료]
  │
  ├── staleTime (5분) ──────────────────┐
  │   이 구간: 데이터가 "fresh" 상태     │
  │   → 같은 queryKey로 요청해도        │
  │     네트워크 요청 없이 캐시 반환      │
  │                                      │
  │                                      ▼
  │                            데이터가 "stale" 상태로 전환
  │                            → 컴포넌트 마운트 시 백그라운드 refetch 발생
  │                            → 기존 캐시를 먼저 보여주고, 새 데이터로 교체
  │
  ├── gcTime (1시간) ─────────────────────────────────────────┐
  │   이 구간: 캐시가 메모리에 유지됨                           │
  │   → 컴포넌트 언마운트 후에도 캐시 보존                      │
  │   → 다시 마운트하면 즉시 캐시 데이터 표시                    │
  │                                                            ▼
  │                                              캐시가 가비지 컬렉션됨
  │                                              → 다음 요청 시 처음부터 fetch
  └────────────────────────────────────────────────────────────┘
```

**핵심 요약:**

- `staleTime`: "얼마나 오래 신선한 것으로 간주할까?" → 네트워크 요청 빈도 제어
- `gcTime`: "미사용 캐시를 메모리에 얼마나 유지할까?" → 메모리 사용량 제어
- `staleTime < gcTime` 관계를 유지합니다. 그렇지 않으면 gcTime이 의미를 잃습니다.

### 1.2 전역 캐시 전략 (Global Cache Settings)

```typescript
// app/providers.tsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5분간 데이터를 '신선'하다고 간주
      gcTime: 1000 * 60 * 60, // 1시간 후 미사용 캐시 삭제
      retry: 1, // 실패 시 1회만 재시도
      refetchOnWindowFocus: false,
    },
  },
})
```

### 1.3 queryKey 팩토리 패턴

queryKey를 문자열 배열로 직접 작성하면 오타, 불일치, 무효화 실수가 생깁니다. 팩토리 패턴으로 중앙 관리하세요.

```typescript
// queries/keys.ts — 도메인별 queryKey 팩토리
export const productKeys = {
  // 최상위 키: 상품 관련 모든 쿼리 무효화 시 사용
  all: ['products'] as const,
  // 목록 계열 키
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
  // 상세 계열 키
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: string) => [...productKeys.details(), id] as const,
}

export const userKeys = {
  all: ['users'] as const,
  me: () => [...userKeys.all, 'me'] as const,
  detail: (id: string) => [...userKeys.all, 'detail', id] as const,
}
```

**사용 예시:**

```typescript
// 커스텀 훅에서 사용
export function useProductDetail(productId: string) {
  return useSuspenseQuery({
    queryKey: productKeys.detail(productId),
    queryFn: () => fetchProduct(productId),
  })
}

export function useProductList(filters: ProductFilters) {
  return useSuspenseQuery({
    queryKey: productKeys.list(filters),
    queryFn: () => fetchProductList(filters),
  })
}

// 캐시 무효화 시 계층적으로 사용
queryClient.invalidateQueries({ queryKey: productKeys.all }) // 상품 관련 전체 무효화
queryClient.invalidateQueries({ queryKey: productKeys.lists() }) // 목록만 무효화
queryClient.invalidateQueries({ queryKey: productKeys.detail('1') }) // 특정 상품만 무효화
```

#### queryKey 계층과 무효화 범위

> **일상 비유**: 도서관 청구기호처럼 `[도서관][코너][책장][책]`으로 분류해 두면, "전체 도서관 정리" 한 번에 모든 책이 정리되고, "특정 코너 정리"는 그 코너만 정리됩니다. queryKey의 prefix 매칭도 같은 방식입니다.

이 그림은 queryKey 팩토리에서 생성된 키들이 어떻게 부분 매칭되어 무효화 범위가 결정되는지 보여줍니다.

```mermaid
flowchart TD
  Root[productKeys.all<br/>products]
  Root --> ListsRoot[productKeys.lists<br/>products, list]
  Root --> DetailsRoot[productKeys.details<br/>products, detail]
  ListsRoot --> L1[productKeys.list filterA<br/>products, list, A]
  ListsRoot --> L2[productKeys.list filterB<br/>products, list, B]
  DetailsRoot --> D1[productKeys.detail 1<br/>products, detail, 1]
  DetailsRoot --> D2[productKeys.detail 2<br/>products, detail, 2]

  Inv1[invalidate all] -. matches .-> Root
  Inv1 -. cascade .-> ListsRoot
  Inv1 -. cascade .-> DetailsRoot
  Inv2[invalidate lists] -. matches .-> ListsRoot
  Inv2 -. cascade .-> L1
  Inv2 -. cascade .-> L2
  Inv3[invalidate detail 1] -. exact .-> D1
```

### 1.4 서버 상태 호출 패턴 (Custom Hook)

```typescript
export function useProductDetail(productId: string) {
  return useSuspenseQuery({
    queryKey: productKeys.detail(productId),
    queryFn: () => fetchProduct(productId),
  })
}
```

### 1.5 useMutation과 낙관적 업데이트 (Optimistic Update)

서버에 데이터를 보내기 전에 UI를 먼저 업데이트하여 즉각적인 반응성을 제공합니다. 실패 시 자동 롤백됩니다.

> **일상 비유**: 신용카드 결제는 일단 "승인 표시"부터 보여주고, 뒤에서 진짜 결제 처리를 합니다. 실패하면 영수증이 취소로 정정됩니다. 낙관적 업데이트도 동일한 신호 우선·정정 후행 패턴입니다.

#### onMutate -> onError -> onSettled 시퀀스

```mermaid
sequenceDiagram
  autonumber
  participant UI as 컴포넌트
  participant QC as QueryClient
  participant Cache as 캐시(detail)
  participant API as 서버

  UI->>QC: mutate(updatedProduct)
  QC->>QC: onMutate 실행
  QC->>Cache: cancelQueries(detail)
  QC->>Cache: getQueryData (이전값 백업)
  QC->>Cache: setQueryData (낙관적 값)
  QC-->>UI: 화면 즉시 갱신
  QC->>API: mutationFn 호출
  alt 성공
    API-->>QC: 응답 OK
    QC->>QC: onSuccess
  else 실패
    API-->>QC: 에러
    QC->>Cache: setQueryData(previous) — 롤백
    QC->>QC: onError 토스트/로그
  end
  QC->>Cache: invalidateQueries(detail) (onSettled)
  Cache->>API: background refetch
  API-->>Cache: 최신 데이터
  Cache-->>UI: 최종 동기화
```

```typescript
// hooks/useUpdateProduct.ts
export function useUpdateProduct() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (updatedProduct: Product) =>
      api.put(`/products/${updatedProduct.id}`, updatedProduct),

    // 1. 낙관적 업데이트: 서버 응답 전에 UI를 먼저 갱신
    onMutate: async (updatedProduct) => {
      // 진행 중인 refetch를 취소하여 낙관적 업데이트를 덮어쓰지 않도록 함
      await queryClient.cancelQueries({
        queryKey: productKeys.detail(updatedProduct.id),
      })

      // 현재 캐시 데이터를 백업 (롤백용)
      const previousProduct = queryClient.getQueryData<Product>(
        productKeys.detail(updatedProduct.id),
      )

      // 캐시를 낙관적으로 업데이트
      queryClient.setQueryData(productKeys.detail(updatedProduct.id), updatedProduct)

      // 롤백을 위한 context 반환
      return { previousProduct }
    },

    // 2. 실패 시: 백업 데이터로 롤백
    onError: (_error, updatedProduct, context) => {
      if (context?.previousProduct) {
        queryClient.setQueryData(productKeys.detail(updatedProduct.id), context.previousProduct)
      }
    },

    // 3. 성공/실패 무관하게: 서버와 캐시 동기화 보장
    onSettled: (_data, _error, updatedProduct) => {
      queryClient.invalidateQueries({
        queryKey: productKeys.detail(updatedProduct.id),
      })
    },
  })
}
```

### 1.6 에러 및 로딩 처리 패턴

TanStack Query와 React Suspense/ErrorBoundary를 결합하면 선언적으로 처리합니다.

```tsx
// 선언적 에러/로딩 처리 (권장)
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

function ProductPage({ productId }: { productId: string }) {
  return (
    <ErrorBoundary
      fallback={<ErrorFallback />}
      onReset={() => {
        // 에러 복구 시 캐시 초기화
        queryClient.invalidateQueries({ queryKey: productKeys.detail(productId) })
      }}
    >
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetail productId={productId} />
      </Suspense>
    </ErrorBoundary>
  )
}

// ProductDetail 내부에서는 로딩/에러 분기 없이 데이터만 사용
function ProductDetail({ productId }: { productId: string }) {
  const { data: product } = useProductDetail(productId) // Suspense가 로딩 처리

  return <div>{product.name}</div>
}
```

```tsx
// 명시적 에러/로딩 처리 (Suspense를 쓸 수 없는 경우)
function ProductDetail({ productId }: { productId: string }) {
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: productKeys.detail(productId),
    queryFn: () => fetchProduct(productId),
  })

  if (isLoading) return <ProductSkeleton />
  if (isError) return <ErrorMessage error={error} onRetry={refetch} />

  return <div>{data.name}</div>
}
```

---

## 2. 클라이언트 상태 관리: Zustand 슬라이스 패턴

대규모 프로젝트에서는 하나의 거대한 스토어 대신, 도메인별로 **Slice**를 나누어 관리하는 것이 유지보수에 유리합니다.

> **Zustand v5 변경점 (2024년 말 GA, 현재 공식 문서 기준 안정)**
>
> - React 18+ 만 지원. 내부 `use-sync-external-store` shim을 제거하고 React 내장 `useSyncExternalStore` 사용.
> - 셀렉터를 통한 객체 반환은 **얕은 비교를 명시적으로 켜야 함** → `useShallow` 사용을 강제(아래 4.2 참고).
> - `createStore` (React 비종속 코어)와 `useStore`(훅) 분리가 정착되어, **vanilla 스토어 → React 어댑터** 패턴이 권장됩니다.
> - persist 미들웨어가 동시 rehydrate 시 race condition을 방지하도록 개선됨(v5.0.10).

### 2.1 왜 슬라이스 > 모놀리스 스토어인가?

> **일상 비유**: 모놀리스 스토어는 모든 부서가 하나의 큰 사물함을 공유하는 사무실과 같습니다. 누가 무엇을 꺼냈는지 파악하기도 어렵고, 충돌도 잦습니다. 슬라이스 패턴은 각 부서별로 사물함을 나눠 쓰는 방식 — 누가 어디서 무엇을 쓰는지 분명하고, 정리도 쉽습니다.

#### 슬라이스 스토어 의존성 도식

이 그림은 슬라이스 단위로 분리된 스토어가 어떻게 결합되어 하나의 통합 store를 이루는지 보여줍니다.

```mermaid
flowchart TD
  subgraph Slices [도메인별 슬라이스]
    A[AuthSlice<br/>user, login, logout]
    C[CartSlice<br/>items, addItem]
    T[ThemeSlice<br/>theme, toggleTheme]
    N[NotificationSlice<br/>messages, push]
  end
  subgraph Composition [통합 스토어]
    B[useBoundStore<br/>AuthSlice + CartSlice + ThemeSlice + NotificationSlice]
  end
  A --> B
  C --> B
  T --> B
  N --> B
  B --> X[컴포넌트 selector<br/>s -> s.user]
  B --> Y[컴포넌트 selector<br/>s -> s.items.length]
  B --> Z[컴포넌트 selector<br/>useShallow]
```

| 비교 항목       | 모놀리스 스토어                         | 슬라이스 패턴                      |
| :-------------- | :-------------------------------------- | :--------------------------------- |
| **파일 크기**   | 하나의 파일이 수백 줄로 비대해짐        | 도메인별 50~100줄의 작은 파일      |
| **팀 협업**     | 머지 충돌 빈발                          | 도메인별로 분리되어 충돌 최소화    |
| **테스트**      | 전체 스토어를 모킹해야 함               | 슬라이스 단위로 독립 테스트 가능   |
| **타입 안전성** | 타입이 복잡하게 얽힘                    | 슬라이스별 인터페이스 명확         |
| **코드 삭제**   | 어떤 상태가 어디서 쓰이는지 추적 어려움 | 미사용 슬라이스를 통째로 제거 가능 |

### 2.2 스토어 슬라이스 구조화

```typescript
import { create, StateCreator } from 'zustand'

// --- 슬라이스 인터페이스 정의 ---
interface AuthSlice {
  user: User | null
  login: (user: User) => void
  logout: () => void
}

interface CartSlice {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (itemId: string) => void
  clearCart: () => void
}

// --- StateCreator를 사용하여 슬라이스 정의 ---
const createAuthSlice: StateCreator<AuthSlice & CartSlice, [], [], AuthSlice> = (set) => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null }),
})

const createCartSlice: StateCreator<AuthSlice & CartSlice, [], [], CartSlice> = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (itemId) => set((state) => ({ items: state.items.filter((i) => i.id !== itemId) })),
  clearCart: () => set({ items: [] }),
})

// --- 통합 스토어 생성 ---
export const useBoundStore = create<AuthSlice & CartSlice>()((...a) => ({
  ...createAuthSlice(...a),
  ...createCartSlice(...a),
}))
```

### 2.3 미들웨어 활용: persist, devtools

Zustand의 미들웨어를 사용하면 상태 영속화, 디버깅 등을 선언적으로 추가할 수 있습니다.

```typescript
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface ThemeSlice {
  theme: 'light' | 'dark'
  toggleTheme: () => void
}

// persist: 새로고침 후에도 상태 유지 (localStorage 기본)
// devtools: Redux DevTools에서 상태 변화 추적 가능
export const useThemeStore = create<ThemeSlice>()(
  devtools(
    persist(
      (set) => ({
        theme: 'light',
        toggleTheme: () =>
          set(
            (state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' }),
            undefined,
            'toggleTheme', // devtools에 표시될 액션 이름
          ),
      }),
      {
        name: 'theme-storage', // localStorage key 이름
        // 특정 필드만 저장하고 싶을 때
        partialize: (state) => ({ theme: state.theme }),
      },
    ),
    { name: 'ThemeStore' }, // devtools에 표시될 스토어 이름
  ),
)
```

### 2.4 Immer 통합: 복잡한 중첩 상태 업데이트

깊이 중첩된 객체를 불변성을 유지하며 업데이트하는 것은 번거롭습니다. `immer` 미들웨어로 직관적인 뮤터블 구문을 사용하세요.

```typescript
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

interface OrderSlice {
  orders: {
    [orderId: string]: {
      items: OrderItem[]
      status: 'pending' | 'confirmed' | 'shipped'
      address: {
        city: string
        street: string
        zipCode: string
      }
    }
  }
  updateOrderAddress: (orderId: string, address: Partial<OrderAddress>) => void
  addItemToOrder: (orderId: string, item: OrderItem) => void
}

export const useOrderStore = create<OrderSlice>()(
  immer((set) => ({
    orders: {},

    // immer 덕분에 직접 수정하는 것처럼 작성 가능 (내부적으로 불변성 유지)
    updateOrderAddress: (orderId, address) =>
      set((state) => {
        Object.assign(state.orders[orderId].address, address)
      }),

    addItemToOrder: (orderId, item) =>
      set((state) => {
        state.orders[orderId].items.push(item) // push 직접 사용 가능!
      }),
  })),
)
```

**immer 없이 동일한 코드를 작성하면:**

```typescript
// 이렇게 깊은 스프레드가 필요 — 실수하기 쉽고 가독성 나쁨
updateOrderAddress: (orderId, address) =>
  set((state) => ({
    orders: {
      ...state.orders,
      [orderId]: {
        ...state.orders[orderId],
        address: { ...state.orders[orderId].address, ...address },
      },
    },
  })),
```

---

## 3. URL as State: 공유 가능한 상태 저장소

### 왜 중요한가

정렬, 필터링, 검색어와 같은 상태는 URL 쿼리 파라미터에 저장하세요. 뒤로 가기, 페이지 새로고침, 링크 공유에서도 같은 상태가 복원됩니다.

> **일상 비유**: 지도 앱에서 "이 위치 공유"를 누르면 URL에 좌표가 담겨 전달됩니다. 친구가 그 링크를 열면 같은 위치가 바로 보입니다. URL 상태는 그런 "복사 가능한 화면 좌표"입니다.

#### URL 상태 동기화 흐름

```mermaid
flowchart LR
  A[사용자 인터랙션<br/>필터/정렬/검색어 변경] --> B[setFilters / setSearchParams]
  B --> C[Router push or replace]
  C --> D[브라우저 URL 갱신]
  D --> E[useSearchParams / useQueryStates 변경 감지]
  E --> F[리렌더링: filters 새 값]
  F --> G[queryKey 변경 감지]
  G --> H[TanStack Query 재요청]
  H --> I[목록/상세 데이터 갱신]
  D -. 외부 진입 .-> J[뒤로가기/북마크/공유 링크]
  J --> E
```

### 3.1 기본: useSearchParams 활용

```tsx
import { useSearchParams } from 'react-router-dom'

function SearchPanel() {
  const [searchParams, setSearchParams] = useSearchParams()
  const currentQuery = searchParams.get('q') ?? ''

  const handleSearch = (newQuery: string) => {
    // 로컬 state가 아닌 URL을 직접 조작합니다.
    setSearchParams({ q: newQuery }, { replace: true })
  }

  return <input value={currentQuery} onChange={(e) => handleSearch(e.target.value)} />
}
```

### 3.2 nuqs: 타입 안전한 URL 상태 관리 라이브러리

`nuqs`(Next.js URL Query State)는 URL 파라미터를 `useState`처럼 다룰 수 있게 해주는 모던 라이브러리입니다. Next.js App Router에 최적화되어 있으며, 파서를 통한 타입 안전성이 핵심 장점입니다.

```tsx
import { useQueryState, parseAsInteger, parseAsStringEnum } from 'nuqs'

// 단일 파라미터: useState와 동일한 API
function SearchPage() {
  const [query, setQuery] = useQueryState('q', { defaultValue: '' })
  // URL: ?q=검색어
  // query의 타입: string (자동 추론)

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />
}

// 파서를 활용한 타입 안전한 파라미터
function ProductListPage() {
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
  const [sort, setSort] = useQueryState(
    'sort',
    parseAsStringEnum(['price', 'name', 'date']).withDefault('date'),
  )
  // page의 타입: number (string이 아님!)
  // sort의 타입: 'price' | 'name' | 'date'

  return (
    <div>
      <span>현재 페이지: {page}</span>
      <button onClick={() => setPage(page + 1)}>다음 페이지</button>
    </div>
  )
}
```

### 3.3 복합 필터/정렬/페이지네이션 URL 상태 예시

실무에서는 여러 필터 조건을 동시에 URL로 관리합니다.

```tsx
import {
  useQueryStates,
  parseAsInteger,
  parseAsStringEnum,
  parseAsArrayOf,
  parseAsString,
} from 'nuqs'

// 여러 파라미터를 하나의 훅으로 묶어 관리
const searchParamsConfig = {
  q: parseAsString.withDefault(''),
  page: parseAsInteger.withDefault(1),
  size: parseAsInteger.withDefault(20),
  sort: parseAsStringEnum(['price_asc', 'price_desc', 'newest', 'popular']).withDefault('newest'),
  categories: parseAsArrayOf(parseAsString, ',').withDefault([]),
  minPrice: parseAsInteger,
  maxPrice: parseAsInteger,
}

function ProductSearchPage() {
  const [filters, setFilters] = useQueryStates(searchParamsConfig)
  // URL 예: ?q=노트북&page=2&sort=price_asc&categories=전자기기,컴퓨터&minPrice=500000

  // 필터 변경 시 page를 1로 리셋하는 패턴
  const handleCategoryChange = (category: string) => {
    const newCategories = filters.categories.includes(category)
      ? filters.categories.filter((c) => c !== category)
      : [...filters.categories, category]

    setFilters({
      categories: newCategories,
      page: 1, // 필터 변경 시 첫 페이지로 이동
    })
  }

  // TanStack Query와 결합: URL 파라미터를 queryKey에 직접 사용
  const { data } = useSuspenseQuery({
    queryKey: productKeys.list(filters),
    queryFn: () => fetchProductList(filters),
  })

  return (
    <div>
      <FilterPanel filters={filters} onCategoryChange={handleCategoryChange} />
      <ProductGrid products={data.items} />
      <Pagination
        current={filters.page}
        total={data.totalPages}
        onChange={(page) => setFilters({ page })}
      />
    </div>
  )
}
```

---

## 4. 성능 최적화: Selective Subscription

Zustand 사용 시 전체 스토어를 구독하지 말고, 필요한 값만 선택(Select)하여 구독하세요.

### 4.1 기본 셀렉터

```tsx
// ❌ 나쁜 예: 스토어의 다른 값이 바뀌어도 이 컴포넌트가 리렌더링됩니다.
const state = useBoundStore()

// ✅ 좋은 예: items가 바뀔 때만 리렌더링됩니다.
const items = useBoundStore((state) => state.items)
```

### 4.2 useShallow: 여러 값을 동시에 선택할 때

여러 값을 한 번에 선택하면 매 렌더링마다 새로운 객체가 생성되어 불필요한 리렌더링이 발생합니다. `useShallow`로 얕은 비교를 활성화하세요.

```tsx
import { useShallow } from 'zustand/react/shallow'

// ❌ 나쁜 예: 매번 새로운 객체를 반환하므로 항상 리렌더링
const { user, items } = useBoundStore((state) => ({
  user: state.user,
  items: state.items,
}))

// ✅ 좋은 예: useShallow로 얕은 비교 — user나 items가 실제로 바뀔 때만 리렌더링
const { user, items } = useBoundStore(
  useShallow((state) => ({
    user: state.user,
    items: state.items,
  })),
)
```

### 4.3 성능 비교 요약

| 패턴                                      | 리렌더링 조건                            | 적합한 경우                    |
| :---------------------------------------- | :--------------------------------------- | :----------------------------- |
| `useBoundStore()`                         | 스토어의 **어떤 값이든** 변경 시         | 사용 금지 (디버깅 시에만 허용) |
| `useBoundStore(s => s.items)`             | `items`가 변경될 때만                    | 단일 값 구독                   |
| `useBoundStore(useShallow(s => ({...})))` | 선택된 값 중 하나가 **실제로** 변경될 때 | 여러 값 동시 구독              |

---

## 5. 상태 분류 의사결정 트리

### 왜 중요한가

같은 데이터를 두 곳에 저장하면 어느 쪽이 진실인지 모호해지고, 동기화 코드가 늘어납니다. 시작 시점에 "어디에 저장할 것인가"를 결정하면 이후 캐시 무효화, 공유 링크, 영속화 정책이 자연스럽게 따라옵니다.

> **일상 비유**: 우편물을 받았을 때 어떤 서랍에 넣을지(공과금, 영수증, 영구보관) 처음부터 정해두면 나중에 찾기 쉽습니다. 상태도 처음 분류가 가장 중요합니다.

#### Mermaid 의사결정 트리

```mermaid
flowchart TD
  A[새 상태 도입 필요] --> B{서버가 진실 원천?}
  B -- 예 --> C[TanStack Query]
  B -- 아니오 --> D{URL에 반영 필요?<br/>북마크/공유/뒤로가기}
  D -- 예 --> E[URL State<br/>nuqs/useSearchParams]
  D -- 아니오 --> F{여러 컴포넌트가 공유?}
  F -- 아니오 --> G[useState / useReducer]
  F -- 예 --> H{범위는?}
  H -- 앱 전역 --> I{새로고침 후도 유지?}
  I -- 예 --> J[Zustand + persist]
  I -- 아니오 --> K[Zustand 인메모리]
  H -- 특정 서브트리 --> L[Context 또는<br/>Compound 패턴]
  C -. mutate .-> M[invalidate + optimistic]
  E -. 변경 .-> N[Router push/replace]
  J -. version 충돌 .-> O[migrate 함수 정의]
```

새로운 상태를 추가할 때 "어디에 저장할 것인가?"를 체계적으로 결정하세요.

```
상태를 추가해야 한다
  │
  ├─ 서버에서 온 데이터인가?
  │   ├─ YES → TanStack Query (서버 상태)
  │   │         캐시/재검증/무효화를 Query가 관리
  │   │         절대로 Zustand에 복사하지 말 것!
  │   │
  │   └─ NO → 클라이언트 전용 상태
  │       │
  │       ├─ URL에 반영되어야 하는가?
  │       │   (뒤로 가기, 새로고침, 링크 공유 시 유지 필요?)
  │       │   ├─ YES → URL State (nuqs / useSearchParams)
  │       │   │         예: 검색어, 필터, 페이지, 정렬 기준, 탭 선택
  │       │   │
  │       │   └─ NO → 다음 질문으로
  │       │
  │       ├─ 여러 컴포넌트가 공유하는가?
  │       │   ├─ YES → 얼마나 넓은 범위?
  │       │   │   ├─ 앱 전역 → Zustand 스토어
  │       │   │   │   예: 로그인 사용자, 테마, 사이드바 열림/닫힘
  │       │   │   │
  │       │   │   └─ 특정 서브트리만 → Context 또는 Compound Component 패턴
  │       │   │       예: 폼 Step 상태, 아코디언 열림 상태
  │       │   │
  │       │   └─ NO → useState / useReducer
  │       │       예: 모달 열림, 입력값, 토글, 로컬 카운터
  │       │
  │       └─ 새로고침 후에도 유지해야 하는가?
  │           ├─ YES → Zustand + persist 미들웨어
  │           │         예: 최근 본 상품, 다크모드 설정
  │           │
  │           └─ NO → 위의 분류 결과대로 사용
```

**경험 법칙:**

1. 기본은 `useState`로 시작하세요. 필요할 때만 상위로 올리세요.
2. 서버 데이터는 캐시/동기화가 필요하면 TanStack Query 같은 서버 상태 도구를 우선 검토하세요.
3. 공유 가능한 URL이 필요하면 URL State.
4. 그 외 전역 UI 상태만 Zustand.

---

## 6. TanStack Query 고급 패턴

### 왜 중요한가

기본 패턴만으로는 부족한 상황(SSR 스트리밍, 무한 스크롤, 의존 쿼리, 실시간 스트림)을 위한 고급 도구입니다. 같은 queryKey 팩토리 위에서 캐시 무효화 범위를 정확히 그리는 것이 핵심입니다.

#### queryKey 계층적 캐시 무효화 트리

```mermaid
flowchart TD
  A["productKeys.all = ['products']"] --> B["productKeys.lists()<br/>['products','list']"]
  A --> C["productKeys.details()<br/>['products','detail']"]
  B --> D["productKeys.list(filtersA)"]
  B --> E["productKeys.list(filtersB)"]
  C --> F["productKeys.detail(id1)"]
  C --> G["productKeys.detail(id2)"]
  H[mutation 발생] -->|invalidate detail id1| F
  H -->|invalidate lists 전체| B
  H -->|invalidate all 상품| A
  style A fill:#fef3c7
  style B fill:#dbeafe
  style C fill:#dbeafe
  style F fill:#fee2e2
```

> **읽는 법**: 상위 키를 무효화하면 그 아래의 모든 키가 함께 무효화됩니다. mutation의 영향 범위를 가장 좁게 잡되, 안전을 위해 한 단계 위까지 명시하는 것이 일반적입니다.

### 6.1 Prefetching: 사용자가 클릭하기 전에 데이터 준비

다음 페이지나 hover 대상의 데이터를 미리 캐시에 넣어 체감 속도를 높입니다.

```typescript
// 라우터 loader에서 prefetch (React Router v7 / TanStack Router)
export const productDetailLoader =
  (queryClient: QueryClient) =>
  async ({ params }: { params: { id: string } }) => {
    // 캐시에 데이터가 없거나 stale이면 미리 fetch
    await queryClient.ensureQueryData({
      queryKey: productKeys.detail(params.id),
      queryFn: () => fetchProduct(params.id),
    });
    return null;
  };

// hover 시 prefetch — 목록 → 상세 이동 시 로딩 없음
function ProductCard({ product }: { product: Product }) {
  const queryClient = useQueryClient();

  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: productKeys.detail(product.id),
      queryFn: () => fetchProduct(product.id),
      staleTime: 1000 * 60 * 5, // 5분간 재요청 방지
    });
  };

  return (
    <Link to={`/products/${product.id}`} onMouseEnter={handleMouseEnter}>
      {product.name}
    </Link>
  );
}
```

### 6.2 Infinite Query: 무한 스크롤 / 더보기

```typescript
export function useProductInfiniteList(filters: ProductFilters) {
  return useSuspenseInfiniteQuery({
    queryKey: productKeys.list(filters),
    queryFn: ({ pageParam }) =>
      fetchProductList({ ...filters, cursor: pageParam }),
    initialPageParam: undefined as string | undefined,
    // 다음 페이지 커서 추출 — 없으면 undefined 반환하여 hasNextPage = false
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });
}

// 사용하는 컴포넌트
function InfiniteProductList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useProductInfiniteList({ sort: 'newest' });

  // 모든 페이지의 items를 하나의 배열로 합침
  const allProducts = data.pages.flatMap((page) => page.items);

  return (
    <div>
      {allProducts.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? '로딩 중...' : '더보기'}
        </button>
      )}
    </div>
  );
}
```

### 6.3 `streamedQuery`: SSE / ReadableStream을 캐시에 누적

LLM 응답, 실시간 로그, AsyncIterable 등 **여러 청크가 시간차로 도착하는 데이터**를 단일 쿼리에 자연스럽게 누적하는 v5 신규 API입니다. 매 청크마다 `data`가 갱신되어 컴포넌트가 점진적으로 렌더링됩니다.

```typescript
import { streamedQuery, useQuery } from '@tanstack/react-query';

export function useChatStream(threadId: string) {
  return useQuery({
    queryKey: ['chat', threadId, 'stream'],
    queryFn: streamedQuery({
      streamFn: async ({ signal }) => {
        const res = await fetch(`/api/chat/${threadId}/stream`, { signal });
        // ReadableStream → AsyncIterable로 변환된 청크 시퀀스를 반환
        return parseSSE(res.body!);
      },
      // refetch 시 기존 데이터 폐기 여부
      refetchMode: 'reset',
    }),
  });
}

// 사용처에서는 일반 useQuery와 동일
function ChatThread({ threadId }: { threadId: string }) {
  const { data, isFetching } = useChatStream(threadId);
  return (
    <div>
      {data?.map((chunk, i) => <span key={i}>{chunk.text}</span>)}
      {isFetching && <Dot animated />}
    </div>
  );
}
```

### 6.4 Pending Query Dehydration (v5.40+): SSR 스트리밍

서버에서 `prefetch`를 시작하되, **데이터가 준비되기 전에 일단 HTML을 흘려보내고** Suspense 경계가 해소되는 대로 클라이언트 캐시로 스트리밍합니다. Next.js 16+/15 App Router, TanStack Start, Remix 등에서 활용할 수 있습니다.

```tsx
// Next.js App Router 예시 (app/products/page.tsx)
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query'

export default async function ProductsPage() {
  const queryClient = new QueryClient()

  // 결과를 await하지 않고 prefetch만 시작 → 'pending' 상태로 dehydrate
  queryClient.prefetchQuery({
    queryKey: productKeys.lists(),
    queryFn: fetchProductList,
  })

  return (
    // 클라이언트는 일단 셸을 받고, 데이터가 도착하면 스트림으로 hydrate
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList />
      </Suspense>
    </HydrationBoundary>
  )
}
```

> React 19.2의 **Partial Pre-rendering** 과 결합하면, 정적 셸은 즉시 CDN에서 응답되고 동적 영역은 위 패턴으로 채워 넣을 수 있습니다.

### 6.5 Dependent Query: 의존적 쿼리 체이닝

하나의 쿼리 결과가 다른 쿼리의 파라미터가 되는 경우입니다.

> **일상 비유**: 학생증 발급 절차와 유사합니다. 먼저 신분증을 확인해야 학번을 발급하고, 학번이 있어야 강의 등록으로 넘어갑니다. 앞 단계가 끝나기 전에는 뒤 단계를 시작할 수 없습니다.

#### 의존 쿼리 체이닝 시퀀스

이 그림은 한 쿼리의 결과가 다음 쿼리의 enabled 조건이 되어 순차적으로 실행되는 흐름을 보여줍니다.

```mermaid
sequenceDiagram
  autonumber
  participant UI as MyOrdersPage
  participant Q1 as useCurrentUser
  participant Q2 as useUserOrders userId
  participant API as 백엔드

  UI->>Q1: mount
  Q1->>API: GET /users/me
  Note over Q2: enabled = false (userId 없음)
  API-->>Q1: user 응답
  Q1-->>UI: user state
  UI->>Q2: rerender with user.id
  Note over Q2: enabled = true 전환
  Q2->>API: GET /orders userId
  API-->>Q2: orders 응답
  Q2-->>UI: orders 렌더
  alt user fetch 실패
    API-->>Q1: 401/500
    Q1-->>UI: error -- Q2는 영구히 enabled=false
  end
```

```typescript
// 1단계: 현재 사용자 정보 가져오기
function useCurrentUser() {
  return useQuery({
    queryKey: userKeys.me(),
    queryFn: fetchCurrentUser,
  });
}

// 2단계: 사용자의 주문 목록 가져오기 (사용자 ID가 있어야 요청 가능)
function useUserOrders(userId: string | undefined) {
  return useQuery({
    queryKey: ['orders', { userId }],
    queryFn: () => fetchOrdersByUser(userId!),
    // userId가 없으면 쿼리 자체를 실행하지 않음
    enabled: !!userId,
  });
}

// 컴포넌트에서 조합
function MyOrdersPage() {
  const { data: user } = useCurrentUser();
  const { data: orders, isLoading } = useUserOrders(user?.id);
  // user가 로드되면 자동으로 orders 쿼리가 활성화됨

  if (isLoading) return <OrdersSkeleton />;
  return <OrderList orders={orders ?? []} />;
}
```

---

## 7. 전역 상태 vs Context vs Props Drilling 비교

| 기준                | Props Drilling  | React Context             | Zustand                           |
| :------------------ | :-------------- | :------------------------ | :-------------------------------- |
| **설정 비용**       | 없음            | 낮음 (Provider 생성)      | 낮음 (create 호출)                |
| **적합 깊이**       | 1~2단계         | 3단계 이상의 서브트리     | 앱 전역                           |
| **리렌더링 범위**   | 해당 컴포넌트만 | Provider 하위 모든 소비자 | 셀렉터로 구독한 컴포넌트만        |
| **성능 최적화**     | 자연스럽게 최적 | `useMemo`/분리 필요       | `useShallow`/셀렉터로 쉽게 최적화 |
| **React 외부 접근** | 불가            | 불가                      | `getState()`로 가능               |
| **DevTools**        | 없음            | React DevTools            | Redux DevTools 연동               |
| **SSR 호환**        | 높음            | 높음                      | 주의 필요 (hydration)             |

#### 전달 방식 선택 결정 트리

> **일상 비유**: 가족에게 물건을 전달하는 방식과 비슷합니다. 옆방의 자녀라면 직접 건네주면 되고(props), 거실에 둔 공용 물건은 모두가 가져갈 수 있어야 하며(Context), 회사 전체가 쓰는 비품은 비품실(전역 store)에서 관리합니다.

이 그림은 컴포넌트 트리의 깊이와 공유 범위에 따라 어떤 전달 방식을 선택해야 하는지 단계별 의사결정을 보여줍니다.

```mermaid
flowchart TD
  Start[상태를 어떻게 전달할까] --> Q1{공유 범위는}
  Q1 -- 부모-자식 1~2단계 --> Props[Props Drilling<br/>props로 직접 전달]
  Q1 -- 특정 서브트리 --> Q2{리렌더링 비용 우려}
  Q2 -- 없음 --> Ctx[React Context<br/>Provider로 묶기]
  Q2 -- 있음 --> Q3{Context 분리로 충분}
  Q3 -- 예 --> CtxSplit[Context 분리<br/>state와 dispatch 별도 Provider]
  Q3 -- 아니오 --> ZuSubtree[Zustand store<br/>Provider 안에서 createStore]
  Q1 -- 앱 전역 --> Q4{새로고침 후 유지}
  Q4 -- 예 --> ZuPersist[Zustand + persist]
  Q4 -- 아니오 --> ZuPlain[Zustand 인메모리]
  Q1 -- 라우트 간 공유<br/>북마크/링크 가능 --> URL[URL State<br/>nuqs/useSearchParams]
```

### 언제 어떤 것을 선택하는가?

**Props Drilling이 충분한 경우:**

```tsx
// 부모 → 자식 1~2단계: 그냥 props를 전달하세요.
// 불필요한 추상화는 복잡성만 증가시킵니다.
function Parent() {
  const [isOpen, setIsOpen] = useState(false)
  return <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
}
```

**Context가 적합한 경우:**

```tsx
// 특정 서브트리 내에서만 공유되는 상태
// 예: 폼 위자드의 스텝 상태, 테이블의 선택 상태
const FormContext = createContext<FormContextType | null>(null)

function FormWizard() {
  const [step, setStep] = useState(0)
  return (
    <FormContext.Provider value={{ step, setStep }}>
      <FormStepIndicator /> {/* 깊이 3 */}
      <FormContent /> {/* 깊이 2 */}
      <FormNavigation /> {/* 깊이 2 */}
    </FormContext.Provider>
  )
}
```

**Zustand가 적합한 경우:**

```typescript
// 앱 전역에서 접근해야 하고, 세밀한 구독 제어가 필요한 경우
// 예: 인증 상태, 테마, 사이드바 상태, 알림 설정
const useSidebarStore = create<SidebarSlice>()((set) => ({
  isOpen: true,
  toggle: () => set((s) => ({ isOpen: !s.isOpen })),
}))

// 어떤 컴포넌트에서든 Provider 없이 바로 사용 가능
const isOpen = useSidebarStore((s) => s.isOpen)
```

---

## 8. 상태 관리 라이브러리 지형도

### 8.1 라이브러리별 포지셔닝 요약

| 라이브러리                    | 핵심 모델                 | 권장 사용처                         | 비고                                                     |
| :---------------------------- | :------------------------ | :---------------------------------- | :------------------------------------------------------- |
| **TanStack Query v5**         | 서버 상태 캐시            | API 응답 / RSC 스트리밍             | 원격 데이터 캐시와 재검증에 우선 검토                    |
| **Zustand v5**                | 외부 store + selector     | 전역 UI 상태, 인증, 테마            | React 18+ 전용. SSR 시 hydration 주의                    |
| **Jotai v2**                  | 상향식 atom 그래프        | 미세 의존 그래프, 폼/캔버스         | `useState` 대체에 가까움. 광역 store보다는 컴포넌트-친화 |
| **Redux Toolkit + RTK Query** | flux + listenerMiddleware | 대형 엔터프라이즈, 강한 디버깅 요구 | `createListenerMiddleware`로 saga 대체                   |
| **XState v5**                 | actor / statechart        | 결제, 위자드, 멀티스텝 협상 로직    | "암묵적 상태 머신"을 명시적으로                          |
| **TC39 Signals (Stage 1)**    | 표준 reactive primitive   | 표준화 후 등장 예정                 | 현재 공식 문서 기준 **아직 1Spec 도입 시기 아님**        |

### 8.2 Jotai v2 핵심 패턴

`Jotai`는 **atom = 단위 상태**를 컴포지션하는 모델입니다. Zustand가 "store에 selector를 거는" 방식이라면, Jotai는 **상태 그래프를 컴포넌트 트리와 독립적으로 모듈화**합니다.

```typescript
import { atom, useAtom, useAtomValue } from 'jotai';

// 원시 atom
const countAtom = atom(0);

// 파생 atom (자동 무효화)
const doubledAtom = atom((get) => get(countAtom) * 2);

// 쓰기 가능한 파생 atom
const upperNameAtom = atom(
  (get) => get(nameAtom).toUpperCase(),
  (_get, set, newName: string) => set(nameAtom, newName.trim()),
);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const doubled = useAtomValue(doubledAtom);
  return <button onClick={() => setCount((c) => c + 1)}>{count} → {doubled}</button>;
}
```

권장 사용처: **상태 노드 수가 많고 의존 관계가 명시적인 영역** (디자인 툴의 캔버스, 폼 빌더, 트리 편집기 등).

### 8.3 XState v5: 명시적 상태 머신 / Actor 모델

v5의 핵심은 **"모든 것이 actor"** 라는 추상화 통일과 `setup({ actors, actions, guards })` 패턴입니다. 결제, 다단계 폼, 외부 시스템 협상처럼 **잘못 다루면 if-else가 폭발하는 영역**에 적합합니다.

```typescript
import { setup, assign, createMachine } from 'xstate'

const checkoutMachine = setup({
  types: {} as { context: { items: number; error?: string } },
  actions: {
    notifyFail: ({ context }) => console.error(context.error),
  },
  guards: {
    hasItems: ({ context }) => context.items > 0,
  },
}).createMachine({
  id: 'checkout',
  context: { items: 0 },
  initial: 'cart',
  states: {
    cart: {
      on: { CHECKOUT: { target: 'paying', guard: 'hasItems' } },
    },
    paying: {
      invoke: {
        src: 'payActor', // setup의 actors에서 주입
        onDone: 'success',
        onError: { target: 'cart', actions: 'notifyFail' },
      },
    },
    success: { type: 'final' },
  },
})
```

v5에서는 `actor.select(selector, equalityFn?)` 로 React 외부에서도 안전하게 파생 값을 구독합니다.

### 8.4 TC39 Signals: 현재 위치

표준 Signals 제안은 **현재 공식 문서 기준 TC39 Stage 1**에 머물러 있습니다. 여러 프레임워크에서 자체 구현체가 활발하지만, **React 본체는 공식적으로 Signal을 채택하지 않았습니다.** React 코드베이스에서는 표준화 전까지 Signal API를 전면 도입하지 않고 adapter 경계 안에서 실험합니다.

**실무 권장 전략**:

1. 신규 코드는 **Zustand/Jotai/TanStack Query** 조합을 유지.
2. Signal 호환 API는 라이브러리 레벨에서 시도(예: `@preact/signals-react`)는 가능하지만 프로덕션 전면 도입은 표준 Stage 2 진입 후로 미룹니다.
3. 표준화 시 마이그레이션이 쉬워지도록 **상태 접근을 hook 뒤에 캡슐화**해 두는 것이 안전합니다.

---

## 9. 주의사항 및 흔한 실수

> **일상 비유**: 안티패턴 검출은 건강 검진의 문진표와 같습니다. 증상별로 분기를 따라가면 의심되는 원인이 자연스럽게 좁혀집니다.

#### 안티패턴 자가 진단 결정 트리

이 그림은 코드 리뷰 시 자주 발견되는 안티패턴 후보를 증상별로 추적하는 방법을 보여줍니다.

```mermaid
flowchart TD
  Start[코드에서 의심되는 패턴 발견] --> Q1{서버 응답을 어디에 저장}
  Q1 -- Zustand/useState --> A1[안티패턴 #1<br/>서버 상태 복사<br/>TanStack Query로 이전]
  Q1 -- Query 캐시 --> Q2{useEffect로 fetch}
  Q2 -- 예 --> A2[안티패턴 #2<br/>useEffect fetch<br/>useQuery로 변경]
  Q2 -- 아니오 --> Q3{queryKey 문자열 하드코딩}
  Q3 -- 예 --> A3[안티패턴 #3<br/>queryKey 불일치<br/>팩토리 패턴 도입]
  Q3 -- 아니오 --> Q4{전체 store 구독}
  Q4 -- 예 --> A4[안티패턴 #4<br/>셀렉터 미사용<br/>useShallow 또는 selector 도입]
  Q4 -- 아니오 --> Q5{파생값을 별도 상태로}
  Q5 -- 예 --> A5[안티패턴 #5<br/>중복 상태<br/>selector로 계산]
  Q5 -- 아니오 --> Q6{검색/필터/페이지를 useState}
  Q6 -- 예 --> A6[안티패턴 #6<br/>URL 상태 누락<br/>nuqs 도입]
  Q6 -- 아니오 --> Ok[합격 -- 다른 가이드 절도 함께 확인]
```

### 9.1 안티패턴 모음

**1. 서버 상태를 Zustand에 복사하기**

```typescript
// ❌ 절대 이렇게 하지 마세요
const useProductStore = create((set) => ({
  products: [],
  fetchProducts: async () => {
    const data = await api.get('/products')
    set({ products: data }) // 서버 데이터를 로컬 스토어에 복사
  },
}))
// 문제: 캐시 무효화, 백그라운드 갱신, 에러 처리를 전부 직접 구현해야 함
// 해결: TanStack Query 사용
```

**2. useEffect + setState로 서버 데이터 동기화**

```typescript
// ❌ "useEffect fetch" 패턴은 2024년 이후 안티패턴
function ProductList() {
  const [products, setProducts] = useState<Product[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetchProducts().then((data) => {
      setProducts(data)
      setLoading(false)
    })
  }, [])
  // 문제: race condition, 메모리 누수, 중복 요청, 캐시 없음
  // 해결: TanStack Query의 useQuery 사용
}
```

**3. queryKey 불일치로 인한 캐시 미스**

```typescript
// ❌ queryKey를 문자열로 직접 작성하면 오타와 불일치 발생
useQuery({ queryKey: ['products', 'detial', id], ... }); // 'detial' 오타!
useMutation({
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['product', 'detail', id] }); // 'product' 단수형!
  },
});
// 해결: queryKey 팩토리 패턴 사용 (1.3절 참고)
```

**4. Zustand 셀렉터 미사용**

```typescript
// ❌ 전체 스토어 구독
function CartIcon() {
  const store = useBoundStore(); // user가 바뀌어도 리렌더링됨!
  return <Badge count={store.items.length} />;
}

// ✅ 필요한 값만 구독
function CartIcon() {
  const itemCount = useBoundStore((s) => s.items.length);
  return <Badge count={itemCount} />;
}
```

**5. 파생 상태를 별도로 저장하기**

```typescript
// ❌ 계산 가능한 값을 상태로 관리
const useCartStore = create((set) => ({
  items: [],
  totalPrice: 0, // items에서 계산 가능한 값!
  addItem: (item) =>
    set((state) => {
      const newItems = [...state.items, item]
      return {
        items: newItems,
        totalPrice: newItems.reduce((sum, i) => sum + i.price, 0), // 매번 동기화 필요
      }
    }),
}))

// ✅ 파생 상태는 셀렉터로 계산
const totalPrice = useBoundStore((s) => s.items.reduce((sum, item) => sum + item.price, 0))
```

**6. URL State로 관리해야 할 것을 useState로 관리하기**

```typescript
// ❌ 검색/필터를 로컬 상태로 관리
function SearchPage() {
  const [query, setQuery] = useState('')
  const [sort, setSort] = useState('newest')
  // 문제: 새로고침하면 상태 소실, 링크 공유 불가, 뒤로 가기 동작 안 함
}

// ✅ URL로 관리
function SearchPage() {
  const [query, setQuery] = useQueryState('q', { defaultValue: '' })
  const [sort, setSort] = useQueryState(
    'sort',
    parseAsStringEnum(['newest', 'popular']).withDefault('newest'),
  )
}
```

### 9.2 흔한 실수 정리표

| 실수                       | 증상                              | 해결                               |
| :------------------------- | :-------------------------------- | :--------------------------------- |
| staleTime을 0으로 둠       | 매번 백그라운드 refetch 발생      | 데이터 특성에 맞게 설정 (1~5분)    |
| gcTime < staleTime         | 캐시가 stale 되기 전에 사라짐     | gcTime > staleTime 유지            |
| persist에 민감한 정보 저장 | localStorage에 토큰/개인정보 노출 | 인증 토큰은 httpOnly 쿠키로        |
| Zustand에서 비동기 작업    | 에러/로딩 처리가 복잡해짐         | 비동기는 TanStack Query에 위임     |
| Context를 전역 상태로 남용 | 불필요한 리렌더링 폭탄            | 전역은 Zustand, 서브트리만 Context |

---

## AI 보조 상태 설계 검증

AI 사용 정책과 검증 책임은 [18. AI 개발 워크플로우](./18_AI_개발_워크플로우_종합.md)를 따릅니다. 상태 설계 초안은 저장 위치보다 동기화 실패와 rollback 가능성을 먼저 검증합니다.

| 시나리오                 | 입력                               | AI 산출물                        | 필수 검증                                 |
| :----------------------- | :--------------------------------- | :------------------------------- | :---------------------------------------- |
| 상태 분류                | 상태 목록, 공유/복원/보안 요구사항 | URL/server/client/form/UI 분류표 | deep link E2E, 민감정보 노출 점검         |
| 낙관적 업데이트          | mutation 계약, 실패 응답 fixture   | optimistic path + rollback 후보  | 실패 재생 테스트, cache invalidation 확인 |
| 전역 store 축소          | store slice, consumer graph        | local/query/url state 이동 후보  | render count, consumer smoke              |
| Signals/experimental API | 사용 후보와 fallback               | adapter 격리안                   | flag off E2E, migration ADR               |

---

## 체크리스트

**서버 상태 (TanStack Query v5)**

- [ ] 서버 데이터(API 응답)를 Zustand나 useState에 중복 저장하고 있지 않은가?
- [ ] queryKey 팩토리 패턴을 사용하여 키를 중앙 관리하고 있는가?
- [ ] staleTime과 gcTime을 데이터 특성에 맞게 설정했는가?
- [ ] useMutation의 onSettled에서 관련 쿼리를 invalidate하고 있는가?
- [ ] 에러/로딩 상태를 Suspense + ErrorBoundary로 선언적 처리하고 있는가?
- [ ] 목록 → 상세 이동 시 prefetch를 활용하고 있는가?
- [ ] SSE/스트리밍 응답은 `streamedQuery`로 누적하고 있는가?
- [ ] SSR 환경에서 Pending Query Dehydration(v5.40+)으로 셸을 먼저 흘려보내고 있는가?

**클라이언트 상태 (Zustand)**

- [ ] Zustand 스토어를 도메인별 슬라이스로 분리했는가?
- [ ] 스토어 접근 시 셀렉터 함수를 사용하여 구독 범위를 좁혔는가?
- [ ] 여러 값을 동시 구독할 때 `useShallow`를 사용하고 있는가?
- [ ] 중첩 상태 업데이트에 immer 미들웨어를 활용하고 있는가?
- [ ] 영속화가 필요한 상태에만 persist 미들웨어를 적용했는가?
- [ ] 파생 상태를 별도 저장하지 않고 셀렉터에서 계산하고 있는가?

**URL 상태**

- [ ] 검색어, 필터, 정렬, 페이지 등 공유 가능한 상태를 URL 파라미터로 관리하는가?
- [ ] 필터 변경 시 page를 1로 리셋하는 로직이 있는가?
- [ ] URL 파라미터에 타입 파서를 적용하여 타입 안전성을 확보했는가?

**일반**

- [ ] 폼 입력값과 같은 휘발성 상태는 `useState`로 충분한데 스토어에 넣고 있지 않은가?
- [ ] 상태 추가 시 의사결정 트리(5장)를 따라 저장 위치를 결정했는가?
- [ ] Context를 전역 상태 대용으로 남용하고 있지 않은가?

**라이브러리 선택 **

- [ ] 결제/위자드 같은 명시적 상태 머신 영역은 **XState v5** 도입을 검토했는가?
- [ ] 의존 그래프가 복잡한 폼/캔버스 영역에는 **Jotai v2** 가 더 자연스러운지 검토했는가?
- [ ] TC39 Signals는 Stage 1임을 인지하고, 프로덕션 도입을 보류했는가?
- [ ] Zustand v5의 `useShallow` 강제 정책을 따르고 있는가?

---

## 10. 상태 Hook 책임 분리 규칙

상태 관리는 "어떤 라이브러리를 쓰는가"보다 "수정 영향 범위가 어디까지 퍼지는가"가 먼저입니다.

### 10.1 `usePageState`를 기본 패턴으로 만들지 않는다

- 페이지 전체 URL query, 필터, pagination, selected id, date range를 하나의 hook에서 반환하면 작은 변경도 전체 페이지 리렌더링과 테스트 범위를 넓힙니다.
- `useCardIdQueryParam`, `useStatementDateRange`, `useStatusFilter`처럼 쿼리 파라미터별 hook으로 나눠 변경 단위와 읽기 단위를 맞춥니다.
- 여러 hook을 다시 묶는 facade는 route entry에서만 사용하고, leaf component에는 필요한 값만 전달합니다.

### 10.2 상태 종류가 아니라 변경 책임으로 나눈다

- URL state, form state, server state라는 기술 분류만으로 파일을 나누지 않습니다.
- 같은 기능에서 함께 수정되는 query key, URL param parser, mutation, optimistic rollback은 가까이에 둡니다.
- query param 하나가 바뀌어도 관련 없는 컴포넌트가 리렌더링되면 hook 책임이 너무 넓다는 신호입니다.

### 10.3 반환 계약은 같은 계열끼리 통일한다

- server state hook은 query object 반환, derived data 반환, tuple 반환 중 하나를 팀 규칙으로 고정합니다.
- mutation hook은 `mutate`, `mutateAsync`, optimistic rollback, error type을 일관되게 노출합니다.
- hook 이름에 `WithAuth`, `Tracked`, `Persisted`처럼 추가 동작이 있으면 이름으로 드러냅니다.

---

## 실무 적용 가이드

### 언제 이 문서를 펼칠까

- 서버 상태와 화면 상태가 한 store에 섞여 stale bug가 생길 때
- URL에 남겨야 할 상태와 내부 UI 상태를 구분해야 할 때
- optimistic update 후 실패 복구가 불명확할 때

### 적용 순서

1. 상태를 server, form, URL, UI, persisted state로 분류한다.
2. server state는 query key와 invalidation 규칙을 먼저 정한다.
3. 공유가 필요 없는 UI state는 컴포넌트 가까이에 둔다.
4. URL state는 뒤로가기, 공유 링크, 새로고침 기준으로 결정한다.
5. mutation에는 optimistic path와 rollback path를 함께 테스트한다.

### 함께 두는 파일

- feature의 query key, mutation hook, schema, mock, test를 같은 폴더에 둔다.
- 전역 store는 기능별 slice와 owner가 있을 때만 허용한다.
- persist storage adapter는 보안/만료 정책과 함께 둔다.

### 흔한 실수

- 모든 상태를 global store에 넣는다.
- query key를 문자열로 흩뿌린다.
- 서버 응답 캐시와 form draft를 같은 객체로 다룬다.
- URL state를 쓰면서 기본값과 invalid value 처리를 빼먹는다.

### PR 완료 기준

- [ ] 상태 분류표가 있다.
- [ ] query invalidation과 rollback 테스트가 있다.
- [ ] persist되는 값에 민감정보가 없다.
- [ ] 상태 관련 파일이 기능 폴더 안에 모여 있다.

## 추천 항목 실행 우선순위 매핑

- `P1(7일 내)` — 서버 상태, URL state, UI state 분리 중 하나를 작은 변경 1건에 적용하고 증거(쿼리 키 목록)를 남긴다.
- `P2(30일 내)` — 상태 흐름 기준을 팀 템플릿, 체크리스트, CI 중 한 곳에 고정한다.
- `P3(90일 내)` — 상태 중복 수, stale cache 이슈, 뒤로가기 회귀 추이를 보고 기준을 유지할지 조정할지 결정한다.
- `완료 기준` — 상태 관리 오너가 증거와 철회 조건을 확인했다는 기록을 남긴다.

## 추천 항목 실행 체크리스트

- [ ] `1단계(7일)` : 서버 상태, URL state, UI state 분리 적용 대상을 1개로 좁힌다.
- [ ] `2단계(30일)` : 증거(쿼리 키 목록, optimistic update 테스트, URL 복원 테스트)를 PR, ADR, 회고 중 한 곳에 연결한다.
- [ ] `3단계(60일)` : 상태 중복 수, stale cache 이슈, 뒤로가기 회귀가 기준 안에 들어왔는지 확인한다.
- [ ] `문제 대응` : 미달성 사유와 다음 조치, 중단 여부를 같은 기록에 남긴다.

## 추천 항목 실행 운영 규칙

- `실행 게이트` : 상태의 소유자가 서버, URL, 컴포넌트 중 어디인지 먼저 적는다.
- `승인 체계` : 상태 관리 오너가 영향 범위와 rollback 담당자를 적용 전에 확인한다.
- `재개 조건` : 복원/동기화 테스트가 통과하면 같은 패턴을 다음 화면에 적용한다.
- `정지 조건` : 하나의 상태가 세 위치에서 동시에 수정되면 구조를 다시 나눈다.
- `리스크 점수` : 공유 상태 수, mutation 수, cache invalidation 범위로 산정한다.
- `리더 승인자` : 프론트엔드 아키텍트가 최종 승인 책임을 맡는다.
- `승인 역할` : 상태 흐름 작성자, 검토자, 운영 확인자를 분리해 기록한다.
- `재평가 주기` : 핵심 flow 변경 때마다 상태 소유권 표를 갱신한다.
