Zustand 동작 원리 살펴보기 (with Closure)

profile image

Zustand의 소스코드를 직접 분석하며 클로저를 활용한 상태 관리의 핵심 원리를 파헤치며 100줄로 구현된 강력한 상태 관리 라이브러리의 비밀을 알아보자.

클로저와 스코프에 대해 공부하던 중 Zustand 코드를 분석해보면 클로저를 어떻게 활용하는지에 대해서 알 수 있는 아주 좋은 예시가 될 것 같아 직접 소스 코드를 분석해보기로 했다.

핵심 구조 - vanilla.ts

Zustand의 핵심은 vanilla.ts에 있는 100줄짜리 코드가 전부이다. 나머지 코드는 React나 미들웨어와 연동하기 위한 '포장'에 가깝다.

가독성을 위해 타입을 제거한 코드를 살펴보자.

javascript
// 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 함수가 실행되면, 그 내부에 선언된 변수 statelisteners는 사라지는 일반적인 지역 변수와 달리, 클로저 때문에 사라지지 않고 메모리에 계속 남아있게 된다.

createStoreImpl이 반환하는 api 객체에 포함된 getState, setState, subscribe 함수들은 자신이 생성될 때의 환경(스코프)을 기억하게 된다. 이 스코프에는 statelisteners가 포함되어 있다.

  • 캡슐화 : state 변수는 createStoreImpl 함수 스코프 안에 '갇혀' 있다. 외부에서는 오직 getStatesetState 같은 api 메서드를 통해서만 이 state에 접근할 수 있다.
  • 상태 유지: createStoreImpl 함수 호출은 단 한 번이지만, 그 결과로 생긴 statelisteners는 애플리케이션이 실행되는 내내 유지된다. api 메서드들이 이들을 계속 참조하고 있기 때문이다.

React 통합 - react.ts

이제 vanilla store를 React와 통합하는 과정을 살펴보자. 마찬가지로 타입을 제거한 코드이다.

javascript
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;
  1. vanilla store를 생성한다.
  2. useBoundStore를 통해 useSyncExternalStore를 반환하는 hook을 만든다.
  3. 반환된 hook에 api 메서드(setState, getState 등)를 바인딩한다.

useSyncExternalStore의 역할

useSyncExternalStore는 React 18에서 도입된 hook으로, 외부 저장소와 React를 동기화 환다. React에게 외부 데이터 소스(여기서는 Zustand의 store)를 구독하고, 변경이 발생했을 때 리렌더링을 일으키도록 지시하는 역할을 한다.

javascript
// 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가 실행되면서 컴포넌트가 리렌더링될 수 있는 것이다.

개발자 도구에서 확인해보기

javascript
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>;
}

실제로 위와 같은 컴포넌트를 호출하고, 개발자도구 소스 → 스코프 탭에서 확인해보면 다음과 같이 전부 클로저로 캡처된 것을 볼 수 있다.

scope-dev-tools.webp

단계별 동작 살펴보기

javascript
// 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)

  1. create 함수가 호출되면서 createStoreImpl이 실행된다.
  2. 이때, useBearStore만을 위한 고유한 statelisteners클로저 스코프에 생성된다.
  3. state{ bears: 0, increase: [Function] } 으로 초기화된다. increase 함수는 클로저를 통해 setState 함수를 기억하고 있는다.
  4. useBearStore 라는 이름의 커스텀 훅이 반환된다. 이 훅은 setState, getState 등의 메서드도 함께 가지고 있는다.

2단계: 컴포넌트 렌더링 및 구독 (useBearStore)

  1. BearCounter 컴포넌트가 처음 렌더링될 때 useBearStore 훅이 실행된다.
  2. useSyncExternalStore가 호출되어, 1단계에서 만들어진 listeners 에 리렌더링을 유발하는 함수를 등록(subscribe)한다.
  3. getSnapshot 콜백 () => state.bears가 실행되어 초기값 0을 가져와 bears 변수에 할당하고, 화면에 "0 bears"를 그린다.

3단계: 상태 업데이트 (increase)

  1. 사용자가 버튼을 클릭하면 Controls 컴포넌트에서 가져온 increase 함수가 실행된다.
  2. increase 함수는 클로저로 기억하고 있던 setState를 호출한다.
  3. setStatestate{ bears: 1 }로 업데이트한다.
  4. setStatelisteners 을 순회하며 등록된 모든 함수를 호출한다.
  5. BearCounteruseSyncExternalStore가 등록한 '리렌더링 함수'가 호출된다.
  6. React는 BearCounter 컴포넌트를 리렌더링하고, getSnapshot을 다시 실행해 새로운 값 1을 가져와 화면을 업데이트한다.

마무리

Zustand는 클로저의 강력함과 실용성을 보여주는 완벽한 예시인듯 하다. 단 100줄의 코드 (타입 제거시 약 40줄)로 다음 목적을 달성했다.

  1. 캡슐화: 상태를 외부에서 직접 접근할 수 없도록 보호한다.
  2. 구독 패턴: SetuseSyncExternalStore를 통해 상태 변경을 효율적으로 전파하고, 필요한 컴포넌트만 정확히 리렌더링 한다.
  3. 단순성: 복잡한 리듀서, 액션 타입, 디스패처 없이 직관적인 API를 제공한다.

클로저라는 잘 와닿지 않을 수 있는 개념을 제대로 활용하면 강력한 추상화를 만들 수 있다는 점을 Zustand 분석을 통해 얻게 되었다. 이러한 패턴을 이해하면 더 나은 상태 관리 솔루션을 만들거나, 기존 라이브러리를 더 깊이 이해할 수 있을 것 같다.