Zustand
오늘은 React 애플리케이션에서 상태 관리를 쉽고 효과적으로 할 수 있게 도와주는 Zustand에 대해 알아보겠습니다. 특히 Nex.js 환경에서 어떻게 활용할 수 있는지 자세히 살펴보겠습니다.
Zustand란?
Zustand는 독일어로 ‘상태’라는 뜻으로, React 애플리케이션에서 상태 관리를 위한 작고 빠르며 확장 가능한 라이브러리입니다. Redux나 MobX와 같은 다른 상태 관리 라이브러리보다 API가 단순하고 보일러플레이트 코드가 적어 초보자도 쉽게 접근할 수 있습니다.
Zustand의 주요 특징
- 간격한 API: 복잡함 설정 없이 몇 줄의 코드로 상태 저장소를 생성할 수 있습니다.
- 작은 번들 크기: 약 3KB 정도로 매우 가벼워 애플리케이션 성능에 부담을 주지 않습니다.
- 훅 기반 접근 방식: React 훅 시스템과 완벽하게 통합됩니다.
- Context 없음: React의 Context API를 사용하지 않아 성능 문제가 적습니다.
- 불필요한 리렌더링 방지: 컴포넌트가 실제로 사용하는 상태가 변경될 때만 리렌더링합니다.
- TypeScript 지원: 타입이 안전한 상태 관리가 가능합니다.
Zustand의 기본 사용법
간단한 예제로 Zustand의 기본 사용법을 알아보겠습니다
1
2
3
4
5
6
7
8
9
10
11
12
// store.js
import { create } from 'zustand'
// 스토어 생성
const useCountStore = create((set) => ({
count: 0,
increment: () => set((set) => ({ count: state.count + 1})),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
export default useCountStore
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import useCountStore from './store'
function Counter() {
// 스토어에서 상태와 액션을 가져옴
const count = useCountStore((state) => state.count)
const increment = useCountStore((state) => state.increment)
const decrement = useCountStore((state) => state.decrement)
const reset = useCountStore((state) => state.reset)
return (
<div>
<h1>{count}</h1>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
)
}
Zustand의 내부 구현
create
함수 분석
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
function create<T>(stateCreator: StateCreator<T>): UseBoundStore<T> {
// 1. 상태 관리 메커니즘:
let state: T // 스토어의 실제 상태 데이터를 저장하는 변수
const listeners = new Set<Listener>() // 상태 변화를 구독하는 리스너들의 집합
// 스토어의 상태를 업데이트 하는 함수
// partial은 새 상태 또는 상태를 변경하는 함수일 수 있습니다.
// replace가 true면 기존 상태를 완전히 대체하고, false면 기존 상태에 병합합니다.
// 상태가 변경되면 모든 리스너에게 새 상태를 알립니다.
const setState: SetState<T> = (partial, replace) => {
// 상태 업데이트 로직
const nextState = typeof partial === 'function'
? (partial as Function)(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state = replace
? (nextState as T)
: Object.assign({}, state, nextState)
// 구독자들에게 상태 변화 알림
listeners.forEach((listener) => listener(state, previousState))
}
}
// 현재 상태를 반환하는 간단한 함수입니다.
const getState: GetState<T> = () => state
const subscribe: Subscribe<T> = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
// 상태 관리에 필요한 주요 함수들을 묶은 객체입니다.
const api = { setState, getState, subscrib }
// 사용자가 정의한 상태 생성 함수를 실행하여 초기 상태를 설정합니다.
state = stateCreator(setState, getState, api)
// React 컴포넌트에서 스토어를 사용할 수 있는 커스텀 훅을 생성합니다.
const useBoundStore: UseBoundStore<T> = (selector, equalityFn) => {
// React 훅 구현
// ... 생략 ...
}
// API와 React 훅을 합침
Object.assign(useBoundStore, api)
return useBoundStore
}
useBoundStore
Hook
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const useBoundStore = (selector = getState, equalityFn = Object.is) => {
// 선택된 상태값 추적
const [, forceUpdate] = useReducer((c) => c + 1, 0)
// 선택자와 이전 선택된 상태 저장
const stateRef = useRef(selector(state))
const selectorRef = useRef(selector)
const equalityFnRef = useRef(equalityFn)
// 컴포넌트 마운트 시 구독 설정
useEffect(() => {
// 리스너 함수 정의
const listener = (nextState: T) => {
// 선택자 함수로 상태 일부 추출
const nextStateSlice = selectorRef.current(nextState)
// 이전 상태와 비교하여 변경 감지
if (!equalityFnRef.current(stateRef.current, nextStateSlice)) {
// 변경된 경우 참조 업데이트 및 리렌더링 트리거
stateRef.current = nextStateSlice
forceUpdate()
}
}
// 스토어에 리스너 등록
const unsubscribe = subscribe(listener)
// 컴포넌트 언마운트 시 구독 해제
return unsubscribe
}, [])
// 선택자나 비교 함수가 변경될 때 참조 업데이트
useEffect(() => {
selectorRef.current = selector
equalityFnRef.current = equalityFn
}, [selector, equalityFn])
// 현재 선택된 상태 변환
return stateRef.current
}
이 구현에서 중요한 부분은:
- 선택적 구독: 선택자 함수(selector)를 사용해 전체 상태 중 필요한 부분만 추출합니다.
- 변경 감지: 비교 함수(equalityFn)를 사용해 이전 상태와 현재 상태를 비교합니다.
- 최적화된 리렌더링: 실제로 사용 중인 상태가 변경된 경우에만 컴포넌트를 리렌더링합니다.
##
Zustand의 동작 원리
Zustand가 어떻게 전역 상태 관리를 하는지 이해하려면 그 내부 구조를 살펴볼 필요가 있습니다.
1. 저장소 구조
Zustand는 크게 두 부분으로 구성됩니다
- 상태(State): 애플리케이션의 데이터를 저장하는 단일 JavaScript 객체
- 액션(Actions): 상태를 변경하는 함수들
2. 구독 메커니즘
Zustand는 발행-구독(pub-sub) 패턴을 사용합니다
- 스토어가 생성되면 내부적으로 리스너 배열을 유지합니다.
- 컴포넌트에서
useCountStore()
와 같이 호출할 때, 해당 컴포넌트는 스토어의 상태 변화를 구독합니다. - 상태가 업데이트되면 모든 구독자(컴포넌트)에게 알림이 가고, 필요한 컴포넌트만 리렌더링됩니다.
3. 선택적 구독과 리렌더링
Zustand의 큰 장점 중 하나는 컴포넌트가 실제로 사용하는 상태가 변경될 때만 리렌더링된다는 점입니다:
1
2
3
4
5
6
7
8
// 이 컴포넌트는 count가 변경될 때만 리렌더링됨
const count = useCountStore((state) => state.count)
// 이 컴포넌트는 user 객체가 변경될 때만 리렌더링됨
const user = useUserStore((state) => state.user)
// 더 세밀하게 user.name이 변경될 때만 리렌더링됨
const userName = useUserStore((state) => state.user.name)
4. Zustand는 Context API를 사용하지 않음
Redux와 다르게, Zustand는 React의 Context API를 사용하지 않습니다. Context API는 상태가 변경될 때 해당 Context를 구독하는 모든 컴포넌트를 리렌더링할 수 있는 문제가 있지만, Zustand는 자체 구독 메커니즘을 통해 이 문제를 해결합니다.
Next.js에서 Zustand 사용하기
1. 서버 컴포넌트와 클라이언트 컴포넌트
Next.js 13 이상에서는 React Server Components(RSC)를 지원합니다. Zustand는 기본적으로 클라이언트 사이드에서 동작하므로, 서버 컴포넌트에서 직접 사용할 수 없습니다. 다음과 같이 사용해야 합니다
1
2
3
4
5
6
7
8
'use client' // 클라이언트 컴포넌트로 표시
import useStore from './store'
function ClientComponent() {
const count = useStore((state) => state.count)
// ...
}
2. 하이드레이션(Hydration) 이슈
Next.js는 서버에서 렌더링한 HTML을 클라이언트에 보내고, 클라이언트에서 자바스크립트를 통해 이를 하이드레이션합니다. 이 과정에서 서버와 클라이언트의 상태가 다르면 하이드레이션 에러가 발생할 수 있습니다.
Zustand에서는 이 문제를 해결하기 위한 두 가지 방법이 있습니다
1. 초기 상태를 일치시키기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// store.js
import { create } from 'zustand'
// 서버와 클라이언트 모두 동일한 초기 상태 사용
const initialState = {
count: 0,
// ...
}
const useStore = create((set) => ({
...initialState,
increment: () => set((state) => ({ count: state.count + 1 })),
// ...
}))
export default useStore
2. 상태 지속성 플러그인 사용하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
const useStore = create(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
// ...
}),
{
name: 'count-storage', // 로컬 스토리지 키 이름
storage: createJSONStorage(() => localStorage),
}
)
)
3. 여러 페이지/컴포넌트 간 상태 공유
Zustand의 가장 큰 장점 중 하나는 애플리케이션 전체에서 쉽게 상태를 공유할 수 있다는 점입니다. Next.js에서는 특히 다음과 같은 시나리오에서 유용합니다:
- 사용자 인증 상태 관리
- 장바구니 데이터 관리
- 테마/다크 모드 설정
- 사용자 설정 및 환경 설정 ```js // userStore.js import { create } from ‘zustand’ import { persist } from ‘zustand/middleware’
const useUserStore = create( persist( (set) => ({ user: null, isAuthenticated: false, login: (userData) => set({ user: userData, isAuthenticated: true }), logout: () => set({ user: null, isAuthenticated: false }), }), { name: ‘user-storage’, } ) )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
이제 어떤 페이지나 컴포넌트에서든 이 스토어를 사용할 수 있습니다.
```jsx
'use client'
import useUserStore from '@/stores/userStore'
export default function Header() {
const { user, isAuthenticated, logout } = useUserStore()
return (
<header>
{isAuthenticated ? (
<>
<span>안녕하세요, {user.name}님</span>
<button onClick={logout}>로그아웃</button>
</>
) : (
<a href="/login">로그인</a>
)}
</header>
)
}
Zustand의 리렌더링 최적화
Zustand는 리렌더링을 최적화하기 위한 몇 가지 방법을 제공합니다.
1. 선택적 구독
1
2
3
4
5
// 전체 상태를 구독 (비권장)
const state = useStore()
// 특정 상태만 구독 (권장)
const count = useStore((state) => state.count)
특정 상태만 구독하면, 다른 상태가 변경되더라도 해당 컴포넌트는 리렌더링되지 않습니다.
2. shallow 비교
객체나 배열과 같이 참조 타입의 데이터를 다룰 때는 shallow 비교를 사용하면 불필요한 리렌더링을 방지할 수 있습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { shallow } from 'zustand/shallow'
// user 객체의 내용이 같으면 리렌더링하지 않음
const user = useStore((state) => state.user, shallow)
// 여러 상태를 배열로 선택
const [name, age] = useStore(
(state) => [state.user.name, state.user.age],
shallow
)
// 여러 상태를 객체로 선택
const { name, age } = useStore(
(state) => ({ name: state.user.name, age: state.user.age }),
shallow
)
3. 불변성 유지
Zustand에서는 상태 업데이트 시 불변성을 유지하는 것이 중요합니다. 이는 리렌더링 최적화와 예측 가능한 상태 관리를 위해 필수적입니다.
1
2
3
4
5
6
7
8
// 잘못된 방법 (불변성 위반)
const badUpdate = () => {
state.count += 1
set(state)
}
// 올바른 방법 (불변성 유지)
const goodUpdate = () => set((state) => ({ count: state.count + 1 }))
Zustand 활용 팁
1. 여러 스토어 사용하기
Zustand는 여러 개의 분리된 스토어를 만들 수 있어, 관심사 분리(Separation of Concerns)에 유리합니다:
1
2
3
4
5
6
7
8
// userStore.js
export const useUserStore = create((set) => ({ /* ... */ }))
// cartStore.js
export const useCartStore = create((set) => ({ /* ... */ }))
// themeStore.js
export const useThemeStore = create((set) => ({ /* ... */ }))
2. 미들웨어 활용하기
Zustand는 다양한 미들웨어를 지원하여 기능을 확장할 수 있습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
const useStore = create(
devtools(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{ name: 'count-storage' }
)
)
)
devtools
: Redux DevTools와 연동하여 디버깅을 돕습니다.persist
: 로컬 스토리지 등에 상태를 저장하여 페이지 새로고침 시에도 상태를 유지합니다.immer
: Immer 라이브러리를 통해 불변성을 쉽게 다룰 수 있게 합니다.
3. TypeScript 와 함께 사용하기
Zustand는 TypeScript와 잘 작동합니다:
1
2
3
4
5
6
7
8
9
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
결론
Zustand는 간결한 API와 유연한 사용성으로 인해 React와 Next.js 애플리케이션의 상태 관리에 매우 적합한 라이브러리입니다. 특히:
- 간결함: 최소한의 코드로 강력한 상태 관리 기능을 제공합니다.
- 성능: 선택적 구독 메커니즘을 통해 불필요한 리렌더링을 방지합니다.
- 유연성: 미들웨어를 통해 다양한 확장이 가능합니다.
- 타입 안전성: TypeScript와 완벽하게 호환됩니다.
- Next.js 호환성: 서버 사이드 렌더링과 하이드레이션 이슈를 쉽게 해결할 수 있습니다.
Zustand는 복잡한 설정 없이도 효과적인 상태 관리가 가능하여, 소규모 프로젝트부터 대규모 애플리케이션까지 다양한 상황에서 활용할 수 있습니다. React와 Next.js를 사용하는 프론트엔드 개발자라면 꼭 한번 시도해볼 만한 라이브러리입니다.