상태 관리 라이브러리 소개
Prop Drilling과 공유 상태 문제를 이해하고 Context·Redux Toolkit·Zustand·Jotai의 모델과 적용 규모를 비교합니다.
앞 글 React 기초 (컴포넌트, JSX, 상태 관리)에서는 useState로 컴포넌트 안의 데이터를 관리하고, props로 부모와 자식 사이에 데이터를 넘기는 방법을 익혔습니다. 하지만 애플리케이션이 커져 여러 컴포넌트가 같은 데이터를 함께 쓰고 바꿔야 하면 문제가 복잡해집니다. 예를 들어 상단 내비게이션 바에 보이는 로그인 사용자의 이름과 프로필 페이지에 보이는 이름은 같은 데이터를 써야 합니다.
컴포넌트가 적을 때는 props로 넘기는 것만으로 충분합니다. 하지만 그 값을 쓰지 않는 중간 컴포넌트까지 여러 단계에 걸쳐 props를 넘겨야 하는 상황이 생기는데, 이를 프롭 드릴링(prop drilling)이라고 합니다. 공유하는 상태가 있다고 모두 프롭 드릴링인 것은 아니며, 중간 전달이 길어져 어떤 화면이 그 값을 쓰는지 추적하기 어려워졌는지를 먼저 봐야 합니다. 또 여러 컴포넌트가 같은 데이터를 각자 따로 관리하면 서로 값이 달라지기 쉽습니다. 이런 문제를 풀고 상태를 일관되고 예측할 수 있게 관리하려고 상태 관리 라이브러리(state management library)가 나왔습니다. 이 글에서는 상태 관리가 왜 필요한지와 주요 라이브러리의 특징을 간단히 정리합니다.
프롭 드릴링과 공유 상태
React에서 데이터는 기본으로 부모에서 자식으로 props를 통해 전달됩니다. 이 방식은 간단한 애플리케이션에서는 잘 맞지만, 컴포넌트 트리가 깊어지면 그 데이터가 필요 없는 중간 컴포넌트까지 props를 넘겨야 합니다. 그러면 코드가 지저분해지고, 데이터가 어디로 전달되는지 추적하기 어려워지며, 구조를 바꿀 때도 부담이 커집니다.
// 데이터 전달 경로를 나타낸 개념도이며 실행할 JSX가 아닙니다.
// 예시: Prop Drilling
<App>
<Header user={user}> // user 데이터를 Header에 전달
<Navbar user={user}> // Navbar는 user를 필요로 하지만 Header는 필요 없음
<Avatar user={user} /> // Avatar는 user를 필요로 함
</Navbar>
</Header>
<Main />
</App>Header는 user를 쓰지 않지만 Navbar에 넘기기 위해 받아야 합니다. 트리가 더 깊어지면 이런 중간 전달이 계속 늘어납니다.
서로 멀리 떨어진 컴포넌트가 같은 상태를 함께 쓰고 바꿔야 할 때도 어렵습니다. 장바구니에 담기 버튼이 있는 상품 상세 컴포넌트와 총 상품 개수를 보여 주는 장바구니 아이콘 컴포넌트가 멀리 떨어져 있다면, 둘이 같은 장바구니 상태를 읽고 바꾸도록 props와 콜백으로만 연결하기는 복잡하고 실수하기 쉽습니다. 그래서 애플리케이션 전체나 특정 영역에서 함께 쓰는 공유 상태(shared state)를 한곳에서 관리하는 방법이 필요해졌고, 이것이 상태 관리 라이브러리의 핵심 역할입니다.
상태를 저장하기 전에 원본과 범위를 나누기
STATE OWNERSHIP
상태를 저장하기 전에 원천과 범위를 나눈다
먼저 파생 가능한 값인지 확인하고, 남은 상태의 원천과 공유 범위를 판단한다. 도구는 필요와 성능을 확인하며 선택한다.
- 기존 값에서 계산할 수 있는가?
예: 필터 결과·합계는 렌더 중 순수하게 계산한다. 비싼 계산은 측정한 뒤 메모화를 검토한다. 별도 상태로 중복 저장하지 않는다.
- 원본이 서버에 있는가?
예: 요청·로딩·오류·재검증을 관리한다. 화면 간 캐시 공유가 필요하면 SWR·TanStack Query 같은 query cache를 검토한다. 단순 요청에 도구가 항상 필요한 것은 아니다.
- 다른 컴포넌트와 공유하는가?
아니요: 필요한 컴포넌트에 local state를 둔다. 예: 가장 가까운 공통 소유자로 올리고 props·callback으로 전달하는 방식부터 살핀다.
- 공유 범위와 갱신 비용은 어떤가?
트리 안의 공통 값은 Context(+ useReducer)로 전달할 수 있다. 선택적 구독·복잡한 갱신 규칙이 필요하면 Redux Toolkit·Zustand 같은 store나 Jotai의 atom 모델을 검토한다.
원본은 한곳에, 파생값은 계산으로
서버 응답과 편집 중인 폼 초안은 수명과 책임이 다르다. 공유 상태를 만들기 전에 어떤 값이 원본이며 언제 갱신·폐기되는지 정한다.
갱신 빈도만으로 Context와 store를 강제 분류하지 않는다. Context의 전달 범위, 구독할 값, 팀의 갱신 규칙과 실제 렌더 비용을 함께 비교한다.
위 그림은 상태를 어디에 둘지 정하는 질문을 순서대로 보여 줍니다. 첫 질문은 이미 있는 값에서 계산할 수 있는지입니다. 필터 결과나 합계처럼 계산할 수 있는 값은 상태로 따로 저장하지 않고 렌더 중에 계산하며, 계산이 비싸다면 측정해 본 뒤에 결과를 기억해 두는 메모이제이션(memoization)을 검토합니다.
두 번째 질문은 원본이 서버에 있는지입니다. 서버의 데이터라면 요청, 로딩, 오류, 다시 확인하는 일을 함께 관리해야 하고, 여러 화면이 같은 응답을 나눠 써야 한다면 SWR이나 TanStack Query 같은 쿼리 캐시(query cache) 라이브러리를 검토합니다. 단순한 요청 하나에 이런 도구가 항상 필요한 것은 아닙니다.
세 번째 질문은 다른 컴포넌트와 함께 쓰는지입니다. 함께 쓰지 않는다면 그 컴포넌트의 지역 상태(local state)로 두고, 함께 쓴다면 앞 글에서 본 것처럼 가장 가까운 공통 부모로 올려 props와 콜백으로 넘기는 방법부터 살핍니다. 마지막으로 공유하는 범위와 갱신 비용을 봅니다. 트리 안에서 공통으로 쓰는 값은 Context와 useReducer로 전달할 수 있고, 필요한 값만 골라 구독하거나 갱신 규칙이 복잡하다면 Redux Toolkit이나 Zustand 같은 스토어(store)나 Jotai의 아톰(atom) 모델을 검토합니다.
그림 아래의 설명처럼 원본은 한곳에 두고 파생 값은 계산으로 만듭니다. 서버의 응답과 사용자가 편집 중인 폼의 초안은 수명도, 관리하는 쪽도 다르므로, 공유 상태를 만들기 전에 어떤 값이 원본이고 언제 갱신하고 버릴지 먼저 정해야 합니다. 또 값이 얼마나 자주 바뀌는지만으로 Context와 스토어를 나누지는 않으며, Context가 전달하는 범위와 구독할 값, 팀의 갱신 규칙, 실제 렌더 비용을 함께 비교합니다.
스토어와 구독의 흐름
상태 관리 라이브러리는 여러 모델을 씁니다. 대표적인 것은 공유 상태를 하나의 중앙 스토어(centralized store)에 모으는 모델입니다. 이때 스토어에 맡기는 것은 함께 쓰는 상태이며, 컴포넌트의 모든 지역 상태까지 넣을 필요는 없습니다. 상태는 액션(action)이나 리듀서(reducer) 같은 정해진 규칙으로만 바꿔, 언제 어떻게 바뀌었는지 예측하고 디버깅하기 쉽게 합니다.
공유 상태 갱신과 구독 전파 루프 — 상태 추적
의도·reducer·store·selector 비교·렌더/생략에서 저장 상태와 구독 결과가 실제로 달라진다. 작은 업데이트 예로 값 변경과 렌더 생략을 구분할 수 있다.
DATA-FLOW · B99 WEB FUNDAMENTALS
공유 상태 갱신과 구독 전파 루프
Zustand처럼 selector를 구독하는 외부 store의 개념 예시다. 사용자 의도로 상태를 갱신한 뒤 선택한 값의 비교 결과에 따라 해당 구독자의 렌더를 예약한다.
변경 없음 분기는 이 store 구독으로 인한 렌더 예약을 생략한다. props·Context 등 다른 원인으로 렌더될 수 있다. Context와 Jotai의 atom 모델은 이 selector 예시와 갱신 방식이 다르다. 두 결과 모두 다음 사용자 의도를 기다린다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 노드·간선·영역 라벨 | React가 필요한 DOM 변경을 반영 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 구독 결과 | store: 현재 snapshot / selector: 소비자별 결과 | 두 consumer는 각 selector 결과를 기억한다 | 같은 store도 선택한 값은 다를 수 있다 |
| action 적용 | store: 다음 snapshot / selector: 다시 계산 | reducer가 새 store snapshot을 만든다 | 전이 규칙이 현재 state와 action으로 다음 state를 만든다 |
| 변한 선택만 구독 갱신 | 변경 selector: 갱신 요청 / 같은 selector: 이 구독 요청 건너뜀 | 선택 결과가 달라진 구독 경로만 렌더를 요청한다 | 별도 부모 렌더 경로까지 막는 보장은 아니다 |
공유 상태 갱신과 구독 전파 루프 — 상태 추적
의도·reducer·store·selector 비교·렌더/생략에서 저장 상태와 구독 결과가 실제로 달라진다. 작은 업데이트 예로 값 변경과 렌더 생략을 구분할 수 있다.
DATA-FLOW · B99 WEB FUNDAMENTALS
공유 상태 갱신과 구독 전파 루프
Zustand처럼 selector를 구독하는 외부 store의 개념 예시다. 사용자 의도로 상태를 갱신한 뒤 선택한 값의 비교 결과에 따라 해당 구독자의 렌더를 예약한다.
변경 없음 분기는 이 store 구독으로 인한 렌더 예약을 생략한다. props·Context 등 다른 원인으로 렌더될 수 있다. Context와 Jotai의 atom 모델은 이 selector 예시와 갱신 방식이 다르다. 두 결과 모두 다음 사용자 의도를 기다린다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 노드·간선·영역 라벨 | React가 필요한 DOM 변경을 반영 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 구독 결과 | store: 현재 snapshot / selector: 소비자별 결과 | 두 consumer는 각 selector 결과를 기억한다 | 같은 store도 선택한 값은 다를 수 있다 |
| action 적용 | store: 다음 snapshot / selector: 다시 계산 | reducer가 새 store snapshot을 만든다 | 전이 규칙이 현재 state와 action으로 다음 state를 만든다 |
| 변한 선택만 구독 갱신 | 변경 selector: 갱신 요청 / 같은 selector: 이 구독 요청 건너뜀 | 선택 결과가 달라진 구독 경로만 렌더를 요청한다 | 별도 부모 렌더 경로까지 막는 보장은 아니다 |
위 그림은 Zustand처럼 셀렉터(selector)로 필요한 값만 구독하는 외부 스토어의 흐름을 개념으로 보여 줍니다. 클릭이나 입력 같은 화면의 의도가 액션이나 리듀서 같은 갱신 규칙을 거쳐 하나의 스토어를 갱신하면, 스토어는 새 상태를 저장하고 구독자에게 알립니다. 각 구독자는 셀렉터로 고른 이전 값과 새 값을 비교해, 값이 바뀌었으면 그 컴포넌트의 렌더를 예약하고 React가 필요한 DOM 변경을 반영합니다. 같은 참조이거나 같은 값이면 렌더를 건너뜁니다.
두 경우 모두 다음 사용자 의도를 기다리며, 원본은 언제나 스토어 하나입니다. 그림 아래의 설명처럼 렌더를 건너뛰는 것은 이 스토어 구독 때문에 생길 렌더를 생략한다는 뜻일 뿐, props나 Context 같은 다른 이유로는 렌더될 수 있습니다. 또 Context와 Jotai의 아톰 모델은 이 셀렉터 예시와 갱신 방식이 다릅니다.
더 깊이: React는 바깥의 스토어를 어떻게 구독할까
React가 직접 관리하지 않는 값을 화면에 연결하려면, 값이 바뀔 때 React에 알리고 렌더할 때 지금 값을 읽는 약속이 필요합니다. React는 이를 위해 useSyncExternalStore라는 내장 Hook을 제공합니다. 첫 인수 subscribe는 스토어가 바뀔 때 부를 콜백을 받아 등록하고 구독을 해제하는 함수를 돌려주며, 둘째 인수 getSnapshot은 컴포넌트에 필요한 스토어의 현재 값을 돌려줍니다. React 문서는 이 Hook의 쓰임으로 React 밖에 상태를 두는 상태 관리 라이브러리와, navigator.onLine처럼 바뀌는 값과 변경 이벤트를 가진 브라우저 API를 듭니다.
이때 주의할 점은 getSnapshot이 호출될 때마다 새 객체를 만들어 돌려주면 안 된다는 것입니다. React는 이 함수가 돌려준 값이 이전과 다르면 스토어가 바뀌었다고 보고 다시 렌더하므로, 스토어가 그대로인데도 매번 { ...state } 같은 새 객체를 돌려주면 렌더가 끝없이 이어져 오류가 납니다. 그래서 스토어가 바뀌지 않았다면 같은 값을 돌려줘야 합니다. 스토어의 데이터가 불변이라면 그 값을 그대로 돌려주고, 직접 바뀌는 데이터라면 마지막 스냅샷을 저장해 두었다가 데이터가 실제로 바뀔 때만 새로 만듭니다.
다만 React 문서는 가능하면 내장 상태인 useState와 useReducer를 쓰고, 이 Hook은 주로 React가 아닌 기존 코드와 연결할 때 쓰라고 권합니다. 상태 관리 라이브러리를 쓴다면 이 Hook을 직접 부를 일은 드물고, 브라우저 API의 값을 구독하는 작은 Hook을 직접 만들 때 주로 쓰게 됩니다. 자세한 규칙은 React의 useSyncExternalStore 문서에 있습니다.
주요 상태 관리 방법
React Context와 useReducer
React에 들어 있는 Context API는 props를 하나하나 넘기지 않고도 컴포넌트 트리를 통해 값을 전달하게 해 줍니다. Context는 상태를 저장하는 별도의 스토어가 아니라, 부모의 useState나 useReducer가 관리하는 값을 아래로 전달하는 통로입니다.
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext('light');
export default function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext value={theme}>
<Toolbar />
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
테마 바꾸기
</button>
</ThemeContext>
);
}
function Toolbar() {
return <ThemeLabel />; // 중간 컴포넌트는 theme을 몰라도 됨
}
function ThemeLabel() {
const theme = useContext(ThemeContext); // 가장 가까운 Provider의 값을 읽음
return <p>현재 테마: {theme}</p>;
}App이 theme 상태를 갖고 <ThemeContext value={theme}>로 아래 트리에 전달하면, 중간의 Toolbar는 아무것도 넘기지 않아도 ThemeLabel이 useContext()로 값을 읽습니다. React 19부터는 이처럼 Context 자체를 Provider로 쓸 수 있고, 그 이전 버전에서는 <ThemeContext.Provider value={theme}>로 씁니다. useReducer와 함께 쓰면 상태와 갱신 규칙을 함께 전달해 Redux와 비슷한 패턴도 만들 수 있습니다.
Context는 별도 라이브러리가 필요 없고 프롭 드릴링을 없애 줍니다. 대신 Provider의 값이 이전 값과 Object.is로 비교해 달라지면, 그 Context를 읽는 아래 컴포넌트들이 새 값으로 다시 렌더됩니다. Context를 읽지 않는 자식까지 Context 때문에 렌더되는 것은 아니지만, 자주 바뀌는 큰 객체를 한 Context에 담으면 렌더가 늘 수 있으므로 값을 관심사별로 나누고 실제 비용을 측정합니다. 관심사마다 Context를 나누면 Provider를 어떻게 구성하고 누가 값을 바꿀지 팀에서 정해야 하고, 전용 라이브러리만큼 상태 변화를 추적하는 도구는 없습니다.
Context는 테마나 로그인 정보처럼 자주 바뀌지 않거나 트리를 따라 깊이 전달되는 값에 잘 맞습니다. 갱신 규칙이 복잡하거나 많은 컴포넌트가 서로 다른 일부 값만 자주 바꿔 쓴다면 전용 라이브러리가 더 알맞을 수 있습니다.
Redux와 Redux Toolkit
Redux는 React와 가장 오래, 가장 널리 함께 쓰여 온 상태 관리 라이브러리입니다. 예측할 수 있는 상태 컨테이너를 목표로 하며 Flux 아키텍처를 바탕으로 합니다. 새 Redux 코드는 공식으로 권하는 Redux Toolkit으로 작성합니다. configureStore()는 스토어 설정을, createSlice()는 상태별 갱신 규칙과 액션 생성을 도와, Redux의 개념은 그대로 두면서 직접 써야 하던 설정을 줄여 줍니다(Redux의 Redux Toolkit 안내). 스토어와 액션, 리듀서가 이어지는 흐름은 React 교재의 Redux 기초 개념 소개에서 더 자세히 다룹니다.
Redux에는 세 가지 원칙이 있습니다. Redux에 맡긴 상태는 하나의 스토어 안의 상태 트리에 두며, 이를 단일 진실 공급원(single source of truth)이라고 합니다. 앱의 모든 지역 상태나 따로 관리하는 서버 데이터까지 Redux에 넣으라는 뜻은 아닙니다. 상태는 읽기 전용이라 직접 바꿀 수 없고, 반드시 액션을 디스패치(dispatch)해 바꿉니다. 상태를 바꾸는 로직은 이전 상태와 액션을 받아 새 상태를 돌려주는 순수 함수인 리듀서로 작성합니다.
Redux Toolkit의 createSlice() 안에서는 state.count += 1처럼 직접 바꾸는 듯이 쓸 수 있습니다. 이는 Immer 라이브러리가 넘겨준 임시 초안(draft)을 바꾸면 Immer가 새 불변 상태를 만들어 주기 때문이며, 스토어에서 읽은 원본을 아무 데서나 직접 바꿔도 된다는 뜻은 아닙니다. 흐름은 컴포넌트가 액션을 디스패치하면 필요에 따라 미들웨어(middleware)를 거쳐 리듀서가 새 상태를 만들고, 스토어가 갱신되면 그 상태를 구독한 컴포넌트가 다시 렌더되는 순서입니다.
Redux는 엄격한 규칙 덕분에 상태 변화를 예측하고 추적하기 쉽고, Redux DevTools로 상태 변화의 기록을 되돌려 보며(time-travel debugging) 디버깅할 수 있습니다. 미들웨어로 비동기 처리나 로깅 같은 기능을 더할 수도 있습니다. 반면 스토어, 액션, 리듀서, 구독의 역할을 익혀야 하고, 작은 애플리케이션에는 지나칠 수 있습니다. 예전 Redux 예제의 많은 반복 코드를 보고 Redux Toolkit도 그만큼 길다고 생각할 필요는 없습니다.
Zustand
Zustand는 스토어를 만들고 필요한 값만 골라 구독하게 해 주는 가벼운 상태 관리 라이브러리입니다. create()로 스토어를 만들고, set()으로 갱신 규칙을 쓰며, 셀렉터로 필요한 값을 골라 쓰는 Hook 기반의 짧은 API를 제공합니다. set()으로 상태를 바꾸면 구독자에게 알리고, React에서는 셀렉터로 고른 값이 바뀐 컴포넌트만 다시 렌더되도록 연결됩니다. React 밖의 일반 자바스크립트에서도 쓸 수 있습니다.
적은 설정으로 스토어와 구독을 연결할 수 있고 비교적 가벼운 편이지만, 실제 렌더 비용과 앱에 들어가는 크기는 구독 범위와 쓰는 기능, 번들 구성에 따라 달라집니다. Redux처럼 엄격한 규칙을 강제하지 않으므로, 팀 안에서 상태를 바꾸는 규칙을 분명히 정해 둬야 합니다.
Jotai
Jotai는 아톰을 조합해 공유 상태와 파생 상태를 만드는 가벼운 상태 관리 라이브러리입니다. 상태의 가장 작은 단위를 아톰으로 정의하고, 필요한 컴포넌트에서 쓰는 아톰을 중심으로 연결합니다. 다른 아톰을 읽어 계산하는 아톰도 만들 수 있어, 합계처럼 계산할 수 있는 값을 따로 원본으로 저장할 필요가 줄어듭니다. 타입스크립트에서 타입 추론이 잘 되도록 설계되었으며, 번들 크기는 실제 빌드에서 확인합니다(Jotai 공식 문서).
사례: 장바구니 값의 원본 나누기
상품 목록은 서버가 원본이고, 지금 펼쳐 둔 상품 설명은 한 화면에서만 쓰는 값입니다. 장바구니의 상품 ID와 수량은 여러 화면이 함께 쓰는 클라이언트 상태라고 가정합니다. 장바구니를 서버에도 저장한다면 원본이 어디인지와 동기화를 다시 판단해야 합니다.
| 값 | 처음 둘 곳 | 이유 |
|---|---|---|
| 상품 목록과 가격 응답 | 요청 결과, 필요하면 쿼리 캐시 | 서버의 갱신, 로딩, 오류, 재확인을 함께 다룸 |
| 설명 펼침 여부 | 그 컴포넌트의 지역 상태 | 다른 화면과 함께 쓸 필요가 없음 |
| 장바구니 상품 ID와 수량 | 공통 부모, 필요하면 공유 스토어 | 두 화면이 같은 원본을 읽고 바꿈 |
| 총수량 | 장바구니에서 계산 | 같은 정보를 상태로 복제하면 값이 달라질 수 있음 |
공유 스토어를 쓴다면 가장 작은 흐름은 다음과 같습니다. 예제 프로젝트에 Zustand를 따로 설치해야 하며, 이 예제에는 서버에 저장하는 기능이 없습니다(Zustand의 create 문서).
import { create } from 'zustand';
const useCartStore = create((set) => ({
quantities: { mouse: 1, keyboard: 2 },
addOne: (id) => set((state) => ({
quantities: {
...state.quantities,
[id]: (state.quantities[id] ?? 0) + 1,
},
})),
}));
export function CartCount() {
const total = useCartStore((state) =>
Object.values(state.quantities).reduce((sum, quantity) => sum + quantity, 0)
);
return <span>장바구니 {total}개</span>;
}
export function AddMouseButton() {
const addOne = useCartStore((state) => state.addOne);
return <button onClick={() => addOne('mouse')}>마우스 담기</button>;
}처음 총수량은 1 + 2 = 3입니다. 버튼을 누르면 addOne('mouse')가 mouse가 2인 새 quantities 객체를 만들고, 총수량은 2 + 2 = 4가 됩니다. CartCount의 셀렉터는 숫자를 돌려주므로 값이 바뀌었는지 바로 비교할 수 있습니다. 셀렉터가 매번 새 객체나 배열을 만들어 돌려주면 내용이 같아도 참조가 달라지는데, Zustand 5에서는 이 때문에 렌더가 끝없이 반복되어 Maximum update depth exceeded 오류가 날 수 있습니다. 여러 값을 한 번에 고르려면 셀렉터를 useShallow로 감싸거나 값마다 따로 구독합니다. 갱신할 때 같은 객체를 직접 바꾼 뒤 돌려주는 방식은 바뀐 것을 알아채지 못하게 하므로 피합니다.
정리
- 프롭 드릴링은 값을 쓰지 않는 중간 컴포넌트까지 props를 넘기는 것이며, 공유 상태가 있다고 모두 프롭 드릴링은 아닙니다.
- 상태를 만들기 전에 계산할 수 있는 값인지, 서버가 원본인지, 함께 쓰는지, 공유 범위와 갱신 비용이 어떤지를 차례로 묻습니다.
- 함께 쓰는 상태는 먼저 공통 부모로 올리고, 전달이 깊어지면 Context를, 선택적 구독이나 복잡한 규칙이 필요하면 스토어나 아톰을 검토합니다.
- Context는 상태를 저장하지 않고 전달하며, Provider의 값이 바뀌면 그 Context를 읽는 컴포넌트가 다시 렌더됩니다.
- Redux Toolkit은 액션과 리듀서로 상태를 엄격하게 바꾸고, Zustand는 셀렉터로 필요한 값만 구독하며, Jotai는 아톰을 조합합니다.
- 원본은 한곳에 두고, 합계 같은 파생 값은 상태로 복제하지 말고 계산으로 만듭니다.
연습 문제
1. 꼭 라이브러리가 필요할까
부모와 두 자식에서만 쓰는 체크박스 값이 있고, 한 단계로 바로 전달됩니다. 이 값을 위해 공유 상태 라이브러리를 반드시 도입해야 할까요?
해설 보기
꼭 그럴 필요는 없습니다. 공통 부모가 값을 갖고 두 자식에게 값과 콜백을 넘기면 하나의 원본을 유지할 수 있습니다.
먼저 이 방식으로 해결하고, 전달 경로가 길어지거나 갱신 규칙과 실제 렌더 비용이 복잡해졌을 때 다른 도구를 비교해도 늦지 않습니다.
2. 두 번 담은 뒤의 장바구니
장바구니 예제에서 처음 수량은 마우스 1, 키보드 2입니다. 마우스 담기를 두 번 누른 뒤의 수량과 총수량은 얼마일까요? 스토어에 원본으로 저장할 값은 무엇일까요?
해설 보기
수량은 { mouse: 3, keyboard: 2 }이고, 총수량은 3 + 2 = 5입니다.
스토어에는 수량만 원본으로 두고 총수량은 계산합니다. 총수량을 따로 저장하면 수량을 바꿀 때마다 그 값도 빠짐없이 함께 바꿔야 하므로, 한 곳을 놓쳐 두 값이 어긋날 위험이 생깁니다.
3. 매번 새로 만든 Context 값
Context의 Provider에서 렌더할 때마다 { theme: 'dark' } 객체를 새로 만들어 넘깁니다. 안의 문자열이 같으니 Context 값도 같다고 볼 수 있을까요? Jotai의 아톰 모델은 이 점에서 어떻게 다를까요?
해설 보기
같다고 볼 수 없습니다. 새로 만든 객체는 내용이 같아도 이전 객체와 Object.is로 비교하면 다르므로, 그 Context를 읽는 컴포넌트들이 새 값으로 다시 렌더됩니다. 전달할 값과 만드는 방식을 확인하고, 최적화가 필요한지는 측정한 뒤에 판단합니다(React의 useContext 문서).
Jotai는 작은 상태 단위인 아톰과 그로부터 계산하는 아톰을 조합하므로, 컴포넌트가 실제로 쓰는 아톰을 중심으로 연결됩니다. 그래서 모든 도구를 하나의 Provider 값이나 중앙 스토어와 셀렉터의 흐름으로 설명할 수는 없습니다.
다음 글 기타 유용한 라이브러리에서는 라우팅, 데이터 요청, 폼, 스타일링처럼 프런트엔드 개발에서 자주 쓰는 다른 라이브러리들을 살펴봅니다.