Zustand의 combine과 immer는 각각 어떤 문제에 도움이 되나요?
combine은 초기 state와 action 정의를 나누어 타입 추론을 돕고, immer는 중첩 객체를 새 상태로 바꾸는 코드를 간단하게 만듭니다. 둘 다 실제 문제가 있을 때 선택합니다.
적용 조건·범위: 환경: Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기에서 준비한 React·TypeScript 및 Zustand 5 연습 앱입니다. 기존 TaskFlow는 필요하지 않습니다. src/App.tsx 전체를 아래 내용으로 교체합니다.
본문 근거: zustand.docs.pmnd.rs/middlewares/combine · zustand.docs.pmnd.rs/middlewares/immer
이 기술만 따로 실행하기
이 개념 직접 실행
상태를 바꾸고 화면에 반영되는 결과를 확인합니다.
예제 크게 보기 (새 탭)환경: Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기에서 준비한 React·TypeScript 및 Zustand 5 연습 앱입니다. 기존 TaskFlow는 필요하지 않습니다. src/App.tsx 전체를 아래 내용으로 교체합니다.
새 의존성만 추가합니다.
npm install immer@10
import { create } from 'zustand';
import { combine } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
const initial = { preferences: { list: { compact: false, showCompleted: true } } };
const useDemo = create(immer(combine(initial, (set) => ({
toggleCompact: () => set((state) => {
state.preferences.list.compact = !state.preferences.list.compact;
}),
}))));
export default function App() {
const compact = useDemo((state) => state.preferences.list.compact);
const showCompleted = useDemo((state) => state.preferences.list.showCompleted);
const toggleCompact = useDemo((state) => state.toggleCompact);
return (
<main>
<p>compact: {String(compact)} / showCompleted: {String(showCompleted)}</p>
<button onClick={toggleCompact}>간격 전환</button>
</main>
);
}
npm run dev
npm run build
확인 순서: 간격 전환을 두 번 누릅니다.
기대 결과: compact는 false → true → false로 바뀌고 showCompleted는 계속 true입니다. combine이 초기 상태와 action을 합치고, immer가 중첩 변경을 새 상태로 만듭니다.
자가 확인: immer 없이 같은 결과를 만들려면 preferences와 list를 각각 복사해야 하는 이유를 설명하세요.
검증: 2026-09-28, TypeScript strict 검사와 Vite 라이브러리 빌드, jsdom 버튼 조작 후 표시값 검사를 통과했습니다. 실제 브라우저 배치와 누적 프로젝트 전체 실행은 확인 범위 밖입니다.
combine
기본 store는 하나의 객체를 반환합니다.
create<TaskStore>((set) => ({
filter: 'all',
selectedTaskId: null,
setFilter: (filter) => set({ filter }),
}));
combine을 사용하면 초기 state와 action을 나눌 수 있습니다.
import { combine } from 'zustand/middleware';
const initialState = {
filter: 'all' as const,
selectedTaskId: null as string | null,
};
export const useStore = create(
combine(initialState, (set) => ({
setFilter: (filter: TaskFilter) =>
set({ filter }),
})),
);
작은 store라면 기본 create 구조가 더 단순할 수 있습니다.
immer가 필요한 경우
중첩 상태를 직접 불변 업데이트하면 코드가 길어질 수 있습니다.
set((state) => ({
preferences: {
...state.preferences,
list: {
...state.preferences.list,
compact:
!state.preferences.list.compact,
},
},
}));
immer middleware를 사용하면 draft를 변경하는 방식으로 작성할 수 있습니다.
import { immer } from 'zustand/middleware/immer';
immer((set) => ({
preferences: {
list: {
compact: false,
},
},
toggleCompact: () =>
set((state) => {
state.preferences.list.compact =
!state.preferences.list.compact;
}),
}))
immer 내부에서는 draft를 수정하지만 최종적으로는 불변 업데이트가 생성됩니다.
immer를 쓰지 않아도 되는 경우
다음처럼 상태가 얕다면 기본 set이 더 단순합니다.
set({ filter: 'completed' });
또는 배열 한 단계만 변경한다면 map/filter로 충분합니다.
set((state) => ({
tasks: state.tasks.filter(
(task) => task.id !== id,
),
}));
middleware 하나를 추가할 때마다 타입과 디버깅 구조도 함께 복잡해질 수 있습니다.
combine과 immer를 함께 쓸 때
기술적으로 조합할 수 있지만, 조합 자체가 목표가 되어서는 안 됩니다.
타입 추론을 정리할 필요 있음
→ combine 검토
중첩 상태 업데이트가 반복적으로 복잡함
→ immer 검토
둘 다 문제 없음
→ 기본 create 유지
학습 완료 체크리스트
- combine을 왜 사용하는지 설명할 수 있습니다.
- immer가 중첩 업데이트에 유리한 이유를 설명할 수 있습니다.
- 얕은 state에서는 기본 set이 더 단순할 수 있음을 이해했습니다.
- middleware를 필요에 따라 선택할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 여러 화면이 공유하는 상태를 관리합니다.
TaskFlow에 중첩된 UI 설정을 추가하고 immer 적용 전후를 비교합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 직접 실행
기존 tasks·actions·persist를 보존한 전체 Store에 combine·immer와 preferences를 더합니다. compact·showCompleted 버튼이 표시 설정을 바꾸되 작업은 삭제하지 않습니다.
예제 크게 보기 (새 탭)중간부터 시작하는 전체 실습 파일
기존 React Context TaskFlow의 작업 목록·추가·완료·삭제 화면을 이어서 확장합니다.
이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.
시작본에서 직접 바꾸기
시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.
package-lock.json— 기존 파일의 같은 위치를 갱신합니다.package.json— 기존 파일의 같은 위치를 갱신합니다.src/App.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskList.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskPreferences.tsx— 이전 단계에 없던 파일을 만듭니다.src/store/useTaskStore.ts— 기존 파일의 같은 위치를 갱신합니다.
변경 이유와 결과: 기존 tasks·actions·persist를 보존한 전체 Store에 combine·immer와 preferences를 더합니다. compact·showCompleted 버튼이 표시 설정을 바꾸되 작업은 삭제하지 않습니다.
완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.
src/App.tsx 전체 파일
import { useEffect } from 'react';
import Header from './components/Header';
import NetworkStatus from './components/NetworkStatus';
import StateResetDemo from './components/StateResetDemo';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import TaskFilter from './components/TaskFilter';
import { useTaskStore } from './store/useTaskStore';
import TaskSelection from './components/TaskSelection';
import TaskModalControls from './components/TaskModalControls';
import TaskSummary from './components/TaskSummary';
import TaskPreferences from './components/TaskPreferences';
function RemainingCount() {
const tasks = useTaskStore((state) => state.tasks);
const remainingCount = tasks.filter((task) => !task.completed).length;
useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
return <p>남은 작업: {remainingCount}</p>;
}
export default function App() {
return (
<main className="app">
<Header />
<NetworkStatus />
<RemainingCount />
<TaskFilter />
<TaskForm />
<TaskList />
<TaskSelection />
<TaskModalControls />
<TaskSummary />
<TaskPreferences />
<StateResetDemo />
</main>
);
}
src/components/TaskList.tsx 전체 파일
import { useTaskStore } from '../store/useTaskStore';
import TaskItem from './TaskItem';
export default function TaskList() {
const tasks = useTaskStore(
(state) => state.tasks,
);
const filter = useTaskStore(
(state) => state.filter,
);
const preferences = useTaskStore((state) => state.preferences);
const visibleTasks = tasks.filter((task) => {
if (!preferences.list.showCompleted && task.completed) return false;
if (filter === 'active') {
return !task.completed;
}
if (filter === 'completed') {
return task.completed;
}
return true;
});
if (visibleTasks.length === 0) {
return <p>표시할 작업이 없습니다.</p>;
}
return (
<ul style={{ lineHeight: preferences.list.compact ? 1.4 : 2 }}>
{visibleTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
/>
))}
</ul>
);
}
src/components/TaskPreferences.tsx 전체 파일
import { useTaskStore } from '../store/useTaskStore';
export default function TaskPreferences() {
const compact = useTaskStore((state) => state.preferences.list.compact);
const showCompleted = useTaskStore((state) => state.preferences.list.showCompleted);
const toggleCompact = useTaskStore((state) => state.toggleCompact);
const toggleShowCompleted = useTaskStore((state) => state.toggleShowCompleted);
return (
<section aria-label="목록 설정">
<button onClick={toggleCompact}>간격: {compact ? '좁게' : '기본'}</button>
<button onClick={toggleShowCompleted}>완료 항목: {showCompleted ? '표시' : '숨김'}</button>
</section>
);
}
src/store/useTaskStore.ts 전체 파일
import { create } from 'zustand';
import { combine, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type { Task } from '../types/task';
export type TaskFilter = 'all' | 'active' | 'completed';
export type { Task } from '../types/task';
const initialState = {
tasks: [] as Task[],
filter: 'all' as TaskFilter,
selectedTaskId: null as string | null,
isTaskModalOpen: false,
preferences: { list: { compact: false, showCompleted: true } },
};
const creator = persist(
combine(initialState, immer((set) => ({
addTask: (title: string) => {
const value = title.trim();
if (!value) return;
set((state) => { state.tasks.push({ id: crypto.randomUUID(), title: value, completed: false }); });
},
toggleTask: (id: string) => set((state) => {
const task = state.tasks.find((item) => item.id === id);
if (task) task.completed = !task.completed;
}),
deleteTask: (id: string) => set((state) => {
state.tasks = state.tasks.filter((task) => task.id !== id);
if (state.selectedTaskId === id) state.selectedTaskId = null;
}),
selectTask: (selectedTaskId: string | null) => set({ selectedTaskId }),
openTaskModal: () => set({ isTaskModalOpen: true }),
closeTaskModal: () => set({ isTaskModalOpen: false }),
setFilter: (filter: TaskFilter) => set({ filter }),
toggleCompact: () => set((state) => { state.preferences.list.compact = !state.preferences.list.compact; }),
toggleShowCompleted: () => set((state) => { state.preferences.list.showCompleted = !state.preferences.list.showCompleted; }),
}))),
{
name: 'taskflow-ui',
version: 0,
partialize: (state) => ({ filter: state.filter, selectedTaskId: state.selectedTaskId, preferences: state.preferences }),
},
);
export const useTaskStore = create(creator);
완료 확인
- 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤
npm ci로 잠금 파일의 의존성을 설치합니다. npm run dev실행 후 터미널의 localhost 주소를 엽니다.- 기존 tasks·actions·persist를 보존한 전체 Store에 combine·immer와 preferences를 더합니다. compact·showCompleted 버튼이 표시 설정을 바꾸되 작업은 삭제하지 않습니다.
npm run typecheck와npm run build가 성공하면 다음 프로젝트 단계로 갑니다.
이 체크포인트는 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과했습니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.
아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.
누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.
이전 프로젝트 단계: Zustand persist 사용법: 새로고침 후 상태 저장하기의 적용 결과에서 시작합니다. 아래 체크포인트 이름은 단계 식별자이며 다운로드 파일명이나 Git 명령이 아닙니다.
src/store/useTaskStore.ts에서 preferences.list.compact 변경 부분에 immer를 적용합니다. 기존 filter·선택값·persist 범위는 유지합니다. 아래 코드는 middleware 핵심 구조이므로 전체 기존 store를 덮어쓰지 않습니다.
이전 체크포인트
zustand-step-10-persist
이번 목표
TaskFlow에 중첩된 UI 설정을 추가하고 immer 적용 전후를 비교합니다.
UI 설정 타입
type UiPreferences = {
list: {
compact: boolean;
showCompleted: boolean;
};
};
기본 업데이트는 다음처럼 작성할 수 있습니다.
set((state) => ({
preferences: {
...state.preferences,
list: {
...state.preferences.list,
compact:
!state.preferences.list.compact,
},
},
}));
중첩 설정이 계속 늘어난다고 가정하면 immer를 적용할 수 있습니다.
설치
immer middleware를 사용할 때는 immer 패키지가 필요합니다.
npm install immer
전체 파일·코드 보기
src/store/useTaskStore.ts 핵심 구조
import { create } from 'zustand';
import {
persist,
combine,
} from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
type TaskFilter =
| 'all'
| 'active'
| 'completed';
const initialState = {
filter: 'all' as TaskFilter,
selectedTaskId: null as string | null,
isTaskModalOpen: false,
preferences: {
list: {
compact: false,
showCompleted: true,
},
},
};
export const useTaskStore = create(
persist(
immer(
combine(initialState, (set) => ({
toggleCompact: () =>
set((state) => {
state.preferences.list.compact =
!state.preferences.list.compact;
}),
toggleShowCompleted: () =>
set((state) => {
state.preferences.list.showCompleted =
!state.preferences.list.showCompleted;
}),
})),
),
{
name: 'taskflow-ui',
partialize: (state) => ({
filter: state.filter,
selectedTaskId:
state.selectedTaskId,
preferences:
state.preferences,
}),
},
),
);
이 예시는 combine과 immer의 역할을 이해하기 위한 구조입니다. 실제 프로젝트에서는 tasks action까지 모두 같은 방식으로 옮길지, UI 설정 store만 분리할지 다음 회차와 14회차에서 판단합니다.
Before / After
Before
중첩 preferences 변경 시 spread 반복
After
immer draft를 사용해 중첩 변경 표현 단순화
실행 확인
- compact 설정을 여러 번 변경합니다.
- 새로고침 후 preferences가 persist되는지 확인합니다.
- 기존 filter와 selectedTaskId가 유지되는지 확인합니다.
- modal은 저장되지 않는지 확인합니다.
npm run build
현재 확장 단계
✓ 1~10. 기본 Store와 persist
→ 11. combine / immer
○ 12. subscribeWithSelector / devtools
○ 13. migration
○ 14. 실무 설계 점검
○ 15. 최종 통합
완료 체크포인트
zustand-step-11-combine-immer
다음 학습
다음 글에서는 React 렌더 구독과 store 외부 구독을 구분하고, subscribeWithSelector와 devtools로 특정 상태 변화와 action 흐름을 추적합니다.
다음 프로젝트 단계: Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습
공식 문서
- combine: https://zustand.docs.pmnd.rs/middlewares/combine
- immer: https://zustand.docs.pmnd.rs/middlewares/immer
글 소개·학습 준비·전체 목차

Zustand · 11 / 15 · 고급 학습 · combine·immer
Zustand store가 커지면 combine과 immer를 붙이면 더 좋아 보일 수 있습니다. 하지만 middleware는 문제를 해결할 때만 도입하는 것이 좋습니다.
combine은 초기 state와 action 정의를 나눠 타입 추론을 단순하게 만들 때 유용하고, immer는 중첩 객체를 여러 단계 복사해야 하는 업데이트를 간단하게 만들 때 유용합니다.
이번 글에서 배우는 내용
- combine이 하는 일
- immer middleware가 필요한 경우
- 얕은 state에서는 immer가 필요 없는 이유
- 중첩 상태 불변 업데이트
- middleware를 과도하게 사용하지 않는 기준
- TaskFlow UI 설정에 적용
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.