리액트로 애플리케이션을 만들다 보면, 어느 순간 고민하게 되는 주제가 있습니다.
“상태 관리는 어떤 라이브러리를 써야 하지?”
리액트에서 사용할 수 있는 상태 관리 라이브러리는 정말 많습니다. 책 『리액트 훅을 활용한 마이크로 상태 관리』 에서는 Redux, Zustand, Recoil, Jotai, MobX, Valtio까지 총 6개의 상태 관리 라이브러리를 소개합니다. 이 중 어떤 것을 선택해야 할지, 어떤 기준으로 선택할지 고민하게 됩니다.
리액트를 공부하면서 자연스럽게 상태 관리라는 개념에 관심이 생겼고,
“왜 이렇게 다양한 샹태 관리 도구가 존재할까?”
“다른 개발자들은 어떤 기준으로 선택할까?”
라는 궁금증이 생겼습니다.
리액트 훅을 활용한 마이크로 상태 관리
책은 그런 고민에 대한 해답을 줄 수 있을 것 같아 읽게 되었습니다. 책을 읽고 상태 관리의 기본 개념부터 전역 상태를 관리하면서 마주치는 문제, 그 문제를 해결하는 방법과 라이브러리 선택 기준까지 배울 수 있었습니다.
이 글은 책을 읽으며 정리한 내용을 바탕으로 다음과 같은 내용을 담고 있습니다:
리액트에서 상태(state)
는 UI(사용자 인터페이스)가 보여줘야 할 데이터를 말합니다.
이 데이터는 시간이 지나면서 바뀔 수 있고, 리액트는 이 상태가 변할 때마다 자동으로 화면(UI)을 다시 그려줍니다.
“리액트의 상태는 컴포넌트 중심의 구조에 맞게 설계되어 있어, 재사용성과 독립성을 높이는 데 도움이 됩니다.”
상태는 사용하는 범위나 목적에 따라 여러 종류로 나눌 수 있습니다. 책에서는 이러한 상태를 구분하기 위해 지역 상태, 전역 상태, 컴포넌트 상태, 모듈 상태, 마이크로 상태 등과 같은 용어를 사용해 설명합니다.
리액트 컴포넌트 내부에서 선언되고, 해당 컴포넌트 또는그 하위 컴포넌트 트리에서만 사용 되는 상태입니다. useState 또는 useReducer 훅을 사용해 만듭니다.
지역성
? 상태, 로직, 스타일 등 관련된 코드가 그것을 사용하는 위치에 최대한 가까이 존재해야 한다는 개념
버튼을 클릭할 때 숫자를 증가시키는 count
값
const CountComponent = () => {
const [count, setCount] = useState(0); // 지역 상태
return (
<div>
<span>{count}</span>
<button onClick={() => setCount((prev) => prev + 1)}>증가</button>
</div>
);
};
여러 컴포넌트에서 공통으로 사용하는 상태입니다. 특히 서로 멀리 떨어진 컴포넌트들 간에 정보가 공유되어야 할 때 사용합니다.
로그인 여부를 여러 컴포넌트에서 확인해야 할 때
UI를 구성하는 개별 컴포넌트에 필요한 상태를 말합니다. 지역 상태일 수도 있고, 필요에 따라 전역 상태로 만들어 관리할 수도 있습니다.
컴포넌트 외부에서 파일 단위로 관리하는 상태입니다. JavaScript 모듈의 변수처럼 하나의 파일(모듈) 안에서만 접근 가능한 상태를 말합니다.
직접 상태를 공유하지 않고, 함수를 통해 가져다 쓰는 방식을 사용합니다.
// store.js
let count = 0;
export const getCount = () => count;
export const setCount = (next) => {count = next;};
마이크로 상태는 작은 컴포넌트에서만 사용하는 간단한 상태를, 해당 컴포넌트 내부에서 직접 관리하는 방식입니다.
모든 상태를 굳이 전역으로 관리할 필요는 없습니다. 작고 목적이 뚜렷한 상태는 컴포넌트 내 부에서 다루는 것이 더 효율적이며, 불필요한 렌더링을 줄이고 상태를 더 깔끔하게 관리할 수 있게 해줍니다.
전역 상태로 관리하면 오히려 복잡해지고, 불필요한 렌더링이 생길 수 있습니다. 마이크로 상태로 관리한다면 불필요한 렌더링을 줄이고, 상태가 필요한 컴포넌트만 그 상태를 알 수 있습니다.
상태를 여러 컴포넌트에서 함께 사용하려면, 상태를 공유할 수 있는 방법이 필요합니다. 책에서는 세 가지 주요 방법을 소개합니다.
리액트에서 상태를 여러 컴포넌트에서 공유하려면 보통 props를 사용해 값을 전달합니다. 그런데 컴포넌트가 깊게 중첩되어 있을 경우, 값을 일일이 전달해야 하는 prop drilling 문제가 발생할 수 있습니다. 이 문제를 해결하기 위해 리액트 16.3부터 컨텍스트(Context) 기능이 도입되었습니다.
주의: 컨텍스트는 전역 상태 관리용으로 설계된 도구가 아니므로, 값이 변경될 때 모든 하위 컴포넌트가 리렌더링됩니다. 따라서 규모가 커지면 불필요한 렌더링이 발생할 수 있습니다.
컨텍스트는 상위 컴포넌트에서 하위 컴포넌트로 상태를 쉽게 전달할 수 있게 도와줍니다. 하지만 값이 바뀔 때마다 모든 하위 컴포넌트가 다시 렌더링되기 때문에, 대규모 애플리케이션에서는 성능에 영향을 줄 수 있습니다.
Prop Drilling
은 상위 컴포넌트에서 하위 컴포넌트로 props를 계속해서 전달하는 방식입니다.
Context
는 중간 컴포넌트를 거치지 않고, 하위 컴포넌트에서 직접 값을 꺼내 쓸 수 있게 해줍니다.
React에서는 Context를 사용하면 여러 컴포넌트가 같은 데이터를 쉽게 공유할 수 있습니다. 이때 데이터를 넘겨주는 쪽을 Provider(공급자), 데이터를 사용하는 쪽을 Consumer(소비자)라고 부릅니다.
컨텍스트 공급자(Provider)의 값이 변경되면 해당 값을 사용하는 모든 소비자(Consumer)는 리렌더링됩니다.
만약 컨텍스트 값이 객체(const CountContext = createContext({count1: 0, count2: 0})
)인 경우 객체 안의 일부 값만 사용하는 소비자도 전체 리렌더링 됩니다. 그 이유는 객체 전체가 매번 새로 만들어지기 때문에 React의 입장에서는 값이 바뀌었다
고 판단하기 때문입니다.
React는 객체의 내용이 아니라, 참조
가 바뀌었는지를 기준으로 판단합니다.
아래 그림에서 count1만 사용하는 컴포넌트에서 값을 바꿨을 때, count1을 사용하지 않는 count2만 사용하는 컴포넌트도 불필요하게 리렌더링됩니다.
모듈 상태는 자바스크립트 파일 안에 정의된 변수를 다른 컴포넌트에서 import
해서 사용하는 방식입니다. 이 상태는 앱 전체에서 하나만 존재하고, 마치 싱글턴처럼 동작합니다.
하지만 이 방식은 상태가 바뀌어도 React 컴포넌트가 자동으로 리렌더링되지 않는 문제가 있습니다. React는 내부적으로 useState
나 useReducer
와 같은 훅을 통해 상태 변화를 감지하는데, 단순한 변수 값의 변경은 React가 "리렌더링 해야겠다!"고 판단하지 않기 때문입니다.
이 문제를 해결하려면, 구독 패턴을 사용해야 합니다. 구독 패턴은 상태가 바뀔 때, 그 상태를 구독한 컴포넌트에게 알려주는 방식입니다. 이를 통해, 상태가 변경되면 구독한 컴포넌트만 리렌더링하도록 할 수 있습니다.
useState
를 호출하거나, React가 인식할 수 있는 방식으로 리렌더링을 유도합니다.이 방법은 상태를 컴포넌트 외부의 모듈에 보관하고, 필요할 때 구독(Subscription) 하도록 하는 방식입니다. 구독 방식으로 상태가 변경될 때, 구독한 컴포넌트만 리렌더링되므로 불필요한 리렌더링을 피할 수 있습니다. 이는 성능 최적화가 필요한 경우 매우 유용한 방법입니다.
아래 코드는 책에 나온 예제 코드에서 useSyncExternalStore 훅을 사용한 코드로 수정한 코드입니다.
상태 저장소 만들기
상태를 저장하고, 상태가 바뀔 때마다 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 };
};
리액트 컴포넌트에서 사용할 훅 만들기
useSyncExternalStore
훅을 사용하여, 상태를 구독하고 변경 사항을 안전하게 받아올 수 있습니다.
// useStoreSelector.js
import { useSyncExternalStore } from 'react';
const useStoreSelector = (store, selector) => {
const subscribe = store.subscribe;
const getSnapshot = () => selector(store.getState());
return useSyncExternalStore(subscribe, getSnapshot);
};
컴포넌트에서 사용하기
Component1
은 count1
만, Component2
는 count2
만 사용하도록 하여, 각 컴포넌트가 필요한 상태만 리렌더링하도록 할 수 있습니다.
// 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를 사용할 때 주의할 점은, 상태가 변경되면 그 상태를 사용하는 모든 하위 컴포넌트가 리렌더링된다는 것입니다. 이 문제를 해결하기 위해 구독 패턴을 적용할 수 있습니다.
이 방식은 두 가지 주요 개념을 결합한 것입니다:
이 방법을 사용하면 불필요한 리렌더링을 피할 수 있어 성능을 최적화할 수 있습니다. 아래에서 어떻게 작동하는지 예제를 통해 설명하겠습니다.
상태를 관리할 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 };
};
리액트에서 상태를 공유할 Context 만들기
Context
는 store
를 하위 컴포넌트에 전달하는 역할을 합니다. 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>
);
};
구독할 상태를 선택하는 커스텀 훅 만들기
컴포넌트가 필요한 상태만 구독할 수 있도록, 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]
)
);
};
상태 변경하는 useSetState
훅 만들기
컴포넌트에서 상태를 변경할 수 있도록 useSetState
훅을 제공합니다. 이 훅을 통해 상태를 업데이트하면 구독하고 있는 컴포넌트만 리렌더링됩니다.
const useSetState = () => {
const store = useContext(StoreContext);
return store.setState;
};
컴포넌트에서 상태 사용하기
useSelector
를 사용하여 상태의 일부만 구독하고, useSetState
로 상태를 변경합니다. Component
는 count
만 구독하고, 그 값이 변경될 때만 리렌더링됩니다.
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>
);
};
이 방법은 구독 패턴을 적용하여, 상태를 구독하는 컴포넌트만 리렌더링되도록 합니다. 이를 통해 불필요한 리렌더링을 피할 수 있고, 성능을 최적화할 수 있습니다. 상태를 공유하는 많은 컴포넌트가 있을 때도 성능에 부담을 줄 수 있는데, 이 방식은 그 부담을 줄여줍니다.
리액트는 컴포넌트 중심으로 설계된 라이브러리입니다. 하지만 앱이 커지면서 여러 컴포넌트가 같은 데이터를 공유해야 할 일이 많아집니다. 이때 사용하는 것이 전역 상태 관리입니다.
전역 상태는 여러 컴포넌트가 데이터를 쉽게 공유할 수 있게 해주지만, 잘못 관리하면 성능이 떨어지거나 유지보수가 어려워질 수 있습니다.
전역 상태에는 다양한 데이터가 들어 있습니다. 하지만 모든 컴포넌트가 전역 상태의 모든 데이터를 다 사용할 필요는 없습니다. 예를 들어, 어떤 컴포넌트는 전역 상태 중 일부 데이터만 필요할 수 있습니다.
그런데 전역 상태의 데이터가 조금만 바뀌어도, 그 상태를 사용하는 모든 컴포넌트가 다시 렌더링될 수 있습니다. 이렇게 불필요하게 여러 컴포넌트가 리렌더링되면, 앱의 성능이 떨어질 수 있습니다.
리액트는 상태가 변경될 때 화면을 다시 그려야 하는데, 상태 변경을 리액트가 감지하지 못하면 화면이 바뀌지 않습니다.
예를 들어, 상태를 직접 수정하거나 불변성을 지키지 않으면 리액트가 이를 감지하지 못할 수 있습니다. 이로 인해 상태가 바뀌었음에도 불구하고 화면이 업데이트되지 않아 사용자 경험에 문제가 생길 수 있습니다.
리렌더링 최적화의 핵심 질문은 아래와 같습니다:
“이 컴포넌트는 상태의 어떤 부분을 쓰고 있는가?”
상태 중에서 필요한 값만 선택해서 사용합니다.
이 방식은 Redux나 Zustand 같은 라이브러리에서 자주 사용됩니다.
const value = useSelector((state) => state.b.c); // b.c 값만 구독
컴포넌트가 실제로 사용한 값만 자동으로 추적합니다.
이 방법은 Valtio나 Proxy 기반 상태 라이브러리에서 사용됩니다.
const Component = () => {
const tracked = useTrackedState(); // 자동으로 추적된 값 사용
return <>{tracked.b.c}</>; // b.c 만 리렌더링
};
상태를 아주 작은 단위인 아톰(atom) 으로 나눠서 구독합니다.
이 방법은 Jotai나 Recoil과 같은 라이브러리에서 사용됩니다.
const countAtom = atom(0); // 상태 아톰 정의
const count = useAtom(countAtom); // count 아톰 구독
// 파생 아톰 얘시
// 다른 아톰들을 결합
const sumAtom = atom((get) => get(aAtom) + get(bAtom));
Zustand와 Jotai, Valtio라는 세 가지 전역 상태 라이브러리는 모두 마이크로 상태 관리에 적합한 기본 기능을 제공하지만 코딩 스타일과 렌더링 최적화에 대한 접근 방식이 다릅니다.
세 가지 전역 상태 라이브러리(Zustand, Jotai, Valtio)를 비교 가능한 라이브러리와 묶어 살펴보겠습니다.
항목 | Redux | Zustand |
---|---|---|
디렉터리 구조 | features 구조 추천 | 자유롭게 구성 |
상태 업데이트 | Immer 기본 탑재 | Immer 없음 |
상태 전달 방식 | Context 사용 | import로 직접 가져옴 |
데이터 흐름 | 단방향 흐름, 명확함 | 자유로운 데이터 흐름(관리 주의 필요) |
특징 | 유지보수하기 좋고 명확함 | 코드가 짧고 빠르게 구현 가능 |
항목 | Jotai | Recoil |
---|---|---|
key 필요 여부 | 없음 | key 필수 |
상태 추적 방식 | 참조 기반(상태를 직접 사용) | key 기반(구별된 키로 상태 추적) |
파생 상태 처리 | atom()으로 전부 처리 | atom과 selector 구분 필요 |
Provider 필요 | 생략 가능 | RecoilRoot 필수 |
항목 | MobX | Valtio |
---|---|---|
상태 정의 방식 | 클래스 기반 | 일반 객체 사용 |
렌더링 방식 | observer(고차 컴포넌트) 로 감지 | useSnapshot() Hook으로 감지 |
상태/로직 분리 | 상태와 로직이 클래스에 함께 포함됨 | 상태와 로직을 외부 함수로 분리 가능 |
특징 | 전통적인 객체지향 방식 | 최신 리액트(Hook 기반) 스타일에 적합 |
Zustand, Jotai, Valtio는 모두 Poimandres 라는 팀이 만든 라이브러리입니다. 이 팀의 철학은 최대한 작고 단순한 API를 제공해서, 개발자가 필요한 방식으로 조합해 쓰게 하자
는 철학을 가졌습니다.
상태(데이터)를 어디에 두고 어떻게 관리하느냐에 따라 차이가 있습니다.
라이브러리 | 상태 위치 | 설명 |
---|---|---|
Zustand, Valtio | 모듈 (컴포넌트 바깥) | 어디서든 import 해서 사용 가능, React 외부에서 접근 가능 |
Jotai | 컴포넌트 내부(Provider 안) | 리액트 안에서만 사용할 수 있는 상태 |
상태를 업데이트할 때의 코드 스타일과 방식이 다릅니다.
set(state ⇒ ({ count: state.count + 1 }))
state.count++
라이브러리 | 방식 | 설명 |
---|---|---|
Zustand | 불변 상태 | 원래 상태를 복사해 새로 만든 후 바꿈 |
Valtio | 변경 가능한 상태 | 원래 상태를 직접 바꿈(count++ 처럼) |
라이브러리마다 특징이 다르기 때문에, 앱 구조나 개발 스타일에 따라 고르면 됩니다.
상황 | 추천 라이브러리 |
---|---|
앱 전체에서 상태를 공유하고 싶다 | Zustand, Valtio |
리액트 컴포넌트 안에서 상태를 안전하게 관리하고 싶다 | Jotai |
Redux처럼 불변 상태 관리 방식이 익숙하다 | Zustand |
단순하고 직관적인 코드가 좋다 | Valtio |
상태를 작게 나눠서 atom 단위로 상태를 쪼개서 사용하고 싶다 | Jotai |
책을 읽으면서 상태란 무엇인가
에 대한 개념부터 다시 정리할 수 있었습니다. 상태(state)란? UI가 어떻게 보여야 하는지를 결정하는 데이터이고, 리액트에서는 아주 중요한 개념입니다.
예전에 리액트의 컨텍스트(Context)를 사용해서 전역 상태를 관리한 적이 있었는데, 그때 커뮤니티에서 "컨텍스트는 전역 상태 관리에 적합하지 않다"는 피드백을 받은 적이 있습니다. 당시에는 그 이유를 정확히 이해하지 못했지만, 이번에 책을 읽으면서 그 이유를 알게 됐습니다.
컨텍스트의 값이 바뀌면, 이를 사용하는 모든 컴포넌트가 한꺼번에 리렌더링되기 때문에 불필요한 리렌더링이 발생할 수 있고, 이는 성능 이슈로 이어질 수 있습니다.
컨텍스트는 자주 바뀌지 않는 테마나 언어 설정 등을 전달할 때 사용하는 것이 좋고, 자주 변경되는 상태는 별도의 전역 상태 관리 방법을 사용하는 것이 적절하다는 점을 이해하게 됐습니다.
리액트는 기본적으로 컴포넌트 단위의 지역 상태 관리
를 중심으로 설계된 라이브러리입니다. 그런데 전역 상태를 다룰 수 있는 공식적인 도구나 훅을 제공하지 않습니다.
즉, useState
나 useReducer
는 컴포넌트 내부 상태만 다룰 수 있고, 전역에서 공유하려면 별도의 방식이 필요합니다.
이로 인해 전역 상태 관리는 온전히 커뮤니티와 생태계가 책임져야 하는 영역
이 되었고, 이 문제를 해결하기 위해 수많은 상태 관리 라이브러리들이 등장하게 됐습니다.
전역 상태를 다룰 때 흔히 발생하는 문제는 크게 두 가지입니다:
이러한 문제를 각자의 방식으로 해결하고자 등장한 라이브러리들이 바로 Zustand, Valtio, Jotai
입니다.
라이브러리 | 주요 전략 |
---|---|
Zustand | 선택자(selector) 함수를 통해 필요한 데이터만 구독 |
Valtio | 프록시 기반 속성 접근 감지로 사용한 값만 추적 |
Jotai | 상태를 atom 단위로 쪼개어 최소한의 구독 유도 |
책에서는 세 라이브러리 중 하나를 선택할 때, 애플리케이션의 요구사항과 개발자의 멘탈 모델에 어떤 원칙이 잘 맞는지를 확인 하라
고 말합니다.
마이크로 상태 관리를 잘 하려면, "지금 내가 겪고 있는 문제가 무엇인지"
, "그 문제를 해결할 수 있는 도구는 어떤 것들이 있는지"
정확히 이해하는 것이 중요합니다.
저도 실제로 상태 관리 라이브러리를 선택할 때는 이런 기준으로 비교해볼 것 같습니다:
이런 질문들을 스스로에게 던져본 뒤, 프로젝트에 가장 잘 맞는 도구를 선택할 것 같습니다.
책을 두 번 정도 읽고 정리하면서, 마이크로 상태 관리 라이브러리들이 개발자가 사용하기 쉽게 설계되어 있다는 점
이라고 생각했습니다.
예를 들어 Jotai는:
key
값을 따로 지정하지 않아도 됩니다.atom
과 selector
를 하나의 함수로 정의할 수 있습니다.Zustand와 Valtio 또한 각각 단순한 API 설계와 직관적인 상태 수정 방식 덕분에 개발자 친화적인 라이브러리라고 느꼈습니다.
이처럼 개발자가 귀찮아할 수 있는 작업들을 많이 줄여줬습니다. 하지만 그만큼 자유도가 높기 때문에 유지보수에는 신중함이 필요하다는 생각이 들었습니다.
구조적인 제약이 적기 때문에 프로젝트 규모가 커질수록 코드 스타일이나 상태 관리 방식이 제각각
이 되기 쉽습니다. 그래서 팀 단위로 사용할 때는 코드 컨벤션을 명확히 정하고 일관되게 지키는 것이 중요하겠다는 생각이 들었습니다.
이번 책을 통해 상태 관리에 대한 기본 개념부터 시작해서, 리액트에서 전역 상태를 다룰 때 어떤 점을 주의해야 하는지, 그리고 다양한 상태 관리 라이브러리들이 어떤 방식으로 이 문제들을 해결하려고 했는지 깊이 이해할 수 있었습니다.