PromleeBlog
sitemap
aboutMe

posting thumbnail
실전 예제로 배우는 Zustand - Zustand 기초부터 아키텍쳐 설계까지 3편
Learning Zustand with Practical Examples - From Basics to Architecture Part 3

📅

🚀

들어가기 전에 🔗

지난 1, 2편을 통해 우리는 Zustand의 기본 사용법부터 시작해 selector, shallow와 같은 개념까지 깊이 있게 탐험했습니다.
이번 3편에서는 가장 대표적인 웹 애플리케이션 예제인 'To-Do 리스트 앱'을 직접 만들어 보겠습니다.
이 과정을 통해 우리는 다음과 같은 실전 기술들을 자연스럽게 익히게 될 것입니다.

단순히 코드를 따라 치는 것을 넘어, 왜 이렇게 구조를 설계해야 하는지, 각 컴포넌트는 어떤 역할을 담당해야 하는지를 함께 고민하며 진행하겠습니다.

🚀

1단계: To-Do 앱 상태 스토어 설계하기 🔗

가장 먼저 할 일은 애플리케이션의 모든 상태와 행동을 담을 '청사진'을 그리는 것입니다.
바로 Zustand 스토어를 설계하는 것이죠.

우리의 To-Do 앱에는 어떤 상태가 필요할까요? 바로 '할 일(todo)들의 목록'입니다.
그리고 어떤 행동들이 필요할까요? 할 일 추가하기, 할 일 완료 처리하기, 할 일 삭제하기가 필요합니다.
이를 바탕으로 타입스크립트 타입과 스토어를 정의해 보겠습니다.
store/todoStore.ts
import { create } from 'zustand';
 
// 개별 '할 일' 항목에 대한 타입
export interface Todo {
  id: number;
  text: string;
  isDone: boolean;
}
 
// 스토어의 상태(State)에 대한 타입
interface TodoState {
  todos: Todo[];
}
 
// 스토어의 행동(Actions)에 대한 타입
interface TodoActions {
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
  removeTodo: (id: number) => void;
}
 
// create 함수로 스토어를 생성합니다.
export const useTodoStore = create<TodoState & TodoActions>((set) => ({
  // 초기 상태
  todos: [
    { id: 1, text: 'Zustand 공부하기', isDone: true },
    { id: 2, text: 'To-Do 앱 만들기', isDone: false },
  ],
 
  // 할 일을 추가하는 액션
  addTodo: (text) => 
    set((state) => ({
      todos: [
        ...state.todos,
        { id: Date.now(), text, isDone: false },
      ],
    })),
 
  // 할 일의 완료 상태를 변경하는 액션
  toggleTodo: (id) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, isDone: !todo.isDone } : todo
      ),
    })),
  
  // 할 일을 삭제하는 액션
  removeTodo: (id) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),
}));

🚀

2단계: 컴포넌트 구조 설계 및 구현 🔗

이제 스토어가 준비되었으니, 사용자가 상호작용할 화면, 즉 컴포넌트들을 만들어 보겠습니다.
좋은 컴포넌트 설계는 각자의 역할과 책임을 명확하게 나누는 것에서 시작합니다.
이미지 제안: App 컴포넌트 아래에 TodoInput과 TodoList가 있고, TodoList 아래에 여러 개의 TodoItem 컴포넌트가 있는 트리 다이어그램. 그리고 이 모든 컴포넌트들이 중앙의 'useTodoStore'를 가리키는 화살표를 그려, 컴포넌트 구조와 상태 저장소의 관계를 시각적으로 보여줍니다.
컴포넌트, 상태 구조
컴포넌트, 상태 구조

TodoInput.tsx: 폼 상태 다루기 🔗

새로운 할 일을 입력하는 input 태그의 값은, '추가' 버튼을 누르기 전까지는 다른 어떤 컴포넌트와도 공유할 필요가 없습니다.
이것은 전형적인
지역 상태(Local State)
입니다.
따라서 이 값은 useState를 사용해 관리하는 것이 가장 효율적입니다.
components/TodoInput.tsx
import React, { useState } from 'react';
import { useTodoStore } from '../store/todoStore';
 
export function TodoInput() {
  // 입력값은 컴포넌트 내부의 지역 상태로 관리
  const [text, setText] = useState('');
  
  // 스토어에서 'addTodo' 액션만 선택하여 가져옴
  const addTodo = useTodoStore((state) => state.addTodo);
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!text.trim()) return; // 비어있는 입력은 추가하지 않음
 
    addTodo(text); // 전역 스토어의 액션을 호출
    setText('');   // 입력 필드 초기화
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="새로운 할 일을 입력하세요..."
      />
      <button type="submit">추가</button>
    </form>
  );
}
addTodo 액션만 selector를 이용해 가져왔다는 점에 주목하세요. 이렇게 하면 todos 배열이 변경되어도 TodoInput 컴포넌트는 불필요하게 리렌더링되지 않습니다.

TodoList.tsxTodoItem.tsx: 목록 표시와 상호작용 🔗

TodoList는 스토어에서 todos 배열을 가져와 각 항목을 TodoItem 컴포넌트로 렌더링하는 역할만 합니다.
components/TodoList.tsx
import { useTodoStore, Todo } from '../store/todoStore';
import { TodoItem } from './TodoItem';
 
export function TodoList() {
  // 스토어에서 todos 배열만 선택하여 가져옴
  const todos = useTodoStore((state) => state.todos);
 
  return (
    <ul>
      {todos.map((todo: Todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}
실제 완료, 삭제와 같은 상호작용은 TodoItem 컴포넌트에서 일어납니다.
zustand 4버전부터는 useShallow를 사용하여 적용을 해야 합니다.
components/TodoItem.tsx
import { useTodoStore, type Todo } from "../store/todoStore";
import { useShallow } from "zustand/shallow";
 
interface TodoItemProps {
  todo: Todo;
}
 
export function TodoItem({ todo }: TodoItemProps) {
  // toggleTodo와 removeTodo 두 개의 액션을 가져옴
  const { toggleTodo, removeTodo } = useTodoStore(
    useShallow((state) => ({
      toggleTodo: state.toggleTodo,
      removeTodo: state.removeTodo,
    })),
  );
 
  return (
    <li>
      <span
        onClick={() => toggleTodo(todo.id)}
        style={{ textDecoration: todo.isDone ? "line-through" : "none" }}
      >
        {todo.text}
      </span>
      <button onClick={() => removeTodo(todo.id)}>삭제</button>
    </li>
  );
}
 
TodoItem에서는 toggleTodoremoveTodo 두 개의 액션 함수가 필요합니다.
2편에서 배운 것처럼, 여러 개의 상태나 액션을 객체로 묶어 가져올 때는 불필요한 리렌더링을 방지하기 위해 반드시 useShallow를 함께 사용해야 합니다.
TODO 리스트 완성
TODO 리스트 완성

🚀

페이지 간 공유 상태: Zustand는 어떻게 동작할까? 🔗

우리가 만든 To-Do 앱은 단일 페이지 애플리케이션입니다.
하지만 실제 프로젝트는 여러 페이지로 구성되는 경우가 많습니다.
예를 들어, '설정 페이지'에서 "완료된 항목 숨기기" 필터를 켰을 때, 메인 'To-Do 페이지'의 목록에 즉시 반영되어야 하는 상황을 생각해 봅시다.

이것이 바로
페이지 간 공유 상태
가 필요한 경우이며, Zustand는 이 문제를 아주 간단하게 해결합니다.
prop drilling 없이도, 각 페이지 컴포넌트는 그저 동일한 useTodoStore 훅을 호출하기만 하면 됩니다.
스토어에 필터 상태를 추가해 보겠습니다.
store/todoStore.ts
// ... 기존 타입 정의
 
interface TodoState {
  todos: Todo[];
  filter: 'all' | 'done' | 'undone'; // 필터 상태 추가
}
 
interface TodoActions {
  // ... 기존 액션들
  setFilter: (filter: 'all' | 'done' | 'undone') => void; // 필터 변경 액션 추가
}
 
// ... create 함수 내부
// ... 기존 상태 및 액션들
filter: 'all',
setFilter: (newFilter) => set({ filter: newFilter }),
이제 '설정 페이지'에서는 setFilter를 호출하고, 'To-Do 페이지'의 TodoList 컴포넌트에서는 todosfilter 상태를 함께 가져와 필터링된 목록을 보여주기만 하면 됩니다.
어떤 페이지에 있든 같은 스토어를 바라보기 때문에 상태는 항상 동기화됩니다.

🚀

결론 🔗

이번 시간에는 실전 예제인 To-Do 앱을 만들며, 이론으로만 배웠던 Zustand의 개념들을 실제로 어떻게 적용하는지 경험했습니다.
타입스크립트로 스토어의 타입을 명확히 정의하고, useState를 사용한 지역 상태와 Zustand를 사용한 전역 상태를 역할에 맞게 구분했습니다.
또한, selectoruseShallow를 적재적소에 사용하여 컴포넌트의 역할을 분리하고 성능까지 고려하는 설계 방식을 익혔습니다.

참고 🔗