클로저와 스코프에 대해 공부하던 중 Zustand 코드를 분석해보면 클로저를 어떻게 활용하는지에 대해서 알 수 있는 아주 좋은 예시가 될 것 같아 직접 소스 코드를 분석해보기로 했다.
핵심 구조 - vanilla.ts
Zustand의 핵심은 vanilla.ts에 있는 100줄짜리 코드가 전부이다. 나머지 코드는 React나 미들웨어와 연동하기 위한 '포장'에 가깝다.
가독성을 위해 타입을 제거한 코드를 살펴보자.
// vanilla.ts
const createStoreImpl = (createState) => {
// --- 클로저의 시작 ---
let state;
const listeners = new Set();
const getState = () => state;
const setState = (partial, replace) => {
// partial이 함수일 수도, 객체일 수도 있음
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);
// 모든 구독자(listener)에게 변경된 상태를 알림
listeners.forEach((listener) => listener(state, previousState));
}
};
const subscribe = (listener) => {
listeners.add(listener);
// 구독 취소 함수를 반환
return () => listeners.delete(listener);
};
const destroy = () => listeners.clear();
// 외부에 노출될 API 객체
const api = { setState, getState, subscribe, destroy };
// 초깃값 설정! createState 함수를 실행해 state를 초기화
state = createState(setState, getState, api);
return api;
// --- 클로저의 끝 ---
};
export const createStore = (createState) =>
createState ? createStoreImpl(createState) : createStoreImpl;
위 코드에서 createStoreImpl
함수가 실행되면, 그 내부에 선언된 변수 state
와 listeners
는 사라지는 일반적인 지역 변수와 달리, 클로저 때문에 사라지지 않고 메모리에 계속 남아있게 된다.
createStoreImpl
이 반환하는 api
객체에 포함된 getState
, setState
, subscribe
함수들은 자신이 생성될 때의 환경(스코프)을 기억하게 된다. 이 스코프에는 state
와 listeners
가 포함되어 있다.
- 캡슐화 :
state
변수는createStoreImpl
함수 스코프 안에 '갇혀' 있다. 외부에서는 오직getState
나setState
같은 api 메서드를 통해서만 이state
에 접근할 수 있다.- 상태 유지:
createStoreImpl
함수 호출은 단 한 번이지만, 그 결과로 생긴state
와listeners
는 애플리케이션이 실행되는 내내 유지된다.api
메서드들이 이들을 계속 참조하고 있기 때문이다.
React 통합 - react.ts
이제 vanilla store를 React와 통합하는 과정을 살펴보자. 마찬가지로 타입을 제거한 코드이다.
import React from 'react';
import { createStore } from './vanilla.js';
// vanilla store와 React를 연결하는 bridge hook
export function useStore(api, selector) {
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState())
);
return slice;
}
const createImpl = (createState) => {
// 1. vanilla store API 생성 (state와 listeners가 여기서 클로저로 생성됨)
const api = createStore(createState);
// 2. 이 store에 바인딩된 전용 훅(hook) 생성
const useBoundStore = (selector) => useStore(api, selector);
// 3. 훅 자체에 api 메서드(setState 등)를 첨부
Object.assign(useBoundStore, api);
return useBoundStore;
};
export const create = (createState) =>
createState ? createImpl(createState) : createImpl;
- vanilla store를 생성한다.
useBoundStore
를 통해useSyncExternalStore
를 반환하는 hook을 만든다.- 반환된 hook에 api 메서드(
setState
,getState
등)를 바인딩한다.
useSyncExternalStore의 역할
useSyncExternalStore
는 React 18에서 도입된 hook으로, 외부 저장소와 React를 동기화 환다. React에게 외부 데이터 소스(여기서는 Zustand의 store)를 구독하고, 변경이 발생했을 때 리렌더링을 일으키도록 지시하는 역할을 한다.
// useSyncExternalStore 내부 (간략화)
useEffect(() => {
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
const handleStoreChange = () => {
if (checkIfSnapshotChanged(inst)) {
// 강제 리렌더링
forceUpdate({inst});
}
};
// 스토어 구독 및 cleanup 함수 반환
return subscribe(handleStoreChange);
}, [subscribe]);
useSyncExternalStore
에서는 값을 비교 후 강제로 컴포넌트를 업데이트하는 함수인 handleStoreChange
를 listener에 등록한다. 이 과정을 통해서 setState
에서 listener를 호출하면 이 handleStoreChange
가 실행되면서 컴포넌트가 리렌더링될 수 있는 것이다.
개발자 도구에서 확인해보기
const useBearStore = create((set, get) => ({
bears: 0,
increase: () => set(state => ({ bears: state.bears + 1 })),
}));
function Component() {
const bears = useBearStore(state => state.bears);
return <h1>{bears} bears</h1>;
}
실제로 위와 같은 컴포넌트를 호출하고, 개발자도구 소스 → 스코프 탭에서 확인해보면 다음과 같이 전부 클로저로 캡처된 것을 볼 수 있다.
단계별 동작 살펴보기
// 1. 스토어 훅(hook) 생성
const useBearStore = create((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
}));
// 2. 컴포넌트에서 스토어 사용
function BearCounter() {
const bears = useBearStore((state) => state.bears);
return <h1>{bears} bears</h1>;
}
// 3. 상태 업데이트
function Controls() {
const increase = useBearStore((state) => state.increase);
return <button onClick={increase}>one up</button>;
}
1단계: 스토어 생성 (create
)
create
함수가 호출되면서createStoreImpl
이 실행된다.- 이때,
useBearStore
만을 위한 고유한state
와listeners
가 클로저 스코프에 생성된다. state
는{ bears: 0, increase: [Function] }
으로 초기화된다.increase
함수는 클로저를 통해setState
함수를 기억하고 있는다.useBearStore
라는 이름의 커스텀 훅이 반환된다. 이 훅은setState
,getState
등의 메서드도 함께 가지고 있는다.
2단계: 컴포넌트 렌더링 및 구독 (useBearStore
)
BearCounter
컴포넌트가 처음 렌더링될 때useBearStore
훅이 실행된다.useSyncExternalStore
가 호출되어, 1단계에서 만들어진listeners
에 리렌더링을 유발하는 함수를 등록(subscribe
)한다.getSnapshot
콜백() => state.bears
가 실행되어 초기값0
을 가져와bears
변수에 할당하고, 화면에 "0 bears"를 그린다.
3단계: 상태 업데이트 (increase
)
- 사용자가 버튼을 클릭하면
Controls
컴포넌트에서 가져온increase
함수가 실행된다. increase
함수는 클로저로 기억하고 있던setState
를 호출한다.setState
는state
를{ bears: 1 }
로 업데이트한다.setState
는listeners
을 순회하며 등록된 모든 함수를 호출한다.BearCounter
의useSyncExternalStore
가 등록한 '리렌더링 함수'가 호출된다.- React는
BearCounter
컴포넌트를 리렌더링하고,getSnapshot
을 다시 실행해 새로운 값1
을 가져와 화면을 업데이트한다.
마무리
Zustand는 클로저의 강력함과 실용성을 보여주는 완벽한 예시인듯 하다. 단 100줄의 코드 (타입 제거시 약 40줄)로 다음 목적을 달성했다.
- 캡슐화: 상태를 외부에서 직접 접근할 수 없도록 보호한다.
- 구독 패턴:
Set
과useSyncExternalStore
를 통해 상태 변경을 효율적으로 전파하고, 필요한 컴포넌트만 정확히 리렌더링 한다. - 단순성: 복잡한 리듀서, 액션 타입, 디스패처 없이 직관적인 API를 제공한다.
클로저라는 잘 와닿지 않을 수 있는 개념을 제대로 활용하면 강력한 추상화를 만들 수 있다는 점을 Zustand 분석을 통해 얻게 되었다. 이러한 패턴을 이해하면 더 나은 상태 관리 솔루션을 만들거나, 기존 라이브러리를 더 깊이 이해할 수 있을 것 같다.