Zustand는 React 생태계에서 가볍고 직관적인 상태 관리 라이브러리로 많이 사용되고 있습니다. 이 글에서는 Zustand의 핵심 로직을 직접 분석하며 학습한 내용을 정리하였습니다. 특히 createStore 함수의 동작 원리를 중심으로, 상태가 어떻게 업데이트되고 구독되는지를 파악하는 데 집중하였습니다.
✅ 학습 목표
Zustand의 createStore
함수가 어떻게 동작하는지 완전히 이해합니다.
⚒️ 분석 방법
Zustand 실제 사용 코드를 기반으로 역으로 분석했습니다. create 코드를 정의하는 곳을 찾은 후, 연관된 코드를 분석하여 핵심 로직을 파악했습니다.
🤔 분석 과정
Zustand에서 상태를 만들 때 사용하는 기본적인 패턴은 아래와 같습니다.
import { create } from 'zustand'
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
updateBears: (newBears) => set({ bears: newBears }),
}))
✏️ 내부 코드 분석
create로 넘겨오는 createState 흐름
create는 react.ts에 호출되어 있습니다.
create 함수 내부를 보면 createImpl을 호출하고 있으며, createImpl은 내부에서 createStore를 호출하고 있습니다.
// react.ts
// 1. create 안에서 createImpl을 호출
export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
createState ? createImpl(createState) : createImpl) as Create // createImpl은 vanilla 로직
// 2. createImpl에서 createStore를 호출
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
const useBoundStore: any = (selector?: any) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
즉, create -> createImpl -> createStore -> createStoreImpl 순으로 호출됩니다. 최종적으로 create에서 넘겨주는 인자는 createStoreImpl로 넘어오는 것을 알 수 있습니다. 결국 create에서 인자로 넘기는 (set) => ({}) 부분은 createStoreImpl의 파라미터인 createState로 넘어가게 됩니다.
// vanilla.ts
// 3. react.ts의 createImpl에서 호출하는 함수
export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
// 4. createStore에서 호출하는 함수
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>
type Listener = (state: TState, prevState: TState) => void
let state: TState
const listeners: Set<Listener> = new Set()
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
// TODO: Remove type assertion once https://github.com/microsoft/TypeScript/issues/37663 is resolved
// https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) { // 두 값이 같은 값인지 결정 => nextState, state가 다르면~
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState) // replace true이거나 뒤 조건 true인 경우
: Object.assign({}, state, nextState) // replace false이거나 뒤 조건 false인 경우
listeners.forEach((listener) => listener(state, previousState)) // 새로운 상태와 이전 상태
}
}
const getState: StoreApi<TState>['getState'] = () => state // 클로저
const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api as any
}
const initialState = (state = createState(setState, getState, api))
// 위 코드에 따라 createState가 곧 파라미터로 넘어오는 아래 함수와 같습니다.
createState === (set) => ({})
따라서, set은 createState의 첫 번째 인자인 setState와 동일합니다.
항상 set이 setState와 동일하다고 했을 때, 왜 그런 말이 나오는지 이해가 잘 되지 않았는데, 이런 내부구현에 의해 동일하다는 것을 알 수 있었습니다.
setState 내부 로직
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
// TODO: Remove type assertion once https://github.com/microsoft/TypeScript/issues/37663 is resolved
// https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) { // 두 값이 같은 값인지 결정 => nextState, state가 다르면~
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState) // replace true이거나 뒤 조건 true인 경우
: Object.assign({}, state, nextState) // replace false이거나 뒤 조건 false인 경우
listeners.forEach((listener) => listener(state, previousState)) // 새로운 상태와 이전 상태
}
}
부분적으로 하나씩 살펴봤을 때, partial은 nextState를 결정하는 데 사용합니다.
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
// ...
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
// ...
setState의 첫 번째 파라미터로 넘어오는 값은, 아래 실제 호출하는 부분을 살펴보면 알 수 있습니다.
// 1. partial === (state) => ({ bears: state.bears + 1 })
set((state) => ({ bears: state.bears + 1 })),
// 2. partial === { bears: 0 }
set({ bears: 0 }),
// 3. partial === { bears: newBears }
set({ bears: newBears }),
위 예시에서 1번의 경우, partial은 (state) => ({ bears: state.bears + 1 })입니다.
nextState 로직에서 partial의 타입이 function인 경우 partial을 실행하기 때문에, { bears: state.bears + 1 }가 nextState가 되며, 함수가 아닌 경우인 2, 3번은 각각 { bears: 0 }, { bears: newBears }가 nextState가 됩니다.
if (!Object.is(nextState, state)) {
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState) // replace true이거나 뒤 조건 true인 경우
: Object.assign({}, state, nextState) // replace false이거나 뒤 조건 false인 경우
listeners.forEach((listener) => listener(state, previousState)) // 새로운 상태와 이전 상태
}
이렇게 구해진 nextState를 Object.js를 통해 nextState와 기존 state를 비교하는데,
일치하지 않을 경우, 기존 state에 변경된 값을 전달하고 listeners에 state와 previousState를 넘겨줍니다.
여기까지 이해가 됐는데... Object.js에서 비교하는 기존 state는 어디에서 받아오는 걸까? 하는 궁금증이 생겼습니다.
state가 어디서 오는 건지를 알아봅시다.
기존 state를 가져오는 방법
// vanilla.ts
const createStoreImpl: CreateStoreImpl = (createState) => {
// ...
// state 선언
let state: TState
// getState 호출 시 state를 반환
const getState: StoreApi<TState>['getState'] = () => state
// ...
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api as any
}
state는 createStoreImpl에서 선언합니다. 그리고 이는 getState를 호출 시 반환됩니다. getState는 api에 할당되는데, 결국 api는 createStoreImpl의 반환값으로 사용됩니다.
그리고 앞서 createState가 타고 타고 내려왔던 것처럼, 다시 타고 올라가서 createStore의 반환값으로 api가 사용됩니다. api는 useStore 함수의 파라미터에 할당되어 useBoundStore를 반환하게 됩니다. useBoundStore는 createImpl의 반환값이고 이는 결국 또 react.ts의 호출하는 곳까지 넘어와서 create의 반환값으로 연결된다는 것을 알 수 있습니다.
// react.ts
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
const useBoundStore: any = (selector?: any) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
getState는 api에 담겨, useStore에 넘어가며 useStore에서 가공되어 useBoundStore에서 사용될 수 있도록 반환됩니다.
useStore의 내부 로직
그렇다면 이제 create의 반환값으로 사용되며, state를 가져오고 활용할 수 있게 만드는 useStore의 로직을 살펴보겠습니다.
useStore로는 앞서 createStoreImpl에서 반환되었던 api를 넘겨주며, api는 slice의 내부에서 가공됩니다.
// react.ts
export function useStore<TState, StateSlice>(
api: ReadonlyStoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any,
) {
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState()),
)
React.useDebugValue(slice)
return slice
}
React.useSyncExternalStore는 외부 store를 구독할 수 있는 React Hook입니다. 여기에 createStoreImpl에서 가공했던 데이터들을 넘겨주며, 최종적으로 store의 역할을 하게 만드는 것입니다.
https://ko.react.dev/reference/react/useSyncExternalStore
useSyncExternalStore – React
The library for web and native user interfaces
ko.react.dev
🍀 정리하며
이번 분석을 통해 다음과 같은 흐름을 명확히 이해할 수 있었습니다.
create → createImpl → createStore → createStoreImpl
↘︎ 반환된 api → useStore(api) → useBoundStore()
- Zustand의 내부는 클로저와 구독 기반의 구조로 되어있습니다.
- setState 함수가 create에 넘긴 set으로 직접 전달된다는 점에서, 함수형으로 선언된 상태 정의 로직이 내부적으로 어떻게 연결되는지를 명확히 이해할 수 있었습니다.
이후에는 Zustand에서 사용하는 미들웨어에 대해 알아볼 예정입니다.
'개발' 카테고리의 다른 글
[함수형 코딩] 반응형 아키텍처와 어니언 아키텍처 (+ 짧은 책 후기) (7) | 2025.06.02 |
---|---|
[함수형 코딩] 비동기 타임 라인 컨트롤 (6) | 2025.05.21 |
2025 프론트엔드 3년차 경력직 면접 질문 정리 (+ 실전 팁) (13) | 2025.05.11 |
[함수형 코딩] 중복 코드를 없애는 방법: 암묵적 인자 드러내기 (2) | 2025.04.07 |
내가 겪은 CSS 스타일링 전략의 변화와 고민들 🤔 (12) | 2025.04.06 |