왜 이렇게 동작할까?
Zustand의 selector는 엄격한 동등성 검사(old === new)를 통해 변화를 추적하고 상태 변경을 컴포넌트에 알려 리렌더링을 시키기 때문에 셀렉터를 아토믹하게 유지하는 게 중요하다고 합니다.
// 🚨 selector는 모든 호출에서 새 객체를 반환합니다.
const { bears, fish } = useBearStore((state) => ({
bears: state.bears,
honey: state.honey,
}))
// 😮 그래서 이 둘은 동등합니다.
const { bears, honey } = useBearStore()
// ⬇️ 최적화되었기 때문에 훨씬 더 좋습니다.
const bears = useBearStore((state) => state.bears)
const honey = useBearStore((state) => state.honey)
내부적으로 어떻게 구현되어 있길래 이런 다소 까다로운 규칙을 요구하는지 궁금해졌습니다.
결론적으로 내부 구현을 살펴보며 Zustand의 동작 원리, 발행/구독 모델, 클로저를 통한 상태 관리, 그리고 React의 useSyncExternalStore
훅까지 이해할 수 있는 계기가 되었습니다.
Zustand 사용 방법
본격적으로 내부 구현을 보기 전에 zustand의 사용법부터 보고 가겠습니다.
먼저 create 함수를 콜백과 함께 실행시켜서 스토어를 만들수 있습니다. create는 리액트 훅을 리턴합니다.
create 함수에 넘긴 콜백은 set이라는 함수를 인자로 받게 되죠. set을 가지고 상태를 변경할 수 있습니다.
import { create } from 'zustand'
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
}))
이렇게 만들어진 훅을 컴포넌트에서 실행하면 스토어에서 정의된 상태를 가져와 쓸 수 있게 되죠.
const { bears } = useStore()
물론 이렇게 사용하면 셀렉터가 아토믹하지 않기 때문에 어떤 상태가 변경되든지 리렌더링이 발생합니다. 셀렉터와 함께 사용할 때 컴포넌트 리렌더링을 최소화할 수 있습니다.
const bears = useStore((state) => state.bears)
구조 살펴보기
zustand의 프로젝트 구조를 볼까요?
zustand는 프레임워크에 종속되지 않은 core 로직과 이를 감싸는 React 어댑터 로직으로 구성된 Framework-Agnostic 구조를 가지고 있습니다.
코어에서 프레임워크에 종속되지 않은 상태로 모든 핵심 로직을 처리하고, 어댑터는 핵심 로직을 React의 라이프사이클에 통합하여 React 컴포넌트에서 훅으로 쉽게 사용할 수 있게 해줍니다.
Zustand 코어 로직 열어보기
타입스크립트 타입을 제거하고 순수 JavaScript로 단순화한 코어 로직은 다음과 같습니다.
const createStoreImpl = (createState) => {
let state
const listeners = new Set()
const setState = (partial, replace) => {
// partial 값이나 업데이트 함수를 기반으로 다음 상태 결정
const nextState =
typeof partial === 'function'
? partial(state) // partial이 함수면 현재 상태를 인자로 호출
: partial // 그렇지 않으면 partial 자체가 다음 상태
// 다음 상태가 현재 상태와 다를 때만 진행
if (!Object.is(nextState, state)) {
const previousState = state
// 상태를 완전히 대체할지 기존 상태와 병합할지 결정
state =
(replace ?? typeof nextState !== 'object' || nextState === null)
? nextState // 상태를 완전히 대체
: Object.assign({}, state, nextState) // nextState를 현재 상태에 병합
// 상태 변경을 구독한 모든 리스너에게 알림
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState = () => state // 현재 상태를 반환하는 함수
const getInitialState = () => initialState // 초기 상태를 반환하는 함수
const subscribe = (listener) => {
listeners.add(listener) // 리스너를 Set에 추가
return () => listeners.delete(listener) // 구독 해지 함수 반환
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api)) // 상태 초기화
return api // 스토어 API 반환
}
const createStore = (createState) =>
createState ? createStoreImpl(createState) : createStoreImpl
유명한 상태관리 라이브러리의 코어 로직이 이정도로 간단하다는게 놀랍지 않나요?
전체적인 구조를 보면 상태를 외부에 직접 노출하지 않고 상태를 읽고 변경하고 구독하는 setState, getState, subscribe 등의 함수만 외부로 노출해서 안전하게 상태를 클로저로 관리한다는 걸 알 수 있습니다.
상태 저장
위에서부터 천천히 볼까요?
const createStoreImpl = (createState) => {
let state
const listeners = new Set()
// ...
}
상태 변경을 구독하기 위한 리스너들을 Set으로 관리해서 중복 등록을 방지하고 있습니다.
상태 변경
다음은 상태를 변경하는 setState 입니다.
const setState = (partial, replace) => {
const nextState =
typeof partial === "function"
? partial(state)
: partial;
if (!Object.is(nextState, state)) {
const previousState = state;
state =
(replace ?? (typeof nextState !== "object" || nextState === null))
? nextState
: Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState))
}
}
setState는 저희가 create로 스토어를 만들 때 인자로 받아서 사용할 set 함수입니다.
// setState 사용 시
import { create } from 'zustand'
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
}))
먼저 nextState를 만드는데, partial이 함수면 함수를 실행해서 새로운 상태를 생성합니다.
const setState = (partial, replace) => {
const nextState =
typeof partial === "function"
? partial(state)
: partial;
// ...
}
그리고 nextState가 현재의 state와 같은지 Object.is를 통해 엄격한 동등성 검사를해서 다른 경우에만 상태를 변경하게 되죠.
if (!Object.is(nextState, state)) {
const previousState = state;
state =
(replace ?? (typeof nextState !== "object" || nextState === null))
? nextState
: Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState))
}
상태를 재할당할 때 Object.assign으로 객체를 병합해서 새로운 상태를 생성합니다.
그런데 만약 nextState가 객체가 아닌 경우 병합이 불가능하므로 상태를 완전히 대체하게 됩니다.
마찬가지로 replace 파라미터가 명시적으로 true라면 상태를 병합하지 않고 대체하겠죠.
null
은 typeof
결과가 'object'
이지만 실제로는 객체가 아니므로 별도로 체크하는 모습도 볼 수 있습니다.
이렇게 상태가 바뀌고 나면 바뀐 사실을 구독자들에게 알리고 새로운 상태를 줍니다.
그 방법은 생각보다 간단한데, Set.prototype.forEach
로 listeners에 등록해둔 리스너들을 순회하면서 상태와 함께 모두 호출시키는 게 다예요. 구독/발행 패턴에서 자주 볼 수 있는 코드죠.
listeners.forEach((listener) => listener(state, previousState))
💡 리스너가 모두 호출되면 셀렉터를 아토믹하게 유지하는 게 무슨 의미가 있나요?
⇒ 예전엔 코어에서 슬라이스 값을 Object.is로 검사해서 값이 다를때만 리스너를 호출했습니다.
https://github.com/pmndrs/zustand/commit/eadb81f9c6dbbe92dfabd8fb91a584760569c7d9#diff-b68a2cbc8606fe37f9c2c72867a87ff50a11c2b38a6d9de45a94281f02bf0a4eL116
하지만 v4.0부터 useSyncExternalStore(이하 useSES)를 사용하면서 구독하는 값의 동등성을 검사하는 책임을 useSES 훅이 가져갔습니다.
https://github.com/pmndrs/zustand/commit/a34649d35ab6dc91b658729a94764a5c1560fcda#diff-ca56e63fa839455c920562a44ebc44594f47957bbd3e9873c8a9e64104af2c41
즉, 아무리 zustand의 setState에서 상태가 바뀌어서 리스너들을 호출(발행)해도 특정 컴포넌트에서 사용한 useSES 훅이 이전 슬라이스와 현재 슬라이스 값이 같다고 판단하면 해당 컴포넌트의 리렌더링을 트리거하지 않습니다.
상태 구독
const subscribe = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
구독하는 코드는 간단하죠? 리스너 함수를 등록해주고, 구독을 정리하는 함수를 반환하고 있습니다.
예시
// 스토어를 초기화하기 위한 createState 함수 정의
const createState = (set, get, api) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
})
// 스토어 생성
const store = createStore(createState)
// 상태 변경 구독
const unsubscribe = store.subscribe((state, prevState) => {
console.log('상태가 변경되었습니다:', prevState, '->', state)
})
// 스토어의 API 사용
console.log(store.getState()) // { count: 0, increment: [Function: increment] }
store.getState().increment() // 상태가 변경되었습니다: { count: 0, increment: [Function] } -> { count: 1, increment: [Function] }
// 구독 해지
unsubscribe()
리액트와 연동하기
이제 리액트와 코어를 어떻게 연결할지 알아볼까요?
import React from 'react'
import { createStore } from './vanilla.js'
const identity = (arg) => arg
export function useStore(api, selector = identity) {
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState())
)
React.useDebugValue(slice)
return slice
}
const createImpl = (createState) => {
const api = createStore(createState)
const useBoundStore = (selector) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
export const create = (createState) =>
createState ? createImpl(createState) : createImpl
리액트 어댑터는 실행되는 순서대로 설명해 보겠습니다.
1. create 호출
개발자가 create
함수를 호출합니다.
import { create } from 'zustand'
const useBoundStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
여기서 create
함수는 createState
함수를 인자로 받습니다.
- 만약
createState
인자가 있다면 즉시createImpl(createState)
를 호출하여useBoundStore
훅을 반환합니다. createState
인자가 없다면createImpl
함수 자체를 반환하여 나중에 초기화할 수 있게끔 합니다.
이 과정에서 useBoundStore
훅은 아직 반환되지 않은 상태이거나, 혹은 바로 반환될 준비를 마칩니다.
2. createImpl(createState) 실행
createImpl
함수가 호출되면, 내부에서 createStore(createState)
를 통해 vanilla.js의 코어 스토어를 생성합니다.
const api = createStore(createState)
이때 api
는 { setState, getState, getInitialState, subscribe }
메서드를 가진 zustand의 코어 스토어 인터페이스입니다.
3. useBoundStore 훅 생성
createImpl
함수 내부에서 useBoundStore
라는 새로운 훅을 정의합니다. 이 훅은 useStore(api, selector)
를 호출하는 형태로, 결국 useStore
훅에 스토어 api
를 바인딩한 형태입니다.
const useBoundStore = (selector) => useStore(api, selector)
이제 useBoundStore
는 selector를 받아 해당 부분 상태를 반환할 준비가 된 훅입니다.
4. API 메서드 바인딩
Object.assign(useBoundStore, api)
로 useBoundStore
훅에 setState
, getState
, subscribe
등의 메서드를 붙여줍니다.
이로써 useBoundStore
는 단순히 상태를 읽는 훅이 아닌, 상태를 변경하거나 구독할 수 있는 모든 기능을 갖춘 훅이 됩니다.
Object.assign(useBoundStore, api)
return useBoundStore
5. useStore 훅 내부 동작
이제 useBoundStore
를 컴포넌트에서 사용할 때, 실제로는 useStore
훅이 호출됩니다.
export function useStore(api, selector = identity) {
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState())
)
return slice
}
useStore를 이해하기 위해선 useSES 훅을 이해해야합니다.
useSES는 외부 상태를 구독하고 리액트 렌더링과 연동시키 위한 훅입니다.
useSyncExternalStore 훅의 주요 포인트:
- useSES는 외부 스토어의 subscribe를 받아서 내부적으로
handleStoreChange
라는 함수와 함께 호출해서 listeners에 등록합니다. handleStoreChange는 스토어의 스냅샷(여기서는 selector로 추출된 상태의 슬라이스)이 변경되었다면forceUpdate
를 호출해서useStore
를 사용하는 컴포넌트를 리랜더링 하는 함수입니다. - useSES 내부적으로 useState를 사용합니다. forceUpdate도 사실 setState죠.
- 위에서 아무리 zustand의 setState에서 상태가 바뀌어서 리스너들을 모두 호출해도 useSES훅이 이전 슬라이스와 현재 슬라이스값이 같다고 판단하면 해당 컴포넌트의 리렌더링을 트리거하지 않는다고 설명했는데, 그 이유가 바로 handleStoreChange 때문입니다.
useStore
훅의 주요 포인트:
React.useSyncExternalStore
를 사용해 외부 스토어(api
)를 구독합니다.api.subscribe
를 통해 상태 변경 시 리렌더링을 트리거합니다.selector(api.getState())
로 현재 상태 중 필요한 부분만 추출하거나, selector를 주지 않았다면 전체 상태를 반환합니다.- 서버 사이드 렌더링(SSR)의 하이드레이션에서 사용할
selector(api.getInitialState())
도 제공합니다.
6. 컴포넌트에서 useBoundStore 사용
실제 React 컴포넌트에서는 useBoundStore
를 호출하기만 하면, 내부적으로 useStore
가 동작하여 현재 상태를 반영합니다.
function Counter() {
const { count, increment } = useBoundStore()
return (
<div>
<span>Count: {count}</span>
<button onClick={increment}>+1</button>
</div>
)
}
유저가 버튼을 클릭했을때 내부 동작을 따라가 보겠습니다.
click 이벤트 감지 → increment 실행 → zustand setState 실행 → 상태가 변경되면 전체 리스너들을 순회하며 실행 → useSES가 컴포넌트에서 사용하는 슬라이스의 변경 여부를 판단 → 슬라이스도 변경됐다면 컴포넌트 리랜더링 → UI 업데이트
마무리
흔히 말하는 zustand의 특징은 아래와 같습니다.
- 특정 라이브러리에 엮이지 않는다. (React와 함께 쓸 수 있는 API를 제공한다.)
- 한 개의 중앙에 집중된 형식의 스토어 구조를 활용하면서, 상태를 정의하고 사용하는 방법이 단순하다.
- Context API를 사용할 때와 달리 상태 변경 시 불필요한 리랜더링을 일으키지 않도록 제어하기 쉽다.
아, 그냥 이런 장점들이 있구나라고 생각하면서 쓰고 있었는데, 내부적으로 어떻게 동작하는지 머리에 그리면서 사용하니 최적화 포인트를 더 잘 이해할 수 있었습니다.