앱잼 아티클 - Zustand에 대해

Yujin Jung·2025년 12월 29일
post-thumbnail

✔️ 글을 쓰게 된 이유

React로 프로젝트를 진행하다 보면 상태 관리가 점점 부담이 되는 순간이 온다. 처음에는 props로 충분하다고 생각했지만, 컴포넌트가 늘어나면서 props drilling이 깊어지고, Context API는 생각보다 구조가 복잡해졌다. Redux는 안정적이지만 작은 프로젝트에 적용하기에는 설정 비용이 꽤 크다는 느낌을 받았다.

그래서 이번 프로젝트에서는 가볍고 직관적인 전역 상태 관리 도구를 사용해보고 싶다는 생각을 하게 되었고 앱잼 팀원들과의 회의를 거쳐 Zustand를 최종적으로 선택하게 되었다. 이 글은 앱잼 프로젝트에 적용하기 전에 Zustand를 기술적으로 정리하고, 직접 실습해보기 위함이다.


✔️ Zustand란 무엇인가

Zustand는 Flux 패턴을 기반으로 한 경량 상태 관리 라이브러리로, React Hook 형태로 전역 상태를 관리할 수 있도록 도와준다. 가장 큰 특징은 store가 컴포넌트 트리 안에 존재하는 것이 아니라 외부에 독립적으로 존재한다는 점이다. 컴포넌트는 필요한 상태만 선택적으로 구독하고, 상태가 변경되었을 때 실제로 사용하는 값이 바뀐 컴포넌트만 리렌더링된다.

Context API처럼 Provider로 감쌀 필요도 없고, Redux처럼 액션과 리듀서를 분리할 필요도 없다. 상태를 정의하고 바로 사용하는 흐름이 굉장히 단순하다.

개념 요약

  • store는 외부에 존재
  • 컴포넌트는 store를 구독(subscribe) 한다
  • selector를 통해 부분 구독 가능
  • 상태 변경 시 구독 중인 컴포넌트만 리렌더링
[ Store ] → (subscribe) → [ Component ]

👉 React Context와 달리 상태 변경 ≠ 전체 리렌더링


✔️ Store 생성 실습

구조를 이해하기 위해 가장 기본적인 counter store를 예제로 실습을 진행해보겠다.

Zustand에서 store는 create 함수로 생성한다. 이 함수 안에서는 상태(state)상태를 변경하는 함수(action)를 함께 정의한다. Redux처럼 액션과 리듀서를 분리하지 않고, 한 곳에서 상태 흐름을 모두 확인할 수 있다는 점이 특징이다.

먼저 counter 상태를 관리하는 store를 하나 만든다.

// stores/useCounterStore.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  increase: () => void;
  decrease: () => void;
}

const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increase: () =>
    set((state) => ({
      count: state.count + 1,
    })),
  decrease: () =>
    set((state) => ({
      count: state.count - 1,
    })),
}));

export default useCounterStore;

코드를 위에서부터 하나씩 보면 구조가 훨씬 명확해진다.
CounterState 인터페이스에서는 이 store가 어떤 상태와 함수를 가지는지 정의한다. 여기서는 숫자 형태의 count 상태와, 값을 증가·감소시키는 두 개의 함수가 전부다.

create 함수의 인자로 전달되는 콜백 함수 안에서는 set을 사용할 수 있는데, 이 set 함수가 바로 상태를 변경하는 역할을 한다. set 안에서는 이전 상태를 받아 새로운 상태를 반환하는 방식으로 값을 업데이트한다. 이 방식은 React의 setState와 굉장히 유사해서 이해하기 어렵지 않다.

이렇게 만든 store의 가장 큰 특징은 컴포넌트 트리와 완전히 분리되어 있다는 점이다. Context API처럼 Provider로 감싸줄 필요가 없고, 단순히 훅을 import해서 호출하기만 하면 된다.

이제 이 store를 실제 컴포넌트에서 사용해보자.

import useCounterStore from '@/stores/useCounterStore';

export default function Counter() {
  const count = useCounterStore((state) => state.count);
  const increase = useCounterStore((state) => state.increase);
  const decrease = useCounterStore((state) => state.decrease);

  return (
    <div>
      <p>{count}</p>
      <button onClick={increase}>+</button>
      <button onClick={decrease}>-</button>
    </div>
  );
}

여기서 중요한 점은 store 전체를 한 번에 가져오지 않고, selector를 통해 필요한 값만 가져오고 있다는 것이다. count 값은 숫자가 바뀔 때만 리렌더링을 유발하고, 증가·감소 함수는 상태 변화와 무관하게 안정적으로 사용할 수 있다.

이 구조 덕분에 Zustand는 별도의 설정 없이도 자연스럽게 렌더링 최적화를 할 수 있다.

✔️ Zustand 내부 동작 구조

Zustand의 storecreate 함수로 생성되며, 이 함수 안에서 상태와 상태를 변경하는 함수들을 함께 정의한다. 내부적으로는 setget을 통해 상태를 관리하며, 이 store는 하나의 싱글톤처럼 동작한다.

컴포넌트는 store 전체를 구독하는 것이 아니라 selector를 통해 필요한 상태만 가져오게 된다. 이 덕분에 상태가 변경되더라도 관련 없는 컴포넌트는 영향을 받지 않는다. 이런 구조 덕분에 Zustand는 별도의 복잡한 최적화 없이도 자연스럽게 렌더링 성능을 관리할 수 있다.

create((set, get) => ({
  state,
  actions,
}))
  • set : 상태 변경 함수
  • get : 현재 상태 접근
  • store는 singleton
  • React 외부에서도 접근 가능

상태 흐름

  1. 컴포넌트가 selector로 상태를 구독
  2. set() 호출 → 상태 변경
  3. 변경된 상태를 사용하는 컴포넌트만 리렌더링

✔️ Selector 기반 구독의 중요성

Zustand를 사용할 때 가장 중요하다고 느낀 부분은 selector를 통한 상태 구독이다. store 전체를 한 번에 가져오면 상태가 조금만 바뀌어도 컴포넌트가 다시 렌더링된다. 반대로 selector를 사용하면 실제로 필요한 값이 변경될 때만 렌더링이 발생한다.

프로젝트 규모가 커질수록 이 차이는 꽤 크게 느껴질 수 있다. Zustand의 성능을 제대로 활용하려면 반드시 selector 기반 구독을 사용하는 습관을 들이는 게 좋다고 느꼈다.

❌ 전체 상태 구독 (비추천)

const store = useCounterStore();
  • store의 어떤 값이 바뀌어도 리렌더링

✅ 부분 구독 (권장)

const count = useCounterStore((state) => state.count);
  • count가 바뀔 때만 리렌더링

✔️ Zustand에서 비동기 처리하기 — store로 API 로직 옮겨보기

Zustand에서 비동기 처리가 자연스럽다고 느껴지는 이유는 Redux처럼 thunk나 saga 같은 별도의 middleware를 붙이지 않아도, store 안에서 그냥 async 함수를 정의하고 그 안에서 set으로 상태를 업데이트하면 끝난다.

이번 실습에서는 예시로 유저 목록을 가져오는 API 호출을 생각하고, 그 로직을 컴포넌트가 아니라 store 안으로 옮겨보겠다. 핵심은 loading, error, data 같은 상태를 store에서 같이 들고 가면서, 컴포넌트는 “불러오기 버튼”과 “화면 렌더링”만 담당하게 만드는 것이다.

먼저 store를 만든다.

// stores/useUserStore.ts
import { create } from 'zustand';

type User = {
  id: number;
  name: string;
  email: string;
};

interface UserState {
  users: User[];
  isLoading: boolean;
  error: string | null;

  fetchUsers: () => Promise<void>;
  clearError: () => void;
}

const useUserStore = create<UserState>((set, get) => ({
  users: [],
  isLoading: false,
  error: null,

  clearError: () => set({ error: null }),

  fetchUsers: async () => {
    // 1) 요청 시작: 로딩 true, 에러 초기화
    set({ isLoading: true, error: null });

    try {
      // 2) API 호출 (예시)
      const res = await fetch('https://jsonplaceholder.typicode.com/users');

      // 3) 실패 케이스 처리
      if (!res.ok) {
        throw new Error(`요청 실패: ${res.status}`);
      }

      const data = await res.json();

      // 4) 성공 시: 데이터 저장 + 로딩 false
      set({
        users: data.map((u: any) => ({
          id: u.id,
          name: u.name,
          email: u.email,
        })),
        isLoading: false,
      });
    } catch (e: any) {
      // 5) 에러 시: 에러 메시지 저장 + 로딩 false
      set({
        error: e?.message ?? '알 수 없는 에러가 발생했어요.',
        isLoading: false,
      });
    }
  },
}));

export default useUserStore;

여기서 포인트는, fetchUsers라는 비동기 함수가 store 내부에 있다는 점이다. 이 함수는 요청 시작 시점에 로딩 상태를 켜고, 응답을 받으면 users를 업데이트하고, 실패하면 error를 업데이트한다. 즉 비동기 로직에 필요한 상태 흐름을 한 곳에서 통제하게 된다.

이제 컴포넌트에서는 훨씬 단순하게 사용할 수 있다. 컴포넌트는 “언제 fetch를 호출할지”와 “화면에 어떻게 보여줄지”만 신경 쓰면 된다.

import { useEffect } from 'react';
import useUserStore from '@/stores/useUserStore';

export default function UserList() {
  const users = useUserStore((state) => state.users);
  const isLoading = useUserStore((state) => state.isLoading);
  const error = useUserStore((state) => state.error);
  const fetchUsers = useUserStore((state) => state.fetchUsers);
  const clearError = useUserStore((state) => state.clearError);

  // 페이지 들어오자마자 불러오고 싶으면 useEffect로 호출
  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (isLoading) return <p>불러오는 중...</p>;

  if (error) {
    return (
      <div>
        <p>에러: {error}</p>
        <button
          onClick={() => {
            clearError();
            fetchUsers();
          }}
        >
          다시 시도
        </button>
      </div>
    );
  }

  return (
    <div>
      <button onClick={fetchUsers}>유저 목록 새로 불러오기</button>

      <ul>
        {users.map((u) => (
          <li key={u.id}>
            {u.name} ({u.email})
          </li>
        ))}
      </ul>
    </div>
  );
}

이렇게 되면 컴포넌트 안에서 try/catch, setLoading, setError, setUsers 같은 코드가 사라지고, UI는 UI답게 정리된다. 그리고 API 관련 상태 변화는 store에서 일관된 방식으로만 관리되기 때문에, 기능이 커져도 구조가 무너지지 않는다.

✔️ persist 미들웨어 활용

로그인 토큰이나 설정 값처럼 새로고침 이후에도 유지되어야 하는 상태는 persist 미들웨어를 사용하면 된다. localStorage나 sessionStorage를 선택할 수 있고, 설정도 단순하다.

실제로 로그인 상태 등을 관리할 때 굉장히 유용할 것 같았고, 프로젝트에서 바로 활용할 수 있겠다는 생각이 들었다.

import { persist } from 'zustand/middleware';

const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      accessToken: null,
      setToken: (token) => set({ accessToken: token }),
      logout: () => set({ accessToken: null }),
    }),
    {
      name: 'auth-storage',
      storage: sessionStorage,
    }
  )
);
  • 새로고침 후에도 상태 유지
  • 로그인/예약/설정 상태에 적합

✔️ Zustand를 쓰는 기준

Zustand는 전역 UI 상태나 사용자 흐름과 관련된 상태를 관리하는 데 적합하다. 모달 열림 여부, 로그인 정보, 예약 상태처럼 여러 컴포넌트에서 공통으로 사용되는 값들이 여기에 해당한다.

반면 서버에서 받아오는 데이터의 캐싱이나 refetch가 중요한 경우에는 React Query 같은 라이브러리를 함께 사용하는 것이 더 적절하다고 느꼈다.

Zustand에 적합한 상태

  • 로그인 정보
  • 예약 정보
  • 모달, 토스트, UI 상태
  • 전역 필터 조건

Zustand에 부적합한 상태

  • 서버 캐시
  • pagination / refetch 중심 데이터

👉 Zustand + React Query 조합 추천

✔️ Store 분리 전략

Zustand를 사용할 때 store를 어떻게 나누느냐도 중요하다. 하나의 store에 모든 상태를 몰아넣기보다는, 인증, 사용자 정보, UI 상태처럼 도메인 단위로 분리하는 것이 관리하기 훨씬 수월하다.

이렇게 분리하면 상태의 책임이 명확해지고, 유지보수도 쉬워진다.

stores/
 ├─ useAuthStore.ts
 ├─ useModalStore.ts
 ├─ useReservationStore.ts
 └─ useUserStore.ts
  • 도메인 단위 분리
  • store 하나에 모든 상태 몰아넣지 않기

✔️ 다른 상태 관리 방식과의 비교

Context API는 간단한 상태 공유에는 적합하지만, 상태가 자주 바뀌는 경우 렌더링 최적화가 어렵다. Redux는 강력하지만 설정과 구조가 부담스럽다. Zustand는 이 둘 사이에서 비교적 가볍게 사용할 수 있는 대안이라는 느낌을 받았다.

프로젝트 성격과 규모에 따라 선택은 달라질 수 있지만, 이번 앱잼 React 프로젝트에서는 Zustand가 가장 적절한 선택이라고 판단했다.

항목ContextReduxZustand
설정쉬움매우 복잡매우 쉬움
성능 최적화어려움좋음매우 좋음
코드량적음많음적음
비동기직접 처리middleware 필요기본 지원

✔️ 마무리

Zustand는 전역 상태 관리를 복잡하게 만들지 않으면서도, 필요한 성능과 구조를 충분히 제공해주는 라이브러리라고 느꼈다. 설정 부담이 적고, 코드 흐름이 직관적이라 React 프로젝트에 빠르게 적용할 수 있다는 점이 가장 큰 장점이다.

이번 프로젝트에서는 UI 상태와 사용자 흐름 관리에 Zustand를 적극적으로 활용해볼 계획이다. 이후 실제로 사용하면서 느낀 장단점이나 패턴들도 따로 정리해보고 싶다.

profile
매일매일 조금씩 성장하려 노력하는 프론트엔드 개발자입니다!

0개의 댓글