Zustand의 동작 원리를 모르는 Chill guy일 때

드뮴·2025년 1월 27일
31

🐾 리액트

목록 보기
2/3
post-thumbnail

Zustand에서 상태를 가져올 때는 구조 분해 할당 방식과 selector를 통해 일부 상태만 구독하는 방식이 있다. 구조 분해 할당으로 값을 가져오면 어떤 상태가 변경되어도 리렌더링되는데, selector를 사용하면 구독하는 상태만 변경될 때 리렌더링된다. 어떻게 이게 가능한걸까?

Zustand의 사용법에 대해 간단히 정리하고, Zustand의 동작 원리에 대해 공부해보았다.


목차

Zustand란?
ㅤStore
ㅤ상태를 사용할 컴포넌트
ㅤ상태 업데이트는 어떻게 이루어질까?

Zustand 코드 살펴보기
ㅤ1. Store를 생성하는 create
ㅤ2. 상태 관리의 기본 매커니즘 createStore
ㅤ3. 리액트 컴포넌트에서 상태에 접근할 수 있게 해주는 useStore

원하는 값만 구독하는 selector
ㅤ1. 구조 분해 할당
ㅤ2. selector
ㅤ컴포넌트 리렌더링은 어떻게 가능할까?

다른 상태 관리와 무엇이 다를까?
ㅤRedux vs Zustand
ㅤContext API vs Zustand


Zustand란?

Zustand는 독일어로 상태를 의미하고, 리액트 애플리케이션을 위한 작고 빠른 상태 관리 라이브러리이다.
Redux, MobX와 같은 다른 상태 관리 라이브러리들보다 더 간단하고 직관적인 API를 제공한다.

예시를 통해 사용법을 알아보자.

Store

import create from 'zustand';

export const useCountStore = create((set) => {
	// 상태
	count: 0,
	
	// 액션
	increment: () => set((state) => ({ count: state.count + 1 })),
	decrement: () => set((state) => ({ count: state.count - 1 }))
}));
  • create로 스토어를 생성한다. 이 과정에서 상태와 액션을 포함하는 하나의 스토어 객체가 생성된다.
  • 내부적으로는 발행-구독(publish-subscribe) 패턴을 사용하여 상태 변화를 감지한다.
  • set 함수는 상태 변경을 담당한다. set 함수를 호출해서 새로운 상태를 계산한다.

상태를 사용할 컴포넌트

import { useCountStore } from './useCountStore';

function Counter() {
  const { count, increment, decrement } = useCountStore();
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
    </div>
  )
}
  • 컴포넌트에서는 count라는 상태와 count의 상태를 변경하는 액션인 increment, decrement를 구독한다.
  • 버튼을 누르면 액션이 발생하며, 이를 통해 count 상태 변경이 이루어진다.

Zustand는 필요한 상태만 구독해서 불필요한 리렌더링을 방지할 수 있다. 이 내용은 뒤에서 다룰 예정이다.


상태 업데이트는 어떻게 이루어질까?

  1. set 함수를 통해 상태를 변경한다. 스토어 객체 내에 액션을 정의해두었는데 사용하는 컴포넌트에서, 해당 액션을 호출하면 set 함수가 호출되고 새로운 상태가 계산된다.
  2. 이전 상태와 새로운 상태가 자동으로 병합된다.
  3. 상태가 변경되면 구독 중인 모든 컴포넌트에 변경 사실을 알린다.

그림을 보면 컴포넌트들이 상태를 구독하고, 스토어에 정의된 액션을 호출할 수 있다. 액션을 호출하면 액션은 set 함수를 호출해 상태를 업데이트하고, 상태가 업데이트되면 구독 중인 컴포넌트에게 알려준다.


Zustand 코드 살펴보기

create가 스토어를 생성하고 createStore에서는 상태 관리, useStore를 통해서 리액트 컴포넌트와 원활하게 통신한다.


1. Store를 생성하는 create

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 함수는 Zustand 스토어를 생성하는 시작점이다.

  • createState 함수를 받아서 스토어를 생성한다.
    • create((set) => { 상태 정의, 액션 정의 })로 사용하는데, (set) => { 상태 정의, 액션 정의 } 이 부분이 createState 함수인 것이다.
  • 이때 createState를 createStore로 전달한다.
    • createStore는 상태 관리에 필요한 기본 API들(setState, getState 등)을 생성한다.
  • Object.assign을 통해 useBoundStore에 createStore로 생성한 API 메소드를 추가한다.
  • create 함수는 useStore 훅을 반환하는 함수인 useBoundStore를 최종적으로 반환한다.

📌 create 함수가 반환하는 useBoundStore
useBoundStore는 컴포넌트에서 상태를 구독할 수 있는 훅이다. getState, setState 같은 스토어 API 메소드들을 함께 가지고 있다.


2. 상태 관리의 기본 매커니즘 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() // 상태 변화를 감지할 구독자를 저장하는 Set

  const setState: StoreApi<TState>['setState'] = (partial, replace) => {
    const nextState =
      typeof partial === 'function'
        ? (partial as (state: TState) => TState)(state)
        : partial
    if (!Object.is(nextState, state)) {
      const previousState = state
      state =
        (replace ?? (typeof nextState !== 'object' || nextState === null))
          ? (nextState as TState)
          : Object.assign({}, state, nextState)
      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)
    // Unsubscribe
    return () => listeners.delete(listener)
  }

  const api = { setState, getState, getInitialState, subscribe }
  const initialState = (state = createState(setState, getState, api))
  return api as any
}
  • state, listeners는 스토어의 기본 구조를 설정한다.
    • state는 실제 상태 데이터를 저장한다.
    • listeners는 상태 변화를 구독하는 함수의 집합이다.

📌 상태와 listeners는 createStore에 저장되고 클로저에 의해 값이 유지된다.

  • 상태는 모듈 레벨에서 단 하나만 존재한다.
  • 클로저를 통해 상태를 안전하게 보관한다.
  • setState, getState로만 상태에 접근할 수 있다.
  • 컴포넌트들은 useStore를 통해 상태를 구독하고 접근한다.

구독하는 subscribe는 언제 호출될까?
subscribe는 구독 함수로 실제로 구독은 리액트의 useSyncExternalStore를 통해 이루어진다.
예를 들어 const count = useCountStore(state => state.count)를 호출하면, useSyncExternalStore가 subscribe를 호출해서 해당 함수가 리스너를 등록한다. 구독할 때는 컴포넌트를 리렌더링할 함수를 리스너로 전달하고, 컴포넌트가 언마운트될 때 자동으로 구독 해제한다.

setState는 상태를 변경하는 함수이다.

const setState: StoreApi<TState>['setState'] = (partial, replace) => {
  // 새로운 상태 계산
  const nextState =
    typeof partial === 'function'
      ? (partial as (state: TState) => TState)(state)
      : partial
  // 상태가 실제로 변경되었는지 확인
  if (!Object.is(nextState, state)) {
    const previousState = state
    // 상태 업데이트
    state =
      (replace ?? (typeof nextState !== 'object' || nextState === null))
        ? (nextState as TState)
        : Object.assign({}, state, nextState)
    // 상태가 변경되면 모든 구독자에게 알림
    listeners.forEach((listener) => listener(state, previousState))
  }
}
  • 새로운 상태를 받아서 현재 상태와 비교하고, 변경이 있을 경우에만 업데이트를 수행한다.
  • Object.is를 사용하여 정확한 참조 비교를 수행하고, 상태가 변경되면 모든 리스너에게 새로운 상태와 이전 상태를 전달한다.

Object.is로 값을 비교하는 이유는 무엇일까?
Object.is로 값을 비교하면 정확한 값 비교가 가능하기 때문이다.
자바스크립트에서는 == 연산자와 === 연산자, Object.is로 값을 비교할 수 있다.

  • ==는 타입 변환을 하지만 Object.is는 타입 변환을 하지 않는다.
  • Object.is는 특수한 숫자 값은 수학적으로 올바르게 처리한다.
  • === 연산자가 0과 -0의 차이를 인식하지 못한다면 Object.is는 이 차이를 인식한다.
  • === 연산자로는 NaN를 제대로 비교할 수 없는데 Object.is는 올바르게 비교한다.

3. 리액트 컴포넌트에서 상태에 접근할 수 있게 해주는 useStore

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
}
  • 외부 상태를 리액트 컴포넌트와 동기화한다.
  • selector 함수를 통해 전체 상태에서 필요한 부분만 선택적으로 구독할 수 있게 하고, 이를 통해 불필요한 리렌더링을 방지한다.

원하는 값만 구독하는 selector

밑의 코드는 create 함수를 호출하면 반환하는 useBoundStore이다.
useBoundStore는 컴포넌트에서 사용하게 될 커스텀 훅으로, selector를 선택적으로 받을 수 있으며, useStore를 호출하며 상태 값을 반환한다.

const useBoundStore: any = (selector?: any) => useStore(api, selector)
const useCountStore = create((set) => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 }))
}));

useCountStore 사용할 때는 2가지 방법이 있다.

  • const { count, increment } = useCountStore();
  • const count = useCountStore(state => state.count );
  • 1번째 방법은 전체 객체 상태가 저장되고, 두번째 방식은 count 값만 저장된다.

첫번째 방법인 경우에는 selector를 전달하지 않기 때문에 기본적으로 selector의 매개변수는 state => state인 기본 함수가 전달된다.

  • useStore에서는 slice를 반환하고, slice에는 selector가 반환한 값이 담긴다. 이 경우에는 slice에는 전체 상태 객체가 담긴다.
  • slice = { count: 0, increment: () => {} };

두번째 방법은 selector를 전달하기 때문에 state => state.count가 전달된다.

  • slice는 selector가 반환한 값이 담기므로 slice에는 count 값만이 담긴다.

selector를 전달하지 않고 useCountStore를 사용하는 경우에는 전체 상태가 slice에 담긴다. 이때는 구조 분해 할당으로 원하는 값을 가져올 수 있다.
반면 selector를 전달하면 selector가 반환하는 특정 값만 slice에 담기게 되고, 해당 값만 구독하게 된다.


1. 구조 분해 할당

selector를 전달하지 않고 구조 분해 할당으로 스토어에서 값을 가져오게 되면 어떻게 될까?

// 컴포넌트 코드
const { count } = useCountStore();

// 내부 코드
const slice = useSyncExternalStore(
  subscribe,  // 구독 설정
  () => selector(getState()),
  () => selector(getInitialState())
);
  • 구조 분해 할당으로 스토어의 count 상태를 가져온다.
  • 이렇게 되면 useCountStore의 매개변수로 selector를 전달해주지 않는다.
    • 전달하지 않으면 state => state가 selector의 기본 값이므로 전체 상태를 가져온다.
  • 그렇다면 slice는 최종적으로 전체 상태 객체를 받게된다.

상태가 변경될 때 다음과 같이 변한다.

// 초기 상태
const initialState = {
  count: 0,
  ...
};

// count만 변경되고 나머지는 동일
const newState = {
  count: 1,
  ...
};
  • 객체에서 count만 변경되었지만, 객체는 참조 타입이기 때문에 count만 변경되어도 새로운 객체가 생성된다.
  • 새로운 객체는 이전 객체와 다른 참조를 가지기 때문에 Object.is로 비교하면 항상 다르다고 판단한다.
    • Object.is(previousSlice, newSlice)로 비교한다.
  • 따라서 바뀌지 않은 상태도 바뀐 것으로 판단하기 때문에 count가 아닌 다른 값이 변경되어도 리렌더링이 발생한다.

2. selector

selector를 사용하면 구조 분해 할당으로 사용할 때와 무엇이 다를까?

// 컴포넌트 코드
const count = useStore(state => state.count);

// 내부 코드
const selector = state => state.count;

const slice = useSyncExternalStore(
  subscribe,
  () => selector(getState()), 
  () => selector(getInitialState())
);
  • selector를 state => state.count로 설정했기 때문에 count 값만 가져오게 된다.
  • 최종적으로 slice에는 count 값이 저장된다.

상태가 변경될 때는 다음과 같이 변경된다.

// 초기 상태
const initialState = {
  count: 0,
  name: "채멈"
};

// case 1: count가 변경된 경우
const newState1 = {
  count: 1,    // 변경됨
  name: "채멈"
};

// case 2: count가 아닌 다른 변수가 변경된 경우
const newState2 = {
  count: 0,   
  name: "드뮴" 
};
  • selector는 state => state.count이고, 상태는 count, name이 있을 때 초기 상태 count는 0, name은 채멈이다.
  • case1은 name은 그대로이고 count만 변경된 상황이다.
    • selector(newState1)은 1을 반환한다.
    • 이때 count만 구독하므로 이전 값 0에서 반환한 새로운 값인 1이 다르므로 리렌더링이 발생한다.
  • case2는 count는 그대로이고 name만 변경된 상황이다.
    • selector(newState2)도 0을 반환한다.
    • count를 구독하기 때문에 count 값만 반환하고 그렇다면 이전 값 0과 반환한 새로운 값은 0이 같기 때문에 리렌더링이 발생하지 않는다.

구조 분해 할당은 전체 상태 객체를 반환하여 객체 참조를 비교하기 때문에 하나의 상태라도 변경되면, 새로운 객체를 반환하기 때문에 참조가 달라진다. 따라서 이렇게 구독하게 되면 하나의 상태라도 변경되면 모든 구독 컴포넌트는 상태가 변경되었다고 판단해 컴포넌트가 리렌더링된다.

selector를 이용하면 특정 값만 추출해 값 자체를 비교하기 때문에 구독하는 값만 변경되었는지 확인한다. 따라서 특정 값만 구독하는 컴포넌트들은 구독하는 값 외의 다른 값이 변경되더라도 리렌더링되지 않고, 특정 값을 구독한다면 해당 값이 변경될 때만 리렌더링이 발생한다.


컴포넌트 리렌더링은 어떻게 가능할까?

setState 함수에서 listeners.forEach((listener) => listener(state, previousState)); 코드는 상태가 변경되었을 때 상태를 구독하는 컴포넌트에게 알려주는 코드였다.

  • 위 코드에서 listener 함수는 상태 변경을 감지하고 컴포넌트를 업데이트하기 위해 만들어지는 특별한 함수다.
    • 이 함수는 useSyncExternalStore 내부에서 자동으로 생성된다.

useSyncExternalStore 내부 동작을 간단하게 표현한 코드

function useSyncExternalStore(subscribe, getSnapshot) {
  // 컴포넌트를 강제로 리렌더링하기 위한 함수
  const [_, forceUpdate] = useState({});

  useEffect(() => {
    // listener 함수가 생성
    function listener() {
      // 1. 새로운 상태 가져오기
      const nextSnapshot = getSnapshot();
      
      // 2. 이전 상태와 비교
      if (!Object.is(currentSnapshot, nextSnapshot)) {
        // 3. 변경이 있다면 컴포넌트 리렌더링
        forceUpdate({});
      }
    }

    // listener를 구독 시스템에 등록
    return subscribe(listener);
  }, []);
}

listener 함수는 상태 변경을 감지하고 필요한 경우 컴포넌트를 리렌더링하도록 만들어진 특별한 함수다. useSyncExternalStore에 의해 자동으로 생성되고 관리된다.

  • 상태를 구독하는 컴포넌트가 마운트되면서 스토어 훅이 실행되면 listener 함수가 생성된다.
  • 생성된 listener 함수는 스토어의 listeners Set에 추가된다.
  • 상태가 변경되면 새로운 상태가 계산되고, listeners.forEach가 실행되어 등록된 모든 listener 함수가 호출된다.

다른 상태 관리와 무엇이 다를까?

Redux vs Zustand

Redux

// 1. 액션 타입
const INCREMENT = 'counter/increment';

// 2. 액션 생성자
const increment = () => ({ type: INCREMENT });

// 3. 리듀서
const counterReducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case INCREMENT:
      return { ...state, count: state.count + 1 };
    default:
      return state;
  }
};

// 4. 스토어 생성 및 Provider 설정
const store = createStore(counterReducer);

// 컴포넌트에서 사용
function Counter() {
  const count = useSelector(state => state.count);
  const dispatch = useDispatch();
  
  return (
    <div>
      <p>{count}</p>
      {/* dispatch를 통해 액션을 전달 */}
      <button onClick={() => dispatch(increment())}>증가</button>
    </div>
  );
}
  • Redux는 상태 관리를 위해 액션 정의, 리듀서 생성, 스토어 생성, Provider 설정이 필요하다.
    • 상태를 업데이트하기 위해서는 액션을 통해서만 변경이 가능하다.
    • dispatch 함수를 통해 액션을 전달한다.
    • 액션을 전달받은 리듀서는 스토어에 접근해 상태를 업데이트한다.
    • 해당 상태를 구독하는 곳에 전달해서 알려준다.

Zustand

const useCountStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 }))
}));

// 컴포넌트에서 사용
function Counter() {
  const { count, increment } = useCountStore();
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>증가</button>
    </div>
  );
}
  • Zustand는 create를 통해 상태 저장소를 생성하고, 이 저장소에 상태와 상태를 변경하는 함수를 정의한다.
    • 사용자는 상태와 상태 변경 함수를 직접 호출해서 사용한다.

Redux와 다르게 Zustand는 상태 변경을 직접한다. Redux는 Action → Dispatch → Reducer라는 과정을 거쳐야하지만, Zustand는 상태 변경 함수를 직접 호출해서 바로 상태 업데이트를 한다.

직접적으로 관리하기 때문에 코드가 더 간단하고 직관적이다. 그러나 상태 변경 추적과 디버깅이 Redux보다는 덜 체계적일 수 있다.


Context API vs Zustand

Context API

// 1. Context 생성
const CountContext = createContext();

// 2. Provider 컴포넌트 생성
const CountProvider = ({ children }) => {
  const [count, setCount] = useState(0);
  
  const increment = () => {
    setCount(prevCount => prevCount + 1);
  };
  
  return (
    <CountContext.Provider value={{ count, increment }}>
      {children}
    </CountContext.Provider>
  );
};

// 3. 사용할 때마다 Provider로 감싸줌
function App() {
  return (
    <CountProvider>
      <Component />
    </CountProvider>
  );
}

// 컴포넌트에서 사용
function Counter() {
  const { count, increment } = useContext(CountContext);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>증가</button>
    </div>
  );
}
  • 리액트의 내장 기능으로 추가 라이브러리 설치가 필요없다.
  • Context API는 Context 생성, Provider 컴포넌트 정의, Provider로 감싸기 등의 여러 단계가 필요하다.
    • Provider의 하위 컴포넌트에서만 상태에 접근할 수 있다.
    • 여러 개의 Context를 사용하면 Provider의 중첩이 발생해서 코드가 복잡해질 수 있다.
    • 상태가 객체라면 useMemo와 같은 기능을 통해 value 객체를 메모이제이션하거나 Context를 분리해서 리렌더링 최적화를 해줄 수 있다.

Zustand

const useCountStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 }))
}));

// 컴포넌트에서 사용
function Counter() {
  const { count, increment } = useCountStore();
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>증가</button>
    </div>
  );
}
  • Zustand는 어디서든 useCountStore를 호출하면 상태에 접근할 수 있다.
    • create 함수 하나로 모든 것을 설정할 수 있고, 필요한 상태만 구독할 수 있다.

Context API는 주로 테마나 인증 상태와 같이 애플리케이션 전반에 걸쳐 자주 변경되지 않는 데이터를 공유할 때 적합하다.


☑️ 요약 정리

Zustand

Zustand는 리액트를 위한 작고 빠른 상태 관리 라이브러리로, 다른 라이브러리들보다 더 간단하고 직관적인 API를 제공한다.

핵심 구성 요소

  • create: 스토어를 생성하는 함수
  • createStore: 클로저로 상태 관리
  • useStore: 리액트 컴포넌트와 원활하게 통신

상태 구독 방식

  1. 구조 분해 할당
    • 전체 상태를 구독한다.
    • 어떤 상태가 변경되어도 리렌더링이 발생한다.
    • 이전 상태와 새로운 상태를 모두 객체로 관리해 하나라도 변경되면 새로운 객체를 생성해서 새로운 참조가 생기고, 이로 인해 참조가 변경되어 상태 변경으로 인식한다.
  2. selector
    • 원하는 상태만 구독한다.
    • 구독하는 상태가 변경될 때만 리렌더링 발생한다.
    • 이전 상태와 새로운 상태를 모두 구독하는 값으로 저장하고 값만 비교하기 때문에 구독하는 값이 변경되지 않으면 변경되었다고 인식하지 않는다.

상태 업데이트

  • 상태 업데이트는 set 함수를 통해 이루어지고, 이 과정에서 Object.is를 사용하여 상태 변경을 감지한다.
  • 상태가 변경되면 구독 중인 컴포넌트에게 알림이 전달되어 리렌더링이 발생한다.

다른 상태 관리 라이브러리와의 차이

  • Redux vs Zustand
    • Redux는 액션 타입, 액션 생성자, 리듀서와 같은 복잡한 개념들이 필요하다.
    • Zustand는 위 개념이 없이 바로 스토어 객체에 정의해둔 액션을 호출해서 상태를 변경한다.
  • Context API vs Zustand
    • Context API는 Provider를 만들어 감싸줘야하는 불편함이 있지만, 리액트 내장 기능이다.
    • Zustand는 Provider 없이 바로 훅으로 상태를 사용할 수 있다.

참고 자료

profile
안녕하세오

10개의 댓글

comment-user-thumbnail
2025년 1월 27일

Chill한 곰돌..

1개의 답글
comment-user-thumbnail
2025년 1월 28일

칠 하군..

1개의 답글
comment-user-thumbnail
2025년 2월 2일

Chill 하게 정리 잘하시네요... 고생하셨습니당
어떻게 그렇게 Chill할 수가..

1개의 답글
comment-user-thumbnail
2025년 2월 3일

redux vs. zustand에서 redux 그림 직접 만드신건가요 ?? 드뮴밈 금손이시네요 !

1개의 답글
comment-user-thumbnail
2025년 2월 8일

Zustand는 독일어로 상태를 의미하며, 리액트 애플리케이션을 위한 작고 빠른 상태 관리 라이브러리입니다. Redux, MobX와 같은 다른 상태 관리 라이브러리들보다 더 간단하고 직관적인 https://www.cashnetusaus.com API를 제공합니다.

답글 달기
comment-user-thumbnail
2025년 2월 8일

Zustand는 독일어로 상태를 의미하며, 리액트 애플리케이션을 위한 작고 빠른 상태 관리 라이브러리입니다. Redux, MobX와 같은 다른 상태 관리 라이브러리들보다 더 간단하고 직관적인 https://www.cashnetusaus.com API를 제공합니다.

답글 달기

관련 채용 정보