Zustand `create((set) => ({...}))` 완전 분해

들어가기 전에
Zustand는 React에서 **전역 상태(global state)**를 다룰 수 있게 해 주는 라이브러리입니다. "전역 상태"란 여러 컴포넌트가 공유하는 데이터를 말합니다. 예를 들어 할 일 목록(todos)을 여러 화면에서 함께 보고 수정해야 한다면, 그 목록은 한 곳에 모아두고 모두가 그 한 곳을 바라보게 해야 합니다. Zustand는 그 "한 곳"을 store라고 부릅니다.
이 문서는 다음 코드를 한 줄씩 분해해서 설명합니다.
export const useTodoStore = create<TodoStore>((set) => ({ todos: [], setTodos: (todos) => set({ todos }), addTodo: (text) => set((state) => ({ todos: [...state.todos, { id: Date.now(), text, done: false }], })), // ... }));
특히 헷갈리는 세 가지를 차근차근 풀어보겠습니다.
create안에 왜 함수가 들어가나? ((set) => (...))set은 어디서 왔고 무엇을 하나?- 어떤 때는
set({ todos }), 어떤 때는set((state) => ({...})). 왜 두 가지 모양인가?
1. create<TodoStore>(...) — store를 만드는 공장
create는 Zustand가 제공하는 함수입니다. 이 함수에 "store의 모양"을 알려주면, 그 모양대로 store를 만들어서 돌려줍니다.
const useTodoStore = create<TodoStore>(...)
<TodoStore>: TypeScript의 **제네릭(generic)**입니다. "이 store는TodoStore타입을 따른다"고 미리 알려주는 부분입니다.TodoStore에는 어떤 상태(todos)가 있고 어떤 메서드(addTodo,toggleTodo등)가 있는지 정의되어 있습니다.create(...): 괄호 안에 "store를 어떻게 만들지" 설명하는 함수를 넣습니다. 결과로useTodoStore라는 **훅(hook)**이 만들어집니다.
이 훅은 컴포넌트에서 이렇게 씁니다.
const todos = useTodoStore((state) => state.todos); const addTodo = useTodoStore((state) => state.addTodo);
2. (set) => ({...}) — store 정의를 담은 화살표 함수
create의 괄호 안에 들어간 것을 따로 떼어보면 이렇게 생겼습니다.
(set) => ({ todos: [], setTodos: (todos) => set({ todos }), // ... })
이건 화살표 함수입니다. 풀어서 보면:
function makeStore(set) { return { todos: [], setTodos: (todos) => set({ todos }), // ... }; }
왜 이런 모양인가?
Zustand는 store를 만들 때 set이라는 함수를 우리 손에 쥐어주고 싶어합니다. 그래서 "당신이 직접 store 객체를 만들되, 내가 set을 줄 테니까 그걸 받아서 써" 하는 구조입니다.
비유하자면 — 가구 가게에서 책상을 만들어 달라고 부탁할 때, 사장님이 "당신이 원하는 모양 그려서 줘. 대신 망치는 내가 줄게"라고 하는 것과 같습니다. set이 그 망치입니다.
흐름:
- 우리가
create(...)에 화살표 함수를 넣음. - Zustand가 그 화살표 함수를 호출하면서
set을 인자로 넘김. - 우리 화살표 함수는
set을 받아서 store 객체 안에 박아 넣고, 그 객체를 return. - Zustand가 그 객체를 store로 등록.
그래서 addTodo 같은 메서드 안에서 set(...)을 부를 수 있게 되는 겁니다.
3. set 함수 — 상태를 바꾸는 유일한 통로
set은 store의 상태를 바꾸는 함수입니다. Zustand의 규칙은 단순합니다: 상태는 오직 set을 통해서만 바꾼다. 직접 state.todos = ... 같은 짓은 안 됩니다.
set은 두 가지 모양으로 부를 수 있습니다.
모양 A: set(객체) — 새 값을 직접 박기
set({ todos: [] }); set({ todos: [{ id: 1, text: "공부", done: false }] });
set 안에 객체를 그대로 넣으면, Zustand는 store의 해당 키를 그 값으로 바꿉니다. 현재 상태가 무엇이든 상관없이 그냥 덮어씁니다.
예제 코드에서:
setTodos: (todos) => set({ todos }),
이 메서드는 외부에서 받은 todos 배열을 통째로 store에 박는 것입니다. 현재 store 안의 todos가 무엇이었든 신경 쓰지 않고 그냥 덮어씁니다. 그래서 객체 형태로 충분합니다.
참고:
{ todos }는{ todos: todos }의 줄임 표현입니다 (객체 단축 표기).
모양 B: set((state) => 객체) — 현재 상태를 보고 새 값 만들기
set((state) => ({ todos: [...state.todos, newTodo] }));
set 안에 함수를 넣으면, Zustand는 현재 상태를 그 함수에 넣어서 호출합니다. 함수는 새 상태(객체)를 return하고, Zustand는 그 객체로 상태를 업데이트합니다.
현재 상태를 알아야 새 상태를 만들 수 있을 때 이 모양을 씁니다.
예제 코드:
addTodo: (text) => set((state) => ({ todos: [...state.todos, { id: Date.now(), text, done: false }], })),
새 todo를 추가하려면 기존 todos 배열에 새 todo를 붙여야 합니다. 기존 배열을 알아야 하니까 state가 필요합니다. 그래서 콜백 형태를 씁니다.
toggleTodo도 마찬가지입니다.
toggleTodo: (id) => set((state) => ({ todos: state.todos.map((todo) => todo.id === id ? { ...todo, done: !todo.done } : todo ), })),
특정 id의 todo만 done을 뒤집어야 하니까, 기존 todos를 돌면서 작업해야 합니다. 기존 todos를 알아야 하므로 콜백 형태.
removeTodo도 마찬가지로 기존 todos에서 일부를 빼야 하므로 콜백 형태입니다.
4. 그래서 어떤 때 모양 A, 어떤 때 모양 B?
한 줄로 정리하면:
| 상황 | 모양 |
|---|---|
| 현재 상태 신경 쓸 필요 없이 그냥 덮어씌울 때 | set({ key: value }) |
| 현재 상태를 보고 변형해서 새 값 만들 때 | set((state) => ({ key: ... })) |
addTodo, toggleTodo, removeTodo는 기존 todos에 뭔가를 더하거나, 일부를 바꾸거나, 일부를 빼는 일입니다. 모두 기존 todos를 알아야 하니까 콜백 형태입니다.
setTodos는 외부에서 받은 새 배열을 그냥 통째로 박는 일입니다. 기존 todos는 알 필요 없으니까 객체 형태입니다.
5. 한 번 더 — 전체 코드 의미 따라가기
export const useTodoStore = create<TodoStore>((set) => ({
→ "TodoStore 타입의 store를 만들어줘. store의 모양은 이 화살표 함수를 호출해서 받은 객체야. 화살표 함수에는 set을 넘겨줘."
todos: [],
→ 초기 상태. 빈 배열로 시작.
setTodos: (todos) => set({ todos }),
→ "외부에서 todos 배열을 받으면 그대로 덮어써."
addTodo: (text) => set((state) => ({ todos: [...state.todos, { id: Date.now(), text, done: false }], })),
→ "외부에서 text를 받으면, 현재 todos를 보고 그 끝에 새 todo를 붙여서 새 배열로 만들어."
toggleTodo: (id) => set((state) => ({ todos: state.todos.map((todo) => todo.id === id ? { ...todo, done: !todo.done } : todo ), })),
→ "외부에서 id를 받으면, 현재 todos를 돌면서 그 id에 해당하는 todo의 done만 뒤집어."
removeTodo: (id) => set((state) => ({ todos: state.todos.filter((todo) => todo.id !== id), })), }));
→ "외부에서 id를 받으면, 현재 todos에서 그 id에 해당하는 todo를 빼버려."
6. 자주 헷갈리는 질문
Q1. set은 누가 만들어 주나?
A. Zustand가 만들어 줍니다. 우리는 화살표 함수에 (set) => 라고 적기만 하면, Zustand가 그 자리에 자기가 만든 set 함수를 끼워 넣어 줍니다. 우리는 그걸 받아서 쓰기만 하면 됩니다.
Q2. 왜 set({ todos })라고 쓰면 다른 키(setTodos, addTodo 등)는 안 지워지나?
A. Zustand의 set은 **머지(merge)**합니다. 우리가 넘긴 객체의 키만 갈아끼우고, 나머지는 그대로 둡니다. 그래서 set({ todos: [] }) 라고 해도 addTodo 같은 메서드는 안 사라집니다.
Q3. set((state) => state.todos.push(...)) 처럼 직접 수정하면 안 되나?
A. 안 됩니다. Zustand는 새 객체가 와야 변화를 감지합니다. push는 기존 배열을 그대로 두고 안에만 바꾸는 거라서 변화를 감지 못합니다. 그래서 [...state.todos, newItem]처럼 새 배열을 만들어 넘겨야 합니다.
이 원칙은 React 전반에서 똑같이 적용됩니다. **불변성(immutability)**이라고 부릅니다.
Q4. (state) => 의 state는 어디서 왔나?
A. set이 콜백 형태로 불릴 때, Zustand가 현재 store 상태를 그 콜백에 넘겨줍니다. state라는 이름은 우리가 붙인 변수명일 뿐, s라고 써도 되고 currentTodos라고 써도 됩니다.
set((s) => ({ todos: [...s.todos, newTodo] }));
이렇게 써도 똑같이 작동합니다.
Q5. 화살표 함수 두 개가 겹쳐서 어디까지가 무엇인지 모르겠어요.
A. 두 개의 화살표 함수가 겹쳐 있는 게 맞습니다. 풀어 보면:
addTodo: (text) => set((state) => ({ todos: [...state.todos, { id: Date.now(), text, done: false }], })),
이걸 풀면:
addTodo: function (text) { return set(function (state) { return { todos: [...state.todos, { id: Date.now(), text, done: false }], }; }); }
바깥 함수: 외부에서 text를 받음.
안쪽 함수: set에게 넘기는 콜백. state를 받아서 새 상태 객체를 return.
7. 한 줄 정리
create((set) => ({...}))의 정체는 — Zustand가 우리에게set이라는 망치를 쥐어주면서 "이걸 써서 store 객체를 만들어 줘"라고 부탁하는 구조다.
set은 두 가지 모양으로 쓴다.
- 새 값을 통째로 덮어쓸 때:
set({ key: value })- 현재 상태를 보고 새 값을 만들 때:
set((state) => ({ key: ... }))
이 두 모양의 갈림길은 단 하나의 질문으로 정해진다.
"현재 상태를 알아야 새 상태를 만들 수 있는가?"
알아야 하면 콜백, 몰라도 되면 객체.

댓글
댓글을 작성하려면 이 필요합니다.