리액트 전역 상태 관리의 문제와 해결책: 『리액트 훅을 활용한 마이크로 상태 관리』를 통해 배우다

soleil_lucy·2025년 4월 22일
3
post-thumbnail

리액트로 애플리케이션을 만들다 보면, 어느 순간 고민하게 되는 주제가 있습니다.

“상태 관리는 어떤 라이브러리를 써야 하지?”

리액트에서 사용할 수 있는 상태 관리 라이브러리는 정말 많습니다. 책 『리액트 훅을 활용한 마이크로 상태 관리』 에서는 Redux, Zustand, Recoil, Jotai, MobX, Valtio까지 총 6개의 상태 관리 라이브러리를 소개합니다. 이 중 어떤 것을 선택해야 할지, 어떤 기준으로 선택할지 고민하게 됩니다.

리액트를 공부하면서 자연스럽게 상태 관리라는 개념에 관심이 생겼고,

“왜 이렇게 다양한 샹태 관리 도구가 존재할까?”

“다른 개발자들은 어떤 기준으로 선택할까?”

라는 궁금증이 생겼습니다.

리액트 훅을 활용한 마이크로 상태 관리책은 그런 고민에 대한 해답을 줄 수 있을 것 같아 읽게 되었습니다. 책을 읽고 상태 관리의 기본 개념부터 전역 상태를 관리하면서 마주치는 문제, 그 문제를 해결하는 방법과 라이브러리 선택 기준까지 배울 수 있었습니다.

리액트 훅을 활용한 마이크로 상태 관리 책 커버

이 글은 책을 읽으며 정리한 내용을 바탕으로 다음과 같은 내용을 담고 있습니다:

  • 상태란 무엇인가? (지역 상태, 전역 상태, 마이크로 상태)
  • 상태 관리가 왜 중요한가?
  • 전역 상태를 만들 때 발생하는 문제들
  • 그 문제를 어떻게 해결할 수 있을까?
  • 상태 관리 라이브러리를 선택할 때 고려할 점은?

상태란 무엇일까?

리액트에서 상태(state)는 UI(사용자 인터페이스)가 보여줘야 할 데이터를 말합니다.

이 데이터는 시간이 지나면서 바뀔 수 있고, 리액트는 이 상태가 변할 때마다 자동으로 화면(UI)을 다시 그려줍니다.

“리액트의 상태는 컴포넌트 중심의 구조에 맞게 설계되어 있어, 재사용성과 독립성을 높이는 데 도움이 됩니다.”

상태는 사용하는 범위나 목적에 따라 여러 종류로 나눌 수 있습니다. 책에서는 이러한 상태를 구분하기 위해 지역 상태, 전역 상태, 컴포넌트 상태, 모듈 상태, 마이크로 상태 등과 같은 용어를 사용해 설명합니다.

지역 상태 (local state)

리액트 컴포넌트 내부에서 선언되고, 해당 컴포넌트 또는그 하위 컴포넌트 트리에서만 사용 되는 상태입니다. useState 또는 useReducer 훅을 사용해 만듭니다.

언제 쓰나요?

  • 그 상태가 해당 컴포넌트 안에서만 필요할 때
  • 컴포넌트의 UI를 직접적으로 표현하는 데 사용될 때(Ex. 모달 알림 여부, input 입력값 등)
  • 상태의 지역성을 보장하고 싶을 때, 즉 외부에서 해당 상태를 알거나 바꾸지 않아도 될 때

    지역성? 상태, 로직, 스타일 등 관련된 코드가 그것을 사용하는 위치에 최대한 가까이 존재해야 한다는 개념

예시

버튼을 클릭할 때 숫자를 증가시키는 count

const CountComponent = () => {
	const [count, setCount] = useState(0); // 지역 상태
	
	return (
		<div>
			<span>{count}</span>
			<button onClick={() => setCount((prev) => prev + 1)}>증가</button>
		</div>
	);
};

전역 상태 (global state)

여러 컴포넌트에서 공통으로 사용하는 상태입니다. 특히 서로 멀리 떨어진 컴포넌트들 간에 정보가 공유되어야 할 때 사용합니다.

언제 쓰나요?

  • 여러 컴포넌트에서 상태를 참조하거나 갱신해야 할 때
  • 컴포넌트 외부(다른 모듈, 훅 등)에서 해당 상태를 제어할 때
  • 지역성을 제공하고 싶지 않을 때, 즉 상태가 특정 컴포넌트에 국한되지 않아야 할 때

예시

로그인 여부를 여러 컴포넌트에서 확인해야 할 때

컴포넌트 트리에서 컴포넌트 사용하는 예시 그림

컴포넌트 상태 (Component State)

UI를 구성하는 개별 컴포넌트에 필요한 상태를 말합니다. 지역 상태일 수도 있고, 필요에 따라 전역 상태로 만들어 관리할 수도 있습니다.

예시

  • 모달이 열렸는지 닫혔는지 정보를 저장할 때
  • 드롭다운이 펼쳐졌는지 여부를 저장할 때

모듈 상태 (Module State)

컴포넌트 외부에서 파일 단위로 관리하는 상태입니다. JavaScript 모듈의 변수처럼 하나의 파일(모듈) 안에서만 접근 가능한 상태를 말합니다.

직접 상태를 공유하지 않고, 함수를 통해 가져다 쓰는 방식을 사용합니다.

// store.js
let count = 0;

export const getCount = () => count;
export const setCount = (next) => {count = next;};

마이크로 상태 (Micro State)

마이크로 상태는 작은 컴포넌트에서만 사용하는 간단한 상태를, 해당 컴포넌트 내부에서 직접 관리하는 방식입니다.

모든 상태를 굳이 전역으로 관리할 필요는 없습니다. 작고 목적이 뚜렷한 상태는 컴포넌트 내 부에서 다루는 것이 더 효율적이며, 불필요한 렌더링을 줄이고 상태를 더 깔끔하게 관리할 수 있게 해줍니다.

예시

  • 알림 배너가 잠깐 뜨는 상태
  • 아코디언 메뉴가 펼쳐진 상태
  • 툴팁이 보이는지 여부
  • etc…

전역 상태로 관리하면 오히려 복잡해지고, 불필요한 렌더링이 생길 수 있습니다. 마이크로 상태로 관리한다면 불필요한 렌더링을 줄이고, 상태가 필요한 컴포넌트만 그 상태를 알 수 있습니다.

상태는 어떻게 공유할 수 있을까?

상태를 여러 컴포넌트에서 함께 사용하려면, 상태를 공유할 수 있는 방법이 필요합니다. 책에서는 세 가지 주요 방법을 소개합니다.

  1. 리액트 컨텍스트를 이용한 공유
  2. 모듈 상태와 구독을 이용한 공유
  3. 컨텍스트와 구독 패턴을 함께 사용하는 공유

리액트 컨텍스트를 이용한 컴포넌트 상태 공유

리액트에서 상태를 여러 컴포넌트에서 공유하려면 보통 props를 사용해 값을 전달합니다. 그런데 컴포넌트가 깊게 중첩되어 있을 경우, 값을 일일이 전달해야 하는 prop drilling 문제가 발생할 수 있습니다. 이 문제를 해결하기 위해 리액트 16.3부터 컨텍스트(Context) 기능이 도입되었습니다.

주의: 컨텍스트는 전역 상태 관리용으로 설계된 도구가 아니므로, 값이 변경될 때 모든 하위 컴포넌트가 리렌더링됩니다. 따라서 규모가 커지면 불필요한 렌더링이 발생할 수 있습니다.

컨텍스트는 상위 컴포넌트에서 하위 컴포넌트로 상태를 쉽게 전달할 수 있게 도와줍니다. 하지만 값이 바뀔 때마다 모든 하위 컴포넌트가 다시 렌더링되기 때문에, 대규모 애플리케이션에서는 성능에 영향을 줄 수 있습니다.

Prop Drilling vs Context

Prop Drilling은 상위 컴포넌트에서 하위 컴포넌트로 props를 계속해서 전달하는 방식입니다.

Prop drilling

Context는 중간 컴포넌트를 거치지 않고, 하위 컴포넌트에서 직접 값을 꺼내 쓸 수 있게 해줍니다.

Using context is distant children

컨텍스트 값의 리렌더링 전파 방식

React에서는 Context를 사용하면 여러 컴포넌트가 같은 데이터를 쉽게 공유할 수 있습니다. 이때 데이터를 넘겨주는 쪽을 Provider(공급자), 데이터를 사용하는 쪽을 Consumer(소비자)라고 부릅니다.

컨텍스트 공급자(Provider)의 값이 변경되면 해당 값을 사용하는 모든 소비자(Consumer)는 리렌더링됩니다.

만약 컨텍스트 값이 객체(const CountContext = createContext({count1: 0, count2: 0}))인 경우 객체 안의 일부 값만 사용하는 소비자도 전체 리렌더링 됩니다. 그 이유는 객체 전체가 매번 새로 만들어지기 때문에 React의 입장에서는 값이 바뀌었다고 판단하기 때문입니다.
React는 객체의 내용이 아니라, 참조가 바뀌었는지를 기준으로 판단합니다.

아래 그림에서 count1만 사용하는 컴포넌트에서 값을 바꿨을 때, count1을 사용하지 않는 count2만 사용하는 컴포넌트도 불필요하게 리렌더링됩니다.

컨텍스트 사용시 불필요한 리렌더링을 표현하는 그림

모듈 상태와 구독을 이용한 공유

모듈 상태는 자바스크립트 파일 안에 정의된 변수를 다른 컴포넌트에서 import해서 사용하는 방식입니다. 이 상태는 앱 전체에서 하나만 존재하고, 마치 싱글턴처럼 동작합니다.

하지만 이 방식은 상태가 바뀌어도 React 컴포넌트가 자동으로 리렌더링되지 않는 문제가 있습니다. React는 내부적으로 useStateuseReducer와 같은 훅을 통해 상태 변화를 감지하는데, 단순한 변수 값의 변경은 React가 "리렌더링 해야겠다!"고 판단하지 않기 때문입니다.

구독 패턴으로 리렌더링 유도하기

이 문제를 해결하려면, 구독 패턴을 사용해야 합니다. 구독 패턴은 상태가 바뀔 때, 그 상태를 구독한 컴포넌트에게 알려주는 방식입니다. 이를 통해, 상태가 변경되면 구독한 컴포넌트만 리렌더링하도록 할 수 있습니다.

  • 구독 패턴의 핵심 아이디어
    1. 상태가 변경되면, 상태를 구독하고 있는 콜백 함수들을 실행합니다.
    2. 이 콜백 함수 안에서는 useState를 호출하거나, React가 인식할 수 있는 방식으로 리렌더링을 유도합니다.

이 방법은 상태를 컴포넌트 외부의 모듈에 보관하고, 필요할 때 구독(Subscription) 하도록 하는 방식입니다. 구독 방식으로 상태가 변경될 때, 구독한 컴포넌트만 리렌더링되므로 불필요한 리렌더링을 피할 수 있습니다. 이는 성능 최적화가 필요한 경우 매우 유용한 방법입니다.

예제로 이해하는 모듈 상태 + 구독 패턴

아래 코드는 책에 나온 예제 코드에서 useSyncExternalStore 훅을 사용한 코드로 수정한 코드입니다.

  1. 상태 저장소 만들기

    상태를 저장하고, 상태가 바뀔 때마다 subscribers 배열에 등록된 콜백 함수들을 실행하여 리렌더링을 유도합니다.

    // store.js
    export const createStore = (initialState) => {
      let state = initialState; // 모듈 상태
      
      const subscribers = new Set();
    
      const getState = () => state;
    
      const setState = (next) => {
        state = typeof next === 'function' ? next(state) : next;
        subscribers.forEach((callback) => callback());
      };
    
      const subscribe = (callback) => {
        subscribers.add(callback);
        return () => subscribers.delete(callback);
      };
    
      return { getState, setState, subscribe };
    };
    
  2. 리액트 컴포넌트에서 사용할 훅 만들기

    useSyncExternalStore 훅을 사용하여, 상태를 구독하고 변경 사항을 안전하게 받아올 수 있습니다.

    // useStoreSelector.js
    import { useSyncExternalStore } from 'react';
    
    const useStoreSelector = (store, selector) => {
      const subscribe = store.subscribe;
      const getSnapshot = () => selector(store.getState());
    
      return useSyncExternalStore(subscribe, getSnapshot);
    };
    
  3. 컴포넌트에서 사용하기

    Component1count1만, Component2count2만 사용하도록 하여, 각 컴포넌트가 필요한 상태만 리렌더링하도록 할 수 있습니다.

    // App.tsx
    const Component1 = () => {
      const count1 = useStoreSelector(
        store,
        (state) => state.count1
      );
    
      const inc = () => {
        store.setState((prev) => ({
          ...prev,
          count1: prev.count1 + 1,
        }));
      };
    
      return (
        <div>
          count1: {count1} <button onClick={inc}>+1</button>
        </div>
      );
    };
    
    const Component2 = () => {
      const count2 = useStoreSelector(store, (state) => state.count2);
    
      const inc = () => {
        store.setState((prev) => ({
          ...prev,
          count2: prev.count2 + 1,
        }));
      };
    
      return (
        <div>
          count2: {count2} <button onClick={inc}>+1</button>
        </div>
      );
    };
    

컨텍스트와 구독 패턴을 이용한 공유

리액트에서 상태를 전역으로 관리하려면 보통 Context API를 사용합니다. 하지만 Context API를 사용할 때 주의할 점은, 상태가 변경되면 그 상태를 사용하는 모든 하위 컴포넌트가 리렌더링된다는 것입니다. 이 문제를 해결하기 위해 구독 패턴을 적용할 수 있습니다.

컨텍스트 + 구독 패턴으로 리렌더링 최적화하는 방식

이 방식은 두 가지 주요 개념을 결합한 것입니다:

  1. Context는 상태를 앱 내에서 전달하는 역할을 합니다.
  2. 구독 패턴을 사용하여 컴포넌트는 필요한 상태만 구독하고, 상태가 바뀔 때 해당 상태를 구독하는 컴포넌트만 리렌더링됩니다.

이 방법을 사용하면 불필요한 리렌더링을 피할 수 있어 성능을 최적화할 수 있습니다. 아래에서 어떻게 작동하는지 예제를 통해 설명하겠습니다.

예제로 이해하는 컨텍스트 + 구독 패턴

전체 예제 코드 보러가기

  1. 상태를 관리할 store 만들기

    store는 상태와 상태를 변경하는 메서드, 그리고 상태가 변경될 때 구독자에게 알리는 메서드를 가지고 있습니다.

    type Store<T> = {
      getState: () => T;
      setState: (action: T | ((prev: T) => T)) => void;
      subscribe: (callback: () => void) => () => void;
    };
    
    const createStore = <T extends unknown>(initialState: T): Store<T> => {
      let state = initialState; // 초기 상태
      
      const callbacks = new Set<() => void>(); // 상태 변경을 구독하는 콜백 모음
      
      const getState = () => state; // 상태 값 가져오기
      
      const setState = (nextState: T | ((prev: T) => T)) => {
        state =
          typeof nextState === "function"
            ? (nextState as (prev: T) => T)(state)
            : nextState;
        callbacks.forEach((callback) => callback()); // 상태가 변경되면 구독자에게 알리기
      };
      
      const subscribe = (callback: () => void) => {
        callbacks.add(callback); // 구독 추가
        return () => {
          callbacks.delete(callback); // 구독 취소
        };
      };
      
      return { getState, setState, subscribe };
    };
  2. 리액트에서 상태를 공유할 Context 만들기

    Contextstore를 하위 컴포넌트에 전달하는 역할을 합니다. store를 한 번만 만들고, 여러 컴포넌트에서 공유할 수 있게 해줍니다.

    const StoreContext = createContext<Store<State>>(
      createStore<State>({ count: 0, text: "hello" })
    );
    
    const StoreProvider = ({
      initialState,
      children,
    }: {
      initialState: State;
      children: ReactNode;
    }) => {
      const storeRef = useRef<Store<State>>();
      if (!storeRef.current) {
        storeRef.current = createStore(initialState); // store 한 번만 생성
      }
      return (
        <StoreContext.Provider value={storeRef.current}>
          {children}
        </StoreContext.Provider>
      );
    };
  3. 구독할 상태를 선택하는 커스텀 훅 만들기

    컴포넌트가 필요한 상태만 구독할 수 있도록, useSelector 훅을 만들어 상태를 선택하고 구독하도록 합니다. useSubscription 훅을 사용하여 상태의 변경을 감지합니다.

    const useSelector = <S extends unknown>(selector: (state: State) => S) => {
      const store = useContext(StoreContext);
      return useSubscription(
        useMemo(
          () => ({
            getCurrentValue: () => selector(store.getState()),
            subscribe: store.subscribe,
          }),
          [store, selector]
        )
      );
    };
  4. 상태 변경하는 useSetState 훅 만들기

    컴포넌트에서 상태를 변경할 수 있도록 useSetState 훅을 제공합니다. 이 훅을 통해 상태를 업데이트하면 구독하고 있는 컴포넌트만 리렌더링됩니다.

    const useSetState = () => {
      const store = useContext(StoreContext);
      return store.setState;
    };
  5. 컴포넌트에서 상태 사용하기

    useSelector를 사용하여 상태의 일부만 구독하고, useSetState로 상태를 변경합니다. Componentcount만 구독하고, 그 값이 변경될 때만 리렌더링됩니다.

    const selectCount = (state: State) => state.count;
    
    const Component = () => {
      const count = useSelector(selectCount);
      const setState = useSetState();
      const inc = () => {
        setState((prev) => ({
          ...prev,
          count: prev.count + 1,
        }));
      };
      return (
        <div>
          count: {count} <button onClick={inc}>+1</button>
        </div>
      );
    };

컨텍스트와 구독 패턴을 이용한 리렌더링 최적화 이점

이 방법은 구독 패턴을 적용하여, 상태를 구독하는 컴포넌트만 리렌더링되도록 합니다. 이를 통해 불필요한 리렌더링을 피할 수 있고, 성능을 최적화할 수 있습니다. 상태를 공유하는 많은 컴포넌트가 있을 때도 성능에 부담을 줄 수 있는데, 이 방식은 그 부담을 줄여줍니다.

  • 요약
    • Context는 상태를 전달하는 데 사용됩니다.
    • 구독 패턴을 적용하여 리렌더링을 최적화합니다.
    • 이 방식을 사용하면 불필요한 리렌더링을 피하고 성능을 최적화할 수 있습니다.

전역 상태 관리를 하면서 마주치는 문제들

리액트는 컴포넌트 중심으로 설계된 라이브러리입니다. 하지만 앱이 커지면서 여러 컴포넌트가 같은 데이터를 공유해야 할 일이 많아집니다. 이때 사용하는 것이 전역 상태 관리입니다.

전역 상태는 여러 컴포넌트가 데이터를 쉽게 공유할 수 있게 해주지만, 잘못 관리하면 성능이 떨어지거나 유지보수가 어려워질 수 있습니다.

전역 상태 관리가 어려운 이유

불필요한 리렌더링

전역 상태에는 다양한 데이터가 들어 있습니다. 하지만 모든 컴포넌트가 전역 상태의 모든 데이터를 다 사용할 필요는 없습니다. 예를 들어, 어떤 컴포넌트는 전역 상태 중 일부 데이터만 필요할 수 있습니다.

그런데 전역 상태의 데이터가 조금만 바뀌어도, 그 상태를 사용하는 모든 컴포넌트가 다시 렌더링될 수 있습니다. 이렇게 불필요하게 여러 컴포넌트가 리렌더링되면, 앱의 성능이 떨어질 수 있습니다.

상태 변경을 리액트가 감지하지 못하는 경우

리액트는 상태가 변경될 때 화면을 다시 그려야 하는데, 상태 변경을 리액트가 감지하지 못하면 화면이 바뀌지 않습니다.

예를 들어, 상태를 직접 수정하거나 불변성을 지키지 않으면 리액트가 이를 감지하지 못할 수 있습니다. 이로 인해 상태가 바뀌었음에도 불구하고 화면이 업데이트되지 않아 사용자 경험에 문제가 생길 수 있습니다.

전역 상태 최적화 방법 3가지

리렌더링 최적화의 핵심 질문은 아래와 같습니다:

“이 컴포넌트는 상태의 어떤 부분을 쓰고 있는가?”

선택자 함수 사용

상태 중에서 필요한 값만 선택해서 사용합니다.

  • 이 방법에서는 상태가 바뀌면 필요한 부분만 리렌더링됩니다.
  • 개발자가 직접 어떤 값을 사용할지 결정해야 합니다.

이 방식은 ReduxZustand 같은 라이브러리에서 자주 사용됩니다.

const value = useSelector((state) => state.b.c); // b.c 값만 구독

속성 접근 감지 (Proxy 기반)

컴포넌트가 실제로 사용한 값만 자동으로 추적합니다.

  • 읽지 않은 값이 바뀌어도 리렌더링되지 않아서, 자동으로 최적화됩니다.
  • 값이 바뀔 때 리렌더링을 해야 할지를 라이브러리가 알아서 결정해 줍니다.

이 방법은 ValtioProxy 기반 상태 라이브러리에서 사용됩니다.

const Component = () => {
	const tracked = useTrackedState(); // 자동으로 추적된 값 사용
	
	return <>{tracked.b.c}</>; // b.c 만 리렌더링
};

아톰 단위 구독

상태를 아주 작은 단위인 아톰(atom) 으로 나눠서 구독합니다.

  • 필요한 값만 리렌더링되므로 성능이 최적화됩니다.
  • 다른 상태를 사용할 때 자동으로 의존성을 추적해 줍니다.

이 방법은 JotaiRecoil과 같은 라이브러리에서 사용됩니다.

const countAtom = atom(0); // 상태 아톰 정의
const count = useAtom(countAtom); // count 아톰 구독

// 파생 아톰 얘시
// 다른 아톰들을 결합
const sumAtom = atom((get) => get(aAtom) + get(bAtom));

전역 상태 관리 라이브러리 소개

Zustand와 Jotai, Valtio라는 세 가지 전역 상태 라이브러리는 모두 마이크로 상태 관리에 적합한 기본 기능을 제공하지만 코딩 스타일과 렌더링 최적화에 대한 접근 방식이 다릅니다.

세 가지 전역 상태 라이브러리(Zustand, Jotai, Valtio)를 비교 가능한 라이브러리와 묶어 살펴보겠습니다.

Zustand vs Redux

  • Redux: 대규모 팀에서 작업하거나, 복잡한 앱을 유지보수해야 할 때 유용합니다.
  • Zustand: 간단한 프로젝트나 빠르게 프로토타입을 만들 때 적합합니다.
항목ReduxZustand
디렉터리 구조features 구조 추천자유롭게 구성
상태 업데이트Immer 기본 탑재Immer 없음
상태 전달 방식Context 사용import로 직접 가져옴
데이터 흐름단방향 흐름, 명확함자유로운 데이터 흐름(관리 주의 필요)
특징유지보수하기 좋고 명확함코드가 짧고 빠르게 구현 가능

Jotai vs Recoil

  • Recoil: 복잡한 상태를 효율적으로 추적하고 관리하는 데 유용합니다.
  • Jotai: 간단하고 빠르게 사용하고 싶다면 좋습니다.
항목JotaiRecoil
key 필요 여부없음key 필수
상태 추적 방식참조 기반(상태를 직접 사용)key 기반(구별된 키로 상태 추적)
파생 상태 처리atom()으로 전부 처리atom과 selector 구분 필요
Provider 필요생략 가능RecoilRoot 필수

Valtio vs MobX

  • MobX: 객체지향 스타일을 좋아하거나 클래스를 사용하고 싶다면 적합합니다.
  • Valtio: 최신 리액트 스타일을 사용하고 싶다면 Valtio가 더 적합합니다.
항목MobXValtio
상태 정의 방식클래스 기반일반 객체 사용
렌더링 방식observer(고차 컴포넌트) 로 감지useSnapshot() Hook으로 감지
상태/로직 분리상태와 로직이 클래스에 함께 포함됨상태와 로직을 외부 함수로 분리 가능
특징전통적인 객체지향 방식최신 리액트(Hook 기반) 스타일에 적합

전역 상태 관리 라이브러리의 유사점과 차이점

공통점: 같은 팀이 만든 라이브러리로, 철학도 비슷합니다

Zustand, Jotai, Valtio는 모두 Poimandres 라는 팀이 만든 라이브러리입니다. 이 팀의 철학은 최대한 작고 단순한 API를 제공해서, 개발자가 필요한 방식으로 조합해 쓰게 하자는 철학을 가졌습니다.

차이점 1: 상태를 어디에 저장할까? (모듈 vs 컴포넌트)

상태(데이터)를 어디에 두고 어떻게 관리하느냐에 따라 차이가 있습니다.

  • Zustand/Valtio는 앱 전역에서 자유롭게 상태를 공유하는 방식
  • Jotai는 리액트 컴포넌트 구조 안에서 엄격하게 상태를 관리하는 방식
라이브러리상태 위치설명
Zustand, Valtio모듈 (컴포넌트 바깥)어디서든 import 해서 사용 가능, React 외부에서 접근 가능
Jotai컴포넌트 내부(Provider 안)리액트 안에서만 사용할 수 있는 상태

차이점 2: 상태를 어떻게 바꿀까? (불변 vs 변경 가능)

상태를 업데이트할 때의 코드 스타일과 방식이 다릅니다.

  • Zustand: set(state ⇒ ({ count: state.count + 1 }))
  • Valtio: state.count++
라이브러리방식설명
Zustand불변 상태원래 상태를 복사해 새로 만든 후 바꿈
Valtio변경 가능한 상태원래 상태를 직접 바꿈(count++ 처럼)

어떤 라이브러리를 선택해야 할까?

라이브러리마다 특징이 다르기 때문에, 앱 구조나 개발 스타일에 따라 고르면 됩니다.

상황추천 라이브러리
앱 전체에서 상태를 공유하고 싶다Zustand, Valtio
리액트 컴포넌트 안에서 상태를 안전하게 관리하고 싶다Jotai
Redux처럼 불변 상태 관리 방식이 익숙하다Zustand
단순하고 직관적인 코드가 좋다Valtio
상태를 작게 나눠서 atom 단위로 상태를 쪼개서 사용하고 싶다Jotai

책을 읽고 나서 정리해본 나의 생각

책을 읽으면서 상태란 무엇인가에 대한 개념부터 다시 정리할 수 있었습니다. 상태(state)란? UI가 어떻게 보여야 하는지를 결정하는 데이터이고, 리액트에서는 아주 중요한 개념입니다.

Context를 전역 상태 관리로 쓰면 왜 문제가 되는 걸까?

예전에 리액트의 컨텍스트(Context)를 사용해서 전역 상태를 관리한 적이 있었는데, 그때 커뮤니티에서 "컨텍스트는 전역 상태 관리에 적합하지 않다"는 피드백을 받은 적이 있습니다. 당시에는 그 이유를 정확히 이해하지 못했지만, 이번에 책을 읽으면서 그 이유를 알게 됐습니다.

컨텍스트의 값이 바뀌면, 이를 사용하는 모든 컴포넌트가 한꺼번에 리렌더링되기 때문에 불필요한 리렌더링이 발생할 수 있고, 이는 성능 이슈로 이어질 수 있습니다.

컨텍스트는 자주 바뀌지 않는 테마나 언어 설정 등을 전달할 때 사용하는 것이 좋고, 자주 변경되는 상태는 별도의 전역 상태 관리 방법을 사용하는 것이 적절하다는 점을 이해하게 됐습니다.

왜 이렇게 많은 전역 상태 관리 라이브러리가 나왔을까?

리액트는 기본적으로 컴포넌트 단위의 지역 상태 관리를 중심으로 설계된 라이브러리입니다. 그런데 전역 상태를 다룰 수 있는 공식적인 도구나 훅을 제공하지 않습니다.

즉, useStateuseReducer는 컴포넌트 내부 상태만 다룰 수 있고, 전역에서 공유하려면 별도의 방식이 필요합니다.

이로 인해 전역 상태 관리는 온전히 커뮤니티와 생태계가 책임져야 하는 영역이 되었고, 이 문제를 해결하기 위해 수많은 상태 관리 라이브러리들이 등장하게 됐습니다.

전역 상태를 다룰 때 흔히 발생하는 문제는 크게 두 가지입니다:

  • 불필요한 리렌더링 → 상태가 바뀌면 관련 없는 컴포넌트까지 리렌더링되기 쉬움
  • 변경을 감지하지 못하는 상태 업데이트 → 상태를 직접 변경하면 React가 변경 사실을 인지하지 못할 수 있음

이러한 문제를 각자의 방식으로 해결하고자 등장한 라이브러리들이 바로 Zustand, Valtio, Jotai입니다.

라이브러리주요 전략
Zustand선택자(selector) 함수를 통해 필요한 데이터만 구독
Valtio프록시 기반 속성 접근 감지로 사용한 값만 추적
Jotai상태를 atom 단위로 쪼개어 최소한의 구독 유도

어떤 상태 관리 라이브러리를 선택해야 할까?

책에서는 세 라이브러리 중 하나를 선택할 때, 애플리케이션의 요구사항과 개발자의 멘탈 모델에 어떤 원칙이 잘 맞는지를 확인 하라고 말합니다.

마이크로 상태 관리를 잘 하려면, "지금 내가 겪고 있는 문제가 무엇인지", "그 문제를 해결할 수 있는 도구는 어떤 것들이 있는지" 정확히 이해하는 것이 중요합니다.

저도 실제로 상태 관리 라이브러리를 선택할 때는 이런 기준으로 비교해볼 것 같습니다:

  • 이 상태는 모듈 상태로 관리하는 게 좋을까, 아니면 컴포넌트 상태로 두는 게 적절할까?
  • 상태는 불변으로 갱신하는 게 좋을까, 아니면 변경 가능하게 해도 괜찮을까?
  • 라이브러리의 문법은 나에게 직관적인가?

이런 질문들을 스스로에게 던져본 뒤, 프로젝트에 가장 잘 맞는 도구를 선택할 것 같습니다.

마이크로 상태 관리 라이브러리는 기본적으로 개발자 친화적이다.

책을 두 번 정도 읽고 정리하면서, 마이크로 상태 관리 라이브러리들이 개발자가 사용하기 쉽게 설계되어 있다는 점이라고 생각했습니다.

예를 들어 Jotai는:

  • key 값을 따로 지정하지 않아도 됩니다.
  • atomselector를 하나의 함수로 정의할 수 있습니다.

Zustand와 Valtio 또한 각각 단순한 API 설계와 직관적인 상태 수정 방식 덕분에 개발자 친화적인 라이브러리라고 느꼈습니다.

이처럼 개발자가 귀찮아할 수 있는 작업들을 많이 줄여줬습니다. 하지만 그만큼 자유도가 높기 때문에 유지보수에는 신중함이 필요하다는 생각이 들었습니다.

구조적인 제약이 적기 때문에 프로젝트 규모가 커질수록 코드 스타일이나 상태 관리 방식이 제각각이 되기 쉽습니다. 그래서 팀 단위로 사용할 때는 코드 컨벤션을 명확히 정하고 일관되게 지키는 것이 중요하겠다는 생각이 들었습니다.

마무리하며

이번 책을 통해 상태 관리에 대한 기본 개념부터 시작해서, 리액트에서 전역 상태를 다룰 때 어떤 점을 주의해야 하는지, 그리고 다양한 상태 관리 라이브러리들이 어떤 방식으로 이 문제들을 해결하려고 했는지 깊이 이해할 수 있었습니다.

참고 자료

profile
여행과 책을 좋아하는 개발자입니다.

0개의 댓글