Zustand로 전역 상태 관리하기

Frontend

React에서 전역 상태를 관리하는 방법은 여러 가지가 있다.

Context API, Redux, Recoil 등 다양한 선택지가 있지만, 최근에는 Zustand가 주목받고 있다.

 

Zustand는 용량이 가볍고 사용법이 직관적이며, 보일러플레이트 코드가 적다는 장점이 있다.

이 글에서는 Zustand의 기본 사용법부터 실무에서 유용한 최적화 기법과 미들웨어 활용법까지 정리해봤다.

 

Zustand를 사용하는 이유

Context API의 한계

Context API는 Props drilling 문제를 해결할 수 있지만, 범용적인 전역 상태 관리보다는 국소적인 데이터 공유에 더 적합하다.

Context 값이 변경되면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링되는 문제도 있다.

Zustand의 장점

  • 매우 가벼운 용량 (약 1KB)
  • 직관적이고 간결한 API
  • React 외부에서도 상태 접근 가능
  • TypeScript 지원
  • 다양한 미들웨어 제공

 

기본 사용법

Zustand는 create 함수로 store를 생성하는 것부터 시작한다. store는 상태(state)와 상태를 변경하는 액션(actions)을 포함하는 객체이다.

스토어 생성

store 파일은 보통 src/store 폴더에 만들어 관리한다.

import { create } from "zustand";

create(() => {
  return {
    // 스토어 지정
  };
});

create함수는 zustand의 store를 생성하며,

store는 전역 상태인 state와 해당 상태를 업데이트 하는 action 함수들이 포함된 객체이다.

create((set, get) => ({
  count: 0,
  increse: () => {
    const count = get().count;
    set({ count: count + 1 });
  },
  decrese: () => {},
}));

 

Zustand의 set 메서드는 React의 useState와는 다르게 동작한다.

React의 useState에서는 setState({ ...state, count: count + 1 })처럼 스프레드 연산자로 기존 상태를 복사해야 하지만, Zustand는 그럴 필요가 없다. 

set메서드는 명시한 프로퍼티만 업데이트하고 나머지는 자동으로 유지하기 때문에 변경할 프로퍼티만 작성해주면 된다.

 

함수형 업데이트를 권장한다.

set 메서드는 객체 대신 콜백 함수를 받을 수도 있다. 이 방식을 사용하면 최신 상태를 기반으로 업데이트할 수 있어 더 안전하다.

increase: () => {
  set((state) => ({
    count: state.count + 1,
  }));
}

콜백 함수가 반환하는 객체로 상태가 업데이트 되며, 실무에서는 주로 이 함수형 업데이트 방식을 사용한다.

 

TypeScript와 함께 사용하기

import { create } from "zustand";

type Store = {
  count: number;
  increase: () => void;
  decrease: () => void;
};

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

 

컴포넌트에서 사용하기

create 함수는 React Hook을 반환하므로, 컴포넌트에서 바로 사용할 수 있다.

export const useCountStore = create<Store>((set) => ({
  count: 0,
  increase: () => {
    set((store) => ({
      count: store.count + 1,
    }));
  },
  decrease: () => {
    set((store) => ({
      count: store.count - 1,
    }));
  },
}));
import Controller from "@/components/counter/controller";
import Viewer from "@/components/counter/viewer";

export default function CounterPage() {
  return (
    <div>
      <Viewer />
      <Controller />
    </div>
  );
}
import { useCountStore } from "@/store/count";

export default function Viewer() {
  const { count } = useCountStore();

  return <div>{count}</div>;
}
import { useCountStore } from "@/store/count";

export default function Controller() {
  const { decrease, increase } = useCountStore();

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

그러나 Zustand는 컴포넌트에서 불러온 store 값들 중 하나라도 변경되면 해당 컴포넌트를 자동으로 리렌더링 시키기 때문에 다음과 같이 불필요한 리렌더링이 발생할 수 있다.

위와 같이 버튼 컴포넌트에서 count 값을 사용하지 않는데도 count가 변경될 때마다 리렌더링되는 것이다.

 

이 문제는 selector 함수로 해결할 수 있다.

 

성능 최적화

Selector 함수로 필요한 값만 구독하기

selector 함수를 사용하면 필요한 값만 선택적으로 구독할 수 있다. 

const increase = useCountStore((store) => store.increase);
const decrease = useCountStore((store) => store.decrease);

 

이렇게 하면 버튼 컴포넌트는 액션 함수만 구독하므로, count 값이 변경되어도 리렌더링되지 않는다.

 

State와 Actions 분리하기

이때 state와 actions를 분리하면 사용할 때에 한번에 불러오는 것이 가능하다.

export const useCountStore = create<Store>((set) => ({
  count: 0,
  actions: {
    increase: () => {
      set((state) => ({
        count: state.count + 1,
      }));
    },
    decrease: () => {
      set((state) => ({
        count: state.count - 1,
      }));
    },
  },
}));
const { increase, decrease } = useCountStore((store) => store.actions);

 

커스텀 훅 패턴

규모 있는 프로젝트에서는 유지보수를 고려해 다음과 같이 커스텀 훅을 만들어 사용한다.

const useCountStore = create<Store>((set) => ({
  // ...
}));

export const useCount = () => {
  const count = useCountStore((store) => store.count);
  return count;
};

export const useIncreaseCount = () => {
  const increase = useCountStore((store) => store.actions.increase);
  return increase;
};

export const useDecreaseCount = () => {
  const decrease = useCountStore((store) => store.actions.decrease);
  return decrease;
};
import { useCount } from "@/store/count";

export default function Viewer() {
  const count = useCount();

  return <div>{count}</div>;
}
import { useDecreaseCount, useIncreaseCount } from "@/store/count";

export default function Controller() {
  const increase = useIncreaseCount();
  const decrease = useDecreaseCount();

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

 

미들웨어

Zustand는 다양한 미들웨어를 제공한다.

combine - 타입 자동 추론

state와 actions를 분리해서 작성한 후 이를 결합할 수 있고, TypeScript를 사용할 때 타입을 자동으로 추론해주는 장점이 있다.

import { combine } from "zustand/middleware";

create(combine({}, () => ({}));
create(
  combine({ count: 0 }, (set, get) => ({
    actions: {
      increase: () => {
        set((state) => ({
          count: state.count + 1,
        }));
      },
      decrease: () => {
        set((state) => ({
          count: state.count - 1,
        }));
      },
    },
  })),
);

다만 자동 추론되는 타입은 state의 값만 포함하는 객체 타입이기 때문에 매개변수명을 store보다는 state라고 사용하는 편이다.

 

immer - 불변성 관리 간소화

immer 미들웨어를 사용하면 상태를 직접 변경하는 것처럼 코드를 작성할 수 있다.

npm i immer
import { immer } from "zustand/middleware/immer";

const useCountStore = create(
  immer(
    combine({ count: 0 }, (set, get) => ({
      // ...
    })),
  ),
);

원래는 불변성을 지키기 위해 새로운 객체를 만들어야 하지만, immer를 사용하면 마치 값을 직접 수정하는 것처럼 작성해도 내부적으로 불변성이 유지된다.

// 기존 방식
increase: () => {
  set((state) => ({ count: state.count + 1 }))
}

// immer 적용 후
increase: () => {
  set((state) => { state.count += 1 })
}

 

불변성 관리란?
state.count += 1 처럼 값을 직접 변경하는 것이 아니라 변경될 값을 포함한 새로운 객체를 만들어 전달해 변경하는 방식
예시) set((state) => ({ count: state.count + 1 }))

 

subscribeWithSelector

store 내의 특정 값 변화 시 이벤트 핸들러를 호출한다.

import { subscribeWithSelector } from "zustand/middleware";

const useCountStore = create(
  subscribeWithSelector(
    immer(
      // ...
    )
  ),
);

useCountStore.subscribe(
  (store) => store.count,
  (count, prevCount) => {
    // Listener
    console.log(count, prevCount);
  },
);
  • 첫 번째 인자: 구독할 값을 선택하는 함수
  • 두 번째 인자: 실행할 콜백 함수 (리스너)

 

리스너 함수 내부에서는 현재 스토어를 불러오거나 현재 스토어의 특정 값을 업데이트하는 것도 가능하다.

useCountStore.subscribe(
  (store) => store.count,
  (count, prevCount) => {
    const store = useCountStore.getState();
    // useCountStore.setState((store) => ({ count: 10 }));
  },
);

 

persist - 상태 영구 저장

store를 브라우저의 로컬 스토리지나 세션 스토리지에 저장할 수 있다. 페이지를 새로고침해도 상태가 유지된다.

import { persist } from "zustand/middleware";

const useCountStore = create(
  persist(
    subscribeWithSelector(
      // ...
    ),
    { 
      name: "countStore",
    },
  ),
);
  • name: 스토어가 브라우저의 스토리지에 저장될 때 어떤 이름으로 저장될 것인지 지정
  • partialize: 현재 스토어 값 중에 어떤 값을 보관할 것인지 직접 명시

이때 액션 함수는 자동으로 저장되지 않는다.

JavaScript 함수는 실행 컨텍스트, 스코프 체인, 클로저 등 복잡한 정보를 포함하고 있어 JSON으로 직렬화할 수 없다. 따라서 위와 같이 코드를 작성하면 액션 함수는 그냥 빈 객체로 생략되어 저장된다.

앱을 새로고침 했을 때 Zustand는 persist를 이용해 스토리지에 저장한 값을 그대로 불러와서 적용 시킨다. 이때 저장된 액션함수가 빈 객체이기 때문에 이전에 잘 동작하던 함수는 사라지고 만다.

 

따라서 다음과 같이 partialize 옵션으로 실제로 저장할 상태를 명시적으로 지정해 주는 것이 좋다.

const useCountStore = create(
  persist(
    subscribeWithSelector(
	    // ...
    ),
    { name: "countStore" },
    partialize: (store) => ({
      count: store.count,  // 스토리지 보관 대상을 count로 명시
    }),
  ),
);

 

세션 스토리지 사용

import { createJSONStorage } from "zustand/middleware";

const useCountStore = create(
  persist(
    subscribeWithSelector(
      // ...
    ),
    {
      name: "countStore",
      partialize: (store) => ({
        count: store.count,
      }),
      storage: createJSONStorage(() => sessionStorage),
    },
  ),
);

 

devtools

store의 값을 개발자 도구에서 확인하고 디버깅할 수 있다.

import { devtools } from "zustand/middleware";

const useCountStore = create(
  devtools(
    persist(
      // ...
    ),
    {
      name: "countStore",
    }
  ),
);

 

다만 디버깅을 위해서는 Redux DevTools라는 크롬 확장 프로그램을 설치해야 한다.

 

미들웨어 적용 순서

마지막으로, 미들웨어를 적용할 때에는 순서가 중요하다.

아래의 순서대로 적용하지 않으면 의도치 않은 동작이나 버그가 발생할 수 있으니 주의하자.

combine → immer → subscribeWithSelector → persist → devtools

 

 

'Frontend' 카테고리의 다른 글

aria-label과 텍스트 콘텐츠  (0) 2025.11.13
웹 애플리케이션의 렌더링 방식과 Next.js  (0) 2025.11.08
타입스크립트 유틸리티 타입  (0) 2025.11.05
타입스크립트 타입 시스템: 계층과 호환성  (0) 2025.10.31
타입스크립트의 동작 원리  (0) 2025.10.27
'Frontend' 카테고리의 다른 글
  • aria-label과 텍스트 콘텐츠
  • 웹 애플리케이션의 렌더링 방식과 Next.js
  • 타입스크립트 유틸리티 타입
  • 타입스크립트 타입 시스템: 계층과 호환성
고견
고견
개발 자국 남기기
  • 고견
    개발자국
    고견
  • 전체
    오늘
    어제
    • 분류 전체보기 (157) N
      • Frontend (29)
        • Next.js (16)
        • JavaScript (7)
      • CS (19) N
        • 자료구조 (9)
        • 알고리즘 (5)
        • 운영체제 (4) N
        • 네트워크 (1) N
      • TIL (93)
      • Dev Log (16)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    C
    react
    함수 타입
    useState
    typescript
    memory
    자료구조
    트러블 슈팅
    generic
    cs50
    바닐라 자바스크립트
    클래스
    Next.js
    Pages Router
    algorithm
    제네릭
    앱 라우터
    emotion diary
    인터페이스
    CS
    Trouble Shooting
    배열
    문자열
    Spa
    App Router
    알고리즘
    타입 좁히기
    페이지 라우터
    javascript
    ai 감성 일기장
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
고견
Zustand로 전역 상태 관리하기
상단으로

티스토리툴바