Waylog Blog
← 목록으로 돌아가기

Zustand: Redux의 독재를 끝낼 가벼운 영웅

React

Zustand State Management

React 생태계에서 "상태 관리(State Management)"는 언제나 뜨거운 감자였습니다. 오랫동안 Redux가 사실상의 표준(De facto standard)으로 군림했지만, 과도한 보일러플레이트 코드와 복잡한 설정은 개발자들을 지치게 만들었습니다. Context API, Recoil, Jotai, MobX 등 수많은 도전자들이 나타났지만, 최근 가장 가파른 성장세를 보이며 개발자들의 마음을 사로잡은 라이브러리가 있습니다. 바로 Zustand입니다. 독일어로 "상태"를 뜻하는 이 작고 귀여운 곰 인형(Zustand의 로고)이 왜 강력한지, 약 3,000자의 깊이 있는 분석으로 파헤쳐 봅니다.

1. 왜 Zustand인가? (심플함의 미학)

Zustand의 가장 큰 미덕은 **단순함(Simplicity)**입니다. Flux 패턴을 따르면서도 Redux처럼 Action, Reducer, Dispatcher, Selector를 분리해서 작성할 필요가 없습니다.

1.1 보일러플레이트 제로

Redux Toolkit(RTK)이 많이 간소화되었다고는 하지만, 여전히 스토어를 설정하고 슬라이스를 만드는 과정이 필요합니다. 반면 Zustand는 create 함수 하나면 끝입니다.

import { create } from 'zustand';

interface BearState {
  bears: number;
  increasePopulation: () => void;
  removeAllBears: () => void;
}

const useStore = create<BearState>((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}));

이 코드가 전부입니다. Provider로 앱을 감쌀 필요도 없습니다. 훅(Hook)을 사용하는 것처럼 컴포넌트 어디서든 useStore를 호출하여 상태를 읽고 업데이트할 수 있습니다.

2. 강력한 성능과 렌더링 최적화

Zustand는 단순히 사용하기 편한 것을 넘어, 성능 면에서도 탁월합니다.

2.1 Selector를 통한 리렌더링 방지

React Context API의 고질적인 문제는 Context 값이 바뀌면 해당 Context를 구독하는 모든 하위 컴포넌트가 불필요하게 리렌더링된다는 것입니다. 이를 막기 위해 React.memouseMemo를 난사해야 했습니다.
하지만 Zustand는 기본적으로 Selector 패턴을 지원합니다. 내가 필요한 상태 조각만 구독하면, 다른 상태가 변하더라도 내 컴포넌트는 리렌더링되지 않습니다.

const bears = useStore((state) => state.bears);

위 컴포넌트는 increasePopulation 함수가 바뀌어도 리렌더링되지 않습니다. 오직 bears 숫자 값이 변할 때만 반응합니다. 이는 대규모 애플리케이션에서 엄청난 성능 이득을 가져다줍니다.

2.2 Transient Updates (일시적 업데이트)

애니메이션이나 드래그 앤 드롭처럼 상태가 매 프레임마다 빈번하게 바뀌는 경우, React의 리렌더링 사이클을 거치는 것은 성능 저하의 원인이 됩니다. Zustand는 React 컴포넌트를 리렌더링하지 않고도 상태 구독자에게 직접 변경 사항을 알릴 수 있는 방법을 제공합니다. 이를 통해 60fps의 부드러운 인터랙션을 구현할 수 있습니다.

3. 미들웨어와 확장성

Zustand는 작지만 확장성이 뛰어납니다. 로깅, 불변성 유지(Immer), 영속성(Persist) 등 다양한 미들웨어를 손쉽게 붙일 수 있습니다.

3.1 Redux DevTools 연동

Redux를 그리워할 필요가 없습니다. Zustand는 Redux DevTools와 완벽하게 호환됩니다. 미들웨어 한 줄만 추가하면 시간 여행 디버깅이 가능해집니다.

3.2 Persist Middleware

새로고침을 해도 데이터가 날아가지 않게 하려면 로컬 스토리지에 저장해야 합니다. Zustand의 persist 미들웨어를 사용하면, 어떤 상태를 어디에 저장할지 설정 하나로 자동 관리해 줍니다. SSR 환경에서의 Hydration 이슈까지 우아하게 처리해 주는 것은 덤입니다.

4. 리액트 바깥에서도 사용 가능 (Vanilla JS)

Zustand 스토어는 React 컴포넌트 트리에 종속되지 않습니다. 즉, React 컴포넌트가 아닌 일반 JavaScript 함수나 비동기 로직 내부에서도 스토어에 접근하고 상태를 변경할 수 있습니다. 이는 복잡한 비즈니스 로직을 UI와 완전히 분리하여 순수 함수 형태로 테스트하고 관리할 수 있게 해줍니다.

5. 결론

Zustand는 "상태 관리"라는 본질에 가장 가까운 라이브러리입니다. 배우기 쉽고, 가볍고, 빠르며, React의 철학과 잘 어우러집니다. 물론 프로젝트의 규모가 아주 크고 엄격한 아키텍처가 필요하다면 Redux가 더 나은 선택일 수도 있습니다. 하지만 대부분의 모던 웹 애플리케이션에서 Zustand는 생산성과 성능 두 마리 토끼를 잡을 수 있는 최적의 선택지입니다.

6. Zustand와 서버 컴포넌트(RSC) 시대의 상태 관리

6.1 RSC 환경에서 Zustand의 역할 변화

React Server Components가 보편화되면서 상태 관리의 패러다임이 바뀌었습니다. 서버에서 가져오는 데이터는 더 이상 클라이언트 스토어에 저장할 필요가 없습니다. 이 새로운 환경에서 Zustand는 순수한 클라이언트 상태에 집중합니다. 모달 열림/닫힘, 다크모드 토글, 사이드바 접기/펼치기 등 사용자 인터랙션에 밀접한 UI 상태만을 가볍게 관리합니다.

6.2 Next.js App Router에서 Zustand 올바르게 사용하기

서버 컴포넌트에서는 Zustand 스토어에 접근할 수 없으므로, 스토어를 사용하는 컴포넌트는 반드시 use client 지시어를 선언해야 합니다. 또한 SSR 시의 하이드레이션 불일치를 방지하기 위해 persist 미들웨어 사용 시 skipHydration 옵션을 활용하는 것이 좋습니다.

7. Zustand vs Jotai vs Valtio: 올바른 선택 기준

같은 pmndrs 생태계에서 나온 세 라이브러리는 각각 다른 상태 관리 패러다임을 제공합니다.

  • Zustand: Flux 패턴 기반의 중앙 집중식 스토어. 여러 상태가 연관되어 있고 하나의 스토어에서 관리할 때 적합합니다.
  • Jotai: Atomic 패턴. Recoil과 유사하게 작은 단위의 원자(Atom)를 조합하여 관리합니다. 독립적인 상태 조각이 많을 때 좋습니다.
  • Valtio: Proxy 기반의 가변 상태. MobX와 비슷한 접근으로 직관적이지만 디버깅이 까다로울 수 있습니다.

대부분의 프로젝트에서는 Zustand가 가장 범용적이고 안전한 선택입니다.

8. Zustand 실전 패턴: Slice 패턴으로 스토어 분리하기

프로젝트가 커지면 하나의 거대한 스토어로 모든 상태를 관리하는 것이 어려워집니다. Zustand에서는 Slice 패턴을 사용하여 스토어를 기능 단위로 분리하고, 이를 하나의 루트 스토어에서 합칠 수 있습니다.

각 슬라이스는 독립적인 상태와 액션을 정의하고, createStore 호출 시 스프레드 연산자로 모든 슬라이스를 병합합니다. 이렇게 하면 코드의 응집도는 높아지면서도 결합도는 낮아져, 대규모 애플리케이션에서도 상태 관리가 체계적으로 유지됩니다.

X. 깊게 파헤치는 상태 관리의 동시성 제어와 Selector 최적화 (Deep Dive)

Redux가 강제하던 보일러플레이트의 억압에서 풀려나 Zustand를 들이는 순간, 우리는 매우 근본적인 질문에 직면합니다. 자유가 주어진 만큼, 스토어 오염과 불필요한 렌더링 폭포(Render Waterfall)를 어떻게 제어할 것인가?

1. 구독 튜닝과 Selector 파이프라인

Zustand의 진정한 힘은 스토어 코어의 자율성이 아닌, '어떻게 스토어 변화를 컴포넌트가 구독할 것인가(Subscription Model)'에 있습니다.
const state = useStore()를 남발하는 순간 애플리케이션은 최악의 성능 패널티를 받게 됩니다. 스토어 변수 중 어떠한 값만 하나 바뀌어도 해당 컴포넌트는 무의미하게 재평가(Re-render)되기 때문입니다.
이 성능 저하를 방어하려면 엄격하게 쪼개진 Selector를 파생(Derive)시켜야 합니다. const userName = useStore(state => state.user.name)와 같이 스토어에서 아주 좁은 단면만을 도출시키는 이 패턴은 메모이제이션 파이프라인의 핵심입니다. Zustand 내부의 얕은 비교 알고리즘(Shallow Compare)이 동작할 때 객체 전체가 아닌 원시값(Primitive)의 불변성만 체크하므로 V-DOM이 렌더링에 투입하는 비용이 문자 그대로 제로에 수렴하게 됩니다.

2. 외부 환경 연동(Transient Updates)과 마이크로 최적화

때론 리액트의 상태 렌더링 루프를 거치지 않고 DOM에 곧바로 영향을 줘야하는 극단적 마이크로 최적화가 요구됩니다. 가령 60FPS로 캔버스(Canvas)의 스크롤 좌표를 이동시켜야 한다면 Zustand의 상태 구독은 이 렌더링 압박을 견디지 못합니다.
Zustand는 이 한계를 깨부수기 위해 Transient Update(구독 우회 업데이트)라는 비기를 제공합니다. useStore.subscribe를 활용해 스토어 변경을 리액트 컴포넌트 밖에서 순수 리스닝(Listening)하여 DOM Node Ref의 속성을 즉각 덮어씌우는 패턴입니다.
이토록 가벼운 훅(Hook) 하나가, 수십 MB급 전역 상태의 비즈니스 안정성을 통제하면서 동시에 3D 그래픽 레벨의 상태까지 동시 제어할 수 있다는 점이 Zustand가 가져다준 위대한 아키텍처 혁신입니다.

X. 깊게 파헤치는 상태 관리의 동시성 제어와 Selector 최적화 (Deep Dive)

Redux가 강제하던 보일러플레이트의 억압에서 풀려나 Zustand를 들이는 순간, 우리는 매우 근본적인 질문에 직면합니다. 자유가 주어진 만큼, 스토어 오염과 불필요한 렌더링 폭포(Render Waterfall)를 어떻게 제어할 것인가?

1. 구독 튜닝과 Selector 파이프라인

Zustand의 진정한 힘은 스토어 코어의 자율성이 아닌, '어떻게 스토어 변화를 컴포넌트가 구독할 것인가(Subscription Model)'에 있습니다.
const state = useStore()를 남발하는 순간 애플리케이션은 최악의 성능 패널티를 받게 됩니다. 스토어 변수 중 어떠한 값만 하나 바뀌어도 해당 컴포넌트는 무의미하게 재평가(Re-render)되기 때문입니다.
이 성능 저하를 방어하려면 엄격하게 쪼개진 Selector를 파생(Derive)시켜야 합니다. const userName = useStore(state => state.user.name)와 같이 스토어에서 아주 좁은 단면만을 도출시키는 이 패턴은 메모이제이션 파이프라인의 핵심입니다. Zustand 내부의 얕은 비교 알고리즘(Shallow Compare)이 동작할 때 객체 전체가 아닌 원시값(Primitive)의 불변성만 체크하므로 V-DOM이 렌더링에 투입하는 비용이 문자 그대로 제로에 수렴하게 됩니다.

2. 외부 환경 연동(Transient Updates)과 마이크로 최적화

때론 리액트의 상태 렌더링 루프를 거치지 않고 DOM에 곧바로 영향을 줘야하는 극단적 마이크로 최적화가 요구됩니다. 가령 60FPS로 캔버스(Canvas)의 스크롤 좌표를 이동시켜야 한다면 Zustand의 상태 구독은 이 렌더링 압박을 견디지 못합니다.
Zustand는 이 한계를 깨부수기 위해 Transient Update(구독 우회 업데이트)라는 비기를 제공합니다. useStore.subscribe를 활용해 스토어 변경을 리액트 컴포넌트 밖에서 순수 리스닝(Listening)하여 DOM Node Ref의 속성을 즉각 덮어씌우는 패턴입니다.
이토록 가벼운 훅(Hook) 하나가, 수십 MB급 전역 상태의 비즈니스 안정성을 통제하면서 동시에 3D 그래픽 레벨의 상태까지 동시 제어할 수 있다는 점이 Zustand가 가져다준 위대한 아키텍처 혁신입니다.

X. 깊게 파헤치는 상태 관리의 동시성 제어와 Selector 최적화 (Deep Dive)

Redux가 강제하던 보일러플레이트의 억압에서 풀려나 Zustand를 들이는 순간, 우리는 매우 근본적인 질문에 직면합니다. 자유가 주어진 만큼, 스토어 오염과 불필요한 렌더링 폭포(Render Waterfall)를 어떻게 제어할 것인가?

1. 구독 튜닝과 Selector 파이프라인

Zustand의 진정한 힘은 스토어 코어의 자율성이 아닌, '어떻게 스토어 변화를 컴포넌트가 구독할 것인가(Subscription Model)'에 있습니다.
const state = useStore()를 남발하는 순간 애플리케이션은 최악의 성능 패널티를 받게 됩니다. 스토어 변수 중 어떠한 값만 하나 바뀌어도 해당 컴포넌트는 무의미하게 재평가(Re-render)되기 때문입니다.
이 성능 저하를 방어하려면 엄격하게 쪼개진 Selector를 파생(Derive)시켜야 합니다. const userName = useStore(state => state.user.name)와 같이 스토어에서 아주 좁은 단면만을 도출시키는 이 패턴은 메모이제이션 파이프라인의 핵심입니다. Zustand 내부의 얕은 비교 알고리즘(Shallow Compare)이 동작할 때 객체 전체가 아닌 원시값(Primitive)의 불변성만 체크하므로 V-DOM이 렌더링에 투입하는 비용이 문자 그대로 제로에 수렴하게 됩니다.

2. 외부 환경 연동(Transient Updates)과 마이크로 최적화

때론 리액트의 상태 렌더링 루프를 거치지 않고 DOM에 곧바로 영향을 줘야하는 극단적 마이크로 최적화가 요구됩니다. 가령 60FPS로 캔버스(Canvas)의 스크롤 좌표를 이동시켜야 한다면 Zustand의 상태 구독은 이 렌더링 압박을 견디지 못합니다.
Zustand는 이 한계를 깨부수기 위해 Transient Update(구독 우회 업데이트)라는 비기를 제공합니다. useStore.subscribe를 활용해 스토어 변경을 리액트 컴포넌트 밖에서 순수 리스닝(Listening)하여 DOM Node Ref의 속성을 즉각 덮어씌우는 패턴입니다.
이토록 가벼운 훅(Hook) 하나가, 수십 MB급 전역 상태의 비즈니스 안정성을 통제하면서 동시에 3D 그래픽 레벨의 상태까지 동시 제어할 수 있다는 점이 Zustand가 가져다준 위대한 아키텍처 혁신입니다.

X. 깊게 파헤치는 상태 관리의 동시성 제어와 Selector 최적화 (Deep Dive)

Redux가 강제하던 보일러플레이트의 억압에서 풀려나 Zustand를 들이는 순간, 우리는 매우 근본적인 질문에 직면합니다. 자유가 주어진 만큼, 스토어 오염과 불필요한 렌더링 폭포(Render Waterfall)를 어떻게 제어할 것인가?

1. 구독 튜닝과 Selector 파이프라인

Zustand의 진정한 힘은 스토어 코어의 자율성이 아닌, '어떻게 스토어 변화를 컴포넌트가 구독할 것인가(Subscription Model)'에 있습니다.
const state = useStore()를 남발하는 순간 애플리케이션은 최악의 성능 패널티를 받게 됩니다. 스토어 변수 중 어떠한 값만 하나 바뀌어도 해당 컴포넌트는 무의미하게 재평가(Re-ren