Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성

2026.09.15·약 8분·작성: 해비·블로그 소개
Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성 학습 표지

이 글은 프론트엔드 라이브러리 연결 과정의 일부입니다. React 컴포넌트·props·state·이벤트와 TypeScript 객체·배열 타입을 먼저 익혀 주세요. 원본 강의의 문장을 옮기는 대신 독립적인 예제와 확인 과제를 구성했습니다.

학습 목표: 초기 상태와 action을 나누고 이전 객체를 보존하며 중첩 값을 변경합니다.

예상 학습 시간: 35분. 개인별 차이가 있으며 설치 시간은 제외합니다.

combine과 immer가 해결하는 문제는 다릅니다

combine은 초기 상태와 추가 상태·action을 합쳐 store를 구성하는 도구입니다. immer는 복사해야 하는 중첩 경로가 많을 때 변경 코드를 쓰기 쉽게 합니다. 두 미들웨어는 함께 쓸 수 있지만 반드시 같이 써야 하는 것은 아닙니다. 카운터 하나라면 기본 set의 함수형 업데이트만으로 충분합니다.

npm install zustand@5.0.15 immer@11.1.18

초기값이 추론의 출발점입니다

combine의 첫 객체가 count: 0이면 count는 숫자로 추론됩니다. 빈 todos 배열은 문맥에 따라 never[]처럼 의도보다 좁게 추론될 수 있어 Todo[] 타입을 명시합니다. combine을 썼다고 모든 타입 선언이 사라지는 것은 아닙니다. 외부 데이터 계약과 action 매개변수는 여전히 타입으로 표현해야 합니다.

원본을 바꾸는 것처럼 보이는 문법

immer 내부 set 콜백의 state는 변경 사항을 기록하는 draft입니다. state.profile.visits += 1로 작성해도 이전 profile 객체의 값은 보존되어야 합니다. getState()로 얻은 실제 객체를 바깥에서 직접 바꾸는 것과 다릅니다. set 콜백은 draft를 변경하거나 새 결과를 반환하는 방식 중 하나를 택합니다. push의 반환값처럼 숫자를 의도치 않게 반환하지 않도록 중괄호를 사용하세요.

관찰할 값과 불변성

실습에서 visits를 증가시키고 count가 유지되는지 확인합니다. 단지 숫자가 화면에 보이는 것만으로 불변성을 검증한 것은 아닙니다. 변경 전 객체를 따로 잡아 두고 변경 후에도 이전 값이 그대로인지 테스트합니다. 이 과정을 알아야 selector가 왜 참조 변화에 의존하는지도 이해할 수 있습니다.

combine의 set/get 타입은 초기 상태 중심으로 추론되는 부분이 있습니다. replace=true로 store 전체를 교체하면 action을 지울 수 있으므로 이 과정에서는 사용하지 않습니다. get을 store 생성 중 동기적으로 호출해 아직 없는 상태를 읽는 패턴도 피합니다. 상태 읽기가 필요하면 실행 시점의 action 안에서 읽으세요.

직접 해보기와 확인 질문

profile에 다른 문자열 필드를 추가한 뒤 visits만 증가시키세요. 이전 profile 객체, 변경 후 profile 객체, count 값을 비교하세요.

풀이 기준 보기

이전 객체의 visits는 유지되고 새 객체만 증가합니다. count와 다른 필드는 유지됩니다. 중첩 참조를 바꾸는 이유를 설명할 수 있어야 하며, 미들웨어를 뺀 코드에서는 직접 변이가 아니라 경로 복사가 필요합니다.

공통 실습 실행

아래 공통 ZIP을 풀고 Node.js 24 환경에서 실행합니다. package-lock.json에 실습 버전을 고정했습니다. 본문의 경로와 ZIP의 경로는 같습니다.

npm ci
npm run dev

터미널에 표시된 주소를 열고 이 글에 해당하는 메뉴를 선택하세요. 서버 Todo만 별도 터미널의 npm run server가 필요합니다. 본문 코드에서 생략한 공통 설정과 UI 파일까지 ZIP에 포함되어 있습니다.

프론트엔드 라이브러리 공통 실습 ZIP

src/stores/counter.ts

공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.

import { create } from "zustand";
import { combine, subscribeWithSelector } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";

export const useCounter = create(
  subscribeWithSelector(
    immer(
      combine({ count: 0, profile: { visits: 0 } }, (set) => ({
        increase: () =>
          set((state) => {
            state.count += 1;
          }),
        visit: () =>
          set((state) => {
            state.profile.visits += 1;
          }),
      })),
    ),
  ),
);

src/pages/middleware-page.tsx

공통 실습 ZIP 안의 전체 파일입니다. 이 파일 하나만으로 독립 실행되지는 않으며 import하는 파일은 ZIP에 함께 들어 있습니다.

import { useEffect, useState } from "react";
import { useCounter } from "../stores/counter";
import { Button } from "@/components/ui/button";

export default function MiddlewarePage() {
  const count = useCounter((state) => state.count);
  const visits = useCounter((state) => state.profile.visits);
  const increase = useCounter((state) => state.increase);
  const visit = useCounter((state) => state.visit);
  const [log, setLog] = useState("count 변경 대기");
  useEffect(
    () =>
      useCounter.subscribe(
        (state) => state.count,
        (next, previous) => setLog(`${previous} → ${next}`),
      ),
    [],
  );
  return (
    <section>
      <h2>미들웨어 관찰</h2>
      <p>
        count: {count} / visits: {visits}
      </p>
      <div className="flex gap-4">
        <Button onClick={increase}>count 증가</Button>
        <Button onClick={visit}>visits 증가</Button>
      </div>
      <p role="status">구독 기록: {log}</p>
      <p>visits만 바꾸면 count 구독 기록은 바뀌지 않습니다.</p>
    </section>
  );
}

UI 파일 범위: ZIP의 src/components/ui는 shadcn CLI 생성물이 아닙니다. 레지스트리 접속 실패로 Radix·Sonner·Embla를 사용하는 축약 학습용 구현을 작성했습니다. 공식 설치 절차는 별도 프로젝트에서 비교하세요. 공식 컴포넌트 전체 기능과 동일하다고 보장하지 않습니다.

검증 범위

Node.js 24.19.0, React 19.3.0, React Router 7.18.3, Zustand 5.0.15, TanStack Query 5.102.8, Tailwind CSS 4.3.3에서 타입 검사와 Vite 빌드를 확인했습니다. 자동 테스트는 jsdom 기반입니다. 실제 브라우저의 포커스·레이아웃·화면낭독기와 모든 네트워크 경쟁 상황은 별도 확인 대상입니다.

npm test
npm run build

공식 문서

2026-09-14 확인. 공식 최신 문서의 버전과 실습 고정 버전이 다를 수 있으므로 설치 버전은 ZIP을 기준으로 비교합니다.

이 글이 도움이 되었나요?

조회 중

Zustand 학습 순서

필수 14개 · 전체 15개

읽음 기록 관리

전체 과정 목차 (15개)
  1. 필수 길잡이 · Zustand 학습 로드맵: store·action·selector·persist 순서
  2. 필수 길잡이 · React state vs Zustand: 전역 상태가 필요한 기준
  3. 필수 학습 · Zustand란? React 상태 관리 선택 기준과 기본 Store
  4. 필수 학습 · Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기
  5. 필수 학습 · Zustand state 사용법: 값 읽기와 변경 흐름 익히기
  6. 필수 학습 · Zustand action 사용법: 상태 변경 로직을 store로 분리하기
  7. 필수 학습 · Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기
  8. 필수 학습 · Zustand 리렌더링 원리와 selector 최적화 방법
  9. 필수 학습 · Zustand persist 사용법: 새로고침 후 상태 저장하기
  10. 필수 학습 · Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때
  11. 선택 참고 · Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법
  12. 필수 선수 · Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수
  13. 필수 학습 · Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성 현재 글
  14. 필수 학습 · Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습
  15. 필수 학습 · Zustand Todo 완성 실습: actions·선택 훅·persist 연결

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기