Zustand 심화 가이드 — 고급 상태 관리 패턴·Slice·미들웨어·성능·TypeScript

Zustand 심화 가이드 — 고급 상태 관리 패턴·Slice·미들웨어·성능·TypeScript

이 글의 핵심

이 글은 Zustand를 «카운터 예제»를 넘어 프로덕션 규모로 쓰기 위한 심화 가이드입니다. 스토어 설계·Slice 패턴·미들웨어 조합·셀렉터 기반 리렌더 최적화·TypeScript 고급 타입·Jotai·Recoil과의 선택 기준·대규모 앱에서의 상태 경계를 한 흐름으로 연결합니다.

이 글의 핵심

Zustand는 API가 단순해 보이지만, 팀 규모가 커질수록 스토어 경계, 모듈화, 미들웨어 스택 순서, 구독 단위가 곧 유지보수 비용과 성능으로 되돌아옵니다. 이 글은 다음을 목표로 합니다.

  • 복잡한 스토어 설계: 단일 거대 스토어와 다중 스토어의 트레이드오프, 도메인 단위 경계
  • Slice 패턴: 기능별 상태·액션을 조합해 테스트와 확장이 쉬운 구조 만들기
  • 미들웨어: persist, devtools, immer의 역할과 조합 시 흔한 실수
  • 셀렉터 최적화: 참조 동일성, shallow, 파생 상태의 위치
  • TypeScript: StateCreator, 미들웨어가 붙은 스토어 타입, 팩토리 패턴
  • Zustand vs Jotai vs Recoil: 원자적 모델과 스토어 모델의 선택 기준
  • 대규모 앱: 서버 상태·URL·폼·실시간 스트림과 클라이언트 전역 상태의 역할 분담

아래 예제는 Zustand v4/v5 계열 관용구를 기준으로 합니다. 프로젝트의 설치 버전에 따라 import 경로·미들웨어 시그니처를 공식 문서와 대조하시기 바랍니다.


1. 전제: «고급»이 의미하는 것

프로덕션 프론트엔드에서 상태는 한 덩어리가 아닙니다. 서버에서 온 데이터, URL이 소유한 라우팅 상태, 폼의 임시 입력, WebSocket으로 들어오는 이벤트, 권한·기능 플래그가 동시에 존재합니다. Zustand는 이 중 클라이언트 전역 UI 상태를 담기에 강하지만, 모든 것을 하나의 useStore에 넣으면 불필요한 결합리렌더 폭발이 생깁니다.

고급 단계에서의 질문은 다음과 같습니다.

  1. 무엇을 전역에 둘 것인가 — 진실의 출처가 어디인가
  2. 누가 구독하는가 — 컴포넌트 트리의 어느 깊이에서 어떤 슬라이스를 읽는가
  3. 변경은 어떤 경로로 전파되는가 — 액션·이벤트·동기화 정책

이 세 가지에 대한 팀 합의 없이 미들웨어만 늘리면, 디버깅 난이도만 상승하는 경우가 많습니다.


2. 복잡한 스토어 설계 패턴

2.1 단일 스토어 vs 다중 스토어

단일 스토어는 DevTools 한 화면에서 전체 상태를 보기 좋고, 교차 슬라이스 파생 값을 만들기 쉽습니다. 반면 팀이 커질수록 PR 충돌암묵적 의존이 늘어납니다.

다중 스토어(예: useUserPrefsStore, useCartStore)는 경계가 명확하고 코드 소유권을 나누기 좋습니다. 대신 스토어 간 동기화(장바구니 ↔ 로그인 사용자 할인 정책 등)를 또 다른 레이어에서 설계해야 합니다.

실무에서는 초기에는 단일 스토어 + Slice로 시작하고, 독립 라이프사이클이 분명해지는 시점에 물리 스토어를 쪼개는 절충이 흔합니다.

2.2 액션 네이밍과 «쓰기 경로» 통일

고급 패턴의 핵심은 상태 모양 자체보다 누가 상태를 바꾸는지입니다. 컴포넌트 곳곳에서 set((s) => ({ ...s, x: ... }))를 직접 호출하면, 나중에 중간 검증·로깅·권한 체크를 넣기 어렵습니다.

권장하는 규칙은 다음과 같습니다.

  • 외부 공개 APIactions 객체에 모은다
  • 비동기 흐름은 액션 내부에서만 수행하고, 컴포넌트는 이벤트만 넘긴다
  • 도메인 불변 조건(예: 수량 ≥ 0)은 액션 또는 작은 순수 함수에서 검증한다

이렇게 하면 devtools로 액션 이름만 추적해도 원인 분석이 쉬워집니다.

2.3 파생 상태의 위치: 컴포넌트 vs 스토어

파생 값(필터링된 목록, 합계, 선택된 항목 수)을 어디에 두는지는 성능과 테스트 용이성에 직결됩니다.

  • 스토어에 두기: 여러 화면이 동일한 파생 값을 공유할 때 유리. 다만 파생 값이 많아지면 메모이제이션 비용갱신 타이밍 관리가 필요합니다.
  • 셀렉터로 계산: useStore의 선택 함수에서 계산하면 구독 범위를 좁힐 수 있으나, 매번 새 객체면 shallow 비교와 함께 써야 합니다.
  • reselect류 별도 모듈: 복잡한 파이프라인은 스토어 밖 순수 함수로 두고 단위 테스트를 붙이는 편이 안전합니다.

원칙은 하나입니다. 같은 파생 로직을 여러 컴포넌트에 복붙하지 않는 것입니다.


3. Slice 패턴과 모듈화

Slice 패턴은 Redux Toolkit의 createSlice와 유사하게, 기능 단위로 state + actions를 묶은 뒤 하나의 스토어에 병합하는 방식입니다. Zustand에서는 보통 StateCreator 조각을 만들고 combine 또는 객체 병합으로 합칩니다.

3.1 StateCreator 조각 나누기

아래는 사용자 세션과 UI 사이드바 상태를 분리한 뒤, 타입 안전하게 합치는 최소 예입니다.

import { create, type StateCreator } from 'zustand';

type SessionSlice = {
  userId: string | null;
  setUserId: (id: string | null) => void;
};

type UiSlice = {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
};

const createSessionSlice: StateCreator<
  SessionSlice,
  [],
  [],
  SessionSlice
> = (set) => ({
  userId: null,
  setUserId: (userId) => set({ userId }),
});

const createUiSlice: StateCreator<UiSlice, [], [], UiSlice> = (set) => ({
  sidebarOpen: true,
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
});

/** 두 슬라이스를 합친 앱 스토어 타입 */
type AppStore = SessionSlice & UiSlice;

export const useAppStore = create<AppStore>()((...args) => ({
  ...createSessionSlice(...args),
  ...createUiSlice(...args),
}));

첫 번째 코드 블록에서 주목할 점은 각 Slice가 자신의 키만 알고 있다는 것입니다. createSessionSlicesidebarOpen을 건드리지 않으므로, 세션 관련 변경이 UI 슬라이스에 미치는 부작용을 줄일 수 있습니다.

다만 이런 병합 방식에서는 동일한 키 이름 충돌을 컴파일 타임에 잡기 어렵습니다. 팀 규칙으로 키 접두사(ui_sidebarOpen 등)를 쓰거나, combine 헬퍼로 병합 전략을 고정하는 편이 안전합니다.

3.2 combine으로 중첩 상태 구조화

Zustand의 combine 미들웨어는 초기 상태 객체그 상태를 받는 두 번째 함수를 분리해, 첫 객체를 불변으로 유지하는 패턴에 익숙한 팀에게 읽기 쉬운 형태를 제공합니다. 문서화된 관용구는 버전에 따라 다르므로, 공식 예제를 프로젝트 버전에 맞게 붙이는 것이 좋습니다.

Slice 패턴의 실질적 이점은 폴더 구조와 1:1로 대응할 수 있다는 점입니다. 예를 들어 features/cart/model/cartSlice.ts, features/checkout/model/checkoutSlice.ts처럼 두고, app/store.ts에서만 조립하면 기능 플래그로 슬라이스 자체를 지연 로드하는 전략도 쓰기 쉽습니다.

3.3 테스트와 모킹

슬라이스를 순수 StateCreator로 유지하면, 스토어 없이 (set, get)을 목 객체로 주입해 단위 테스트하는 방식이 가능해집니다. 반면 컴포넌트 통합 테스트에서는 create로 만든 스토어를 테스트마다 setState로 초기화하거나, 모듈 팩토리를 jest.isolateModules로 감싸는 패턴이 사용됩니다.

모듈화의 목적은 «파일 나누기»가 아니라 경계마다 테스트·문서·오너십을 걸 수 있게 하는 데 있습니다.


4. 미들웨어: persist, devtools, immer

미들웨어는 스토어 생성 함수를 감싸 로깅, 영속화, 불변성 보조 등을 끼워 넣습니다. 적용 순서가 곧 실행 순서이므로, 팀에서 한 번 정한 스택을 문서화하는 것이 중요합니다.

4.1 일반적인 스택 순서 (개념)

흔히 논의되는 순서는 다음과 같습니다. (프로젝트·버전에 따라 권장 순서가 다를 수 있습니다.)

  1. devtools: 액션 로깅·시간여행을 최상단에 두어 사람이 읽기 쉬운 이벤트 스트림을 만든다
  2. persist: 직렬화 가능한 스냅샷만 저장한다는 전제하에, devtools 아래 또는 위를 선택 — 직렬화 결과가 DevTools에 어떻게 보일지 확인
  3. immer: 상태 갱신을 가독성 있게 만든다. persist 직전에 immer 프로듀서가 직렬화 불가능한 값을 넣지 않았는지 검증

순서를 바꿔도 «동작»은 할 수 있지만, 디버깅 경험저장 포맷이 달라집니다. 특히 persist부분 저장(partialize), 버전 마이그레이션, 스토리지 백엔드(localStorage, IndexedDB, 세션) 선택이 핵심입니다.

4.2 persist 실무 체크리스트

  • partialize: 토큰 본문·대용량 리스트·민감 필드를 제외한다
  • version + migrate: 배포 후 스키마 변경을 안전하게 처리한다
  • onRehydrateFinish / 에러 핸들링: 깨진 JSON·스토리지 용량 초과 대비
  • SSR: Next.js 등에서는 하이드레이션 불일치를 피하기 위해 클라이언트 전용 초기화·플래그가 필요할 수 있다

persist는 «상태를 영구히 보관한다»는 편의이지, 서버 캐시를 대체하지 않습니다. React Query, SWR, tRPC 등과 역할이 겹치면 중복 진실이 생깁니다.

4.3 devtools와 액션 이름

devtools는 액션 이름을 명시적으로 붙일수록 가치가 커집니다. 익명 함수만 넘기면 스택 트레이스에서 의미를 잃기 쉽습니다. 팀 규칙으로 cart/addItem, session/logout 같은 이름 공간 문자열을 쓰면 검색도 쉬워집니다.

프로덕션 빌드에서 devtools를 제거할지 여부는 번들 크기·보안(상태 노출)현장 디버깅 사이의 트레이드오프입니다. 환경 변수로 개발에서만 켜는 팀이 많습니다.

4.4 immer와 성능 인식

immer는 개발 생산성을 크게 올리지만, 매우 큰 트리를 자주 갱신하면 프로듀서 오버헤드가 문제가 될 수 있습니다. 대규모 테이블·가상 스크롤 리스트에서는 행 단위 정규화(ID 맵)와 변경된 ID만 교체하는 패턴이 여전히 유효합니다.

아래는 immer 없이 얕은 갱신으로 배열 일부를 바꾸는 예입니다.

import { create } from 'zustand';

type Item = { id: string; qty: number };

type CartState = {
  itemsById: Record<string, Item>;
  bump: (id: string) => void;
};

export const useCartStore = create<CartState>()((set, get) => ({
  itemsById: {},
  bump: (id) =>
    set((s) => {
      const cur = s.itemsById[id];
      if (!cur) return s;
      return {
        itemsById: {
          ...s.itemsById,
          [id]: { ...cur, qty: cur.qty + 1 },
        },
      };
    }),
}));

이 패턴은 변경된 항목의 참조만 바뀌므로 리스트 렌더링에서 메모이제이션을 활용하기 좋습니다. 반면 중첩이 깊어지면 실수로 참조 공유를 깨뜨리기 쉬워져, 그때 immer를 도입하는 식의 단계적 선택이 자연스럽습니다.


5. 셀렉터 최적화

Zustand는 기본적으로 선택한 값이 바뀔 때 구독 컴포넌트를 갱신합니다. 문제는 «값이 바뀌었는지」 판단이 얕은 비교인지 참조 동일성인지, 그리고 셀렉터가 매번 새 객체를 만들지 여부입니다.

5.1 객체를 반환하는 셀렉터의 함정

import { create } from 'zustand';

type State = { a: number; b: number };

export const useAbStore = create<State>()(() => ({ a: 1, b: 2 }));

function useAB() {
  // 나쁜 예: 매 렌더마다 새 객체 → 구독자가 항상 «변경»으로 인식할 수 있음
  return useAbStore((s) => ({ a: s.a, b: s.b }));
}

위 패턴은 읽기 쉬워 보이지만, 파생 객체의 참조가 매번 새로 만들어질 수 있어 리렌더를 유발합니다. 해결책은 다음 중 하나입니다.

  • 필드를 분리 구독: useAbStore((s) => s.a) 등으로 쪼갠다
  • useShallow: zustand의 react/shallow로 얕은 비교를 쓴다
  • 파생 값을 원시값으로: 가능하면 셀렉터가 number | string | boolean을 반환하게 한다

5.2 useShallow 예시

import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';

type Vec = { x: number; y: number };

type State = {
  position: Vec;
  setPosition: (p: Vec) => void;
};

export const useSceneStore = create<State>()((set) => ({
  position: { x: 0, y: 0 },
  setPosition: (position) => set({ position }),
}));

export function usePosition() {
  return useSceneStore(useShallow((s) => s.position));
}

useShallow객체의 필드 값이 동일하면 리렌더를 건너뜁니다. 다만 깊은 중첩 구조에서는 얕은 비교만으로는 부족할 수 있어, 정규화된 상태 모양이 더 근본적인 해법인 경우가 많습니다.

5.3 subscribegetServerSnapshot (React 18+)

외부 스토어 패턴을 쓸 때는 React의 useSyncExternalStore와의 연계를 이해하면 좋습니다. Zustand의 useStore는 내부적으로 이와 유사한 구독 모델을 제공합니다. SSR 환경에서는 스냅샷 불일치 이슈가 있을 수 있어, 프레임워크별 권장 설정을 확인해야 합니다.

5.4 파생 구독 분리

여러 컴포넌트가 동일한 무거운 배열을 파생 필터링하는 경우, 스토어에 selectors.ts 모듈을 두고 동일한 순수 함수를 재사용하거나, zustand middleware로 메모이제이션을 붙이는 식으로 중복 계산을 줄입니다. 이는 성능뿐 아니라 비즈니스 규칙의 단일성에도 도움이 됩니다.


6. TypeScript 고급 타입

6.1 StateCreator와 미들웨어 타입 파라미터

StateCreator의 제네릭은 대략 다음 네 가지를 의미합니다: State, InMutators, OutMutators, Return. 미들웨어를 얹을수록 뮤테이터 타입이 누적되어, create 호출 시 타입 추론이 깨지는 경우가 있습니다. 이때 팀에서 선택할 수 있는 전략은 다음과 같습니다.

  • 팩토리 함수로 미들웨어가 적용된 create를 래핑해 한곳에서 타입을 고정한다
  • 스토어 인터페이스를 명시적으로 선언하고 create<AppStore>()로 고정한다
  • 모듈 경계마다 Slice 타입을 export하여 테스트와 문서에 재사용한다

6.2 액션 타입 분리

액션만 모아 Actions 타입을 분리하면, 컴포넌트에 상태 읽기 전용 타입을 주입해 실수로 set을 노출하지 않도록 설계할 수 있습니다.

import { createStore } from 'zustand/vanilla';

type CounterState = { value: number };
type CounterActions = { inc: () => void; dec: () => void };
type CounterStore = CounterState & CounterActions;

export const counterStore = createStore<CounterStore>()((set) => ({
  value: 0,
  inc: () => set((s) => ({ value: s.value + 1 })),
  dec: () => set((s) => ({ value: s.value - 1 })),
}));

/** UI에는 액션만, 표시 컴포넌트에는 상태만 — 제네릭으로 제한 가능 */
export type PickActions = Pick<CounterStore, 'inc' | 'dec'>;

바닐라 스토어(zustand/vanilla)는 React 없이도 동일한 상태 로직을 재사용할 수 있어, 비 React 영역(차트 라이브러리 콜백, 워커와의 브리지)과 공유하기 좋습니다.

6.3 StoreApi와 외부 구독

StoreApi 타입은 getState, setState, subscribe를 포함합니다. 한 번만 구독해 로그를 남기는 부가 기능을 붙일 때 유용합니다. 다만 이런 구독은 메모리 누수를 만들기 쉬우므로, 라이프사이클(마운트/언마운트)과 함께 설계합니다.


7. Zustand vs Jotai vs Recoil

세 라이브러리는 철학이 다릅니다. Zustand는 중앙 스토어 + 셀렉터, Jotai와 Recoil은 원자(atom) 단위의 세밀한 구독에 가깝습니다.

구분ZustandJotaiRecoil
모델보통 단일/소수 스토어, 객체 하나원자 단위 상태원자·셀렉터 그래프
구독 단위셀렉터로 좁힘 (설계 필요)원자 단위가 기본원자·셀렉터 단위
보일러플레이트적음매우 적음 — atom 정의RecoilRoot 등 설정 필요
미들웨어성숙한 persist/devtools생태계·유틸에 의존주로 자체 패턴
학습 곡선낮음낮음~중간중간
팀 합의스토어 경계·액션 규칙atom 분해 기준비동기 셀렉터·그래프 디버깅

Zustand가 유리한 경우: 전역 UI 플래그, 세션 레벨 클라이언트 상태, 명시적 액션 스트림이 중요한 제품. Jotai가 유리한 경우: 값 단위로 쪼개진 다수의 독립 상태, 파생 atom이 많은 UI 실험. Recoil은 특정 조직에서 이미 표준이거나, 복잡한 비동기 그래프를 Recoil 스타일로 표현하고 싶을 때 선택되는 경우가 많습니다.

Recoil은 Facebook이 실험적 단계임을 명시했던 시기가 있어, 신규 프로젝트는 유지보수 로드맵을 확인하고 결정하는 편이 안전합니다. (버전·문서는 시점에 따라 변합니다.)

원자 모델로 전환하면 파생 값의 캐시·무효화가 세련되지만, 팀 전체가 atom 분해 규칙에 익숙해야 합니다. 반면 Zustand는 Redux에 가까운 사고로 온 팀이 빠르게 합의를 만들기 쉽습니다.


8. 실전 대규모 앱 상태 관리

8.1 상태의 «계층» 나누기

대규모 앱에서 권장되는 분리는 다음과 같습니다.

  1. 서버 상태: React Query / RTK Query / SWR 등 — 캐시·재검증·에러·로딩의 주인
  2. URL 상태: 라우터 쿼리 — 공유 가능한 링크로 남겨야 하는 필터·페이지
  3. 폼 상태: React Hook Form 등 — 제출 전 임시 값, 필드 단위 검증
  4. 클라이언트 전역 UI: Zustand — 테마, 사이드바, 마스터 디테일 선택 키, 클라이언트 전용 플래그
  5. 스트림·실시간: 별도 채널 + 필요 시 Zustand로 최소 파생 상태만 동기화

이렇게 나누면 Zustand 스토어는 크기와 수명이 통제됩니다.

8.2 기능 플래그·권한

권한 정보를 Zustand에 넣는 경우, 서버와 불일치할 수 있음을 전제로 방어적 UI를 설계합니다. 예: 관리자 메뉴는 보이지만 API는 403 — 서버 응답이 최종 권한입니다. 클라이언트 스토어는 편의 캐시로 취급하는 것이 안전합니다.

8.3 성능: 렌더 예산과 정규화

대규모에서의 성능은 종종 리렌더 횟수보다 데이터 모양 문제입니다. 목록이 커질수록 ID 기반 정규화셀렉터의 원시 반환이 효과를 발휘합니다. 또한 가상 스크롤과 함께 쓸 때는 스토어에서 보이는 윈도우만 구독하도록 설계합니다.

8.4 마이그레이션: Redux에서 Zustand로

Redux에서 이전할 때는 한 번에 전부보다 기능 단위 슬라이스 이동이 안전합니다. DevTools 사용 이력이 있다면 액션 네이밍·로그 습관을 Zustand에서도 재현해, 운영 중인 팀의 디버깅 경험을 끊지 않는 것이 중요합니다.

8.5 팀 운영 규칙 예시

  • 스토어 파일 네이밍, 액션 접두사, 슬라이스 오너(코드오너) 지정
  • PR 템플릿에 «전역 스토어 키 추가 시 팀 합의 필요» 체크
  • Storybook에서 스토어 목 초기화 헬퍼 제공

9. 흔한 문제 해결 (트러블슈팅)

9.1 «셀렉터를 썼는데도 자꾸 리렌더된다»

객체 리터럴을 반환하는 셀렉터인지, 부모 props가 매번 새 참조인지 확인합니다. useShallow와 필드 분리 구독을 병행하고, 파생 객체를 스토어에 useMemo처럼 저장하는 안티패턴은 피합니다(동기화 버그가 생기기 쉽습니다).

9.2 «persist 이후 상태가 이상하다»

마이그레이션 버전 없이 스키마를 바꿨는지, Date·Map 같은 비직렬화 값이 들어갔는지 확인합니다. 필요 시 zod로 rehydrate 직후 검증을 붙입니다.

9.3 «devtools에 액션이 안 보인다»

액션 이름이 익명인지, 프로덕션에서 devtools가 꺼졌는지, 미들웨어 순서가 바뀌며 다른 레이어가 가로채는지 점검합니다.


10. 정리

Zustand의 고급 활용은 미들웨어 나열이 아니라 상태 경계·파생 위치·구독 단위에 대한 팀 합의에서 시작합니다. Slice 패턴으로 모듈화하고, persist·devtools·immer역할에 맞게 조합하며, 셀렉터에서는 참조 동일성과 얕은 비교를 의식하면 대규모 앱에서도 운영 가능한 구조를 유지할 수 있습니다. Jotai·Recoil과의 선택은 데이터 모델이 스토어 중심인지 원자 중심인지, 팀의 사고방식에 맞추는 것이 가장 현실적인 기준입니다.

이후 학습으로는 React Query와의 역할 분담, SSR 프레임워크별 권장 패턴, 상태 정규화를 주제로 샘플 앱을 작게 만들어 보는 것을 권합니다.


참고 및 더 읽기