상태 관리 도구
프롭스 드릴링 문제를 Context로 해결하고 서버 상태·전역 상태의 성격에 따라 TanStack Query와 상태 도구를 선택합니다.
앞선 절들에서는 useState와 props를 이용한 기본 상태 관리,
그리고 서버/클라이언트 컴포넌트 간 폼 처리 흐름을 살펴봤습니다.
하지만 애플리케이션이 커질수록
useState와 props만으로는 한계에 부딪히는 구간이 생깁니다.
트리가 깊어질 때 발생하는 프롭스 드릴링(Prop Drilling)과 컴포넌트 간 상태 공유 복잡성이 대표적인 문제입니다.
이러한 문제들을 해결하기 위해 React 생태계에서는 다양한 상태 관리 도구들이 발전해 왔습니다.
Next.js App Router 환경에서는 이 도구들을 클라이언트 컴포넌트 내에서 활용하여 전역 상태를 관리할 수 있습니다.
이 절에서는 React의 내장 Context API와 널리 사용되는 외부 상태 관리 라이브러리들에 대해 살펴보겠습니다.
React Context API
React Context API는 props를 명시적으로 전달하지 않고도 컴포넌트 트리를 가로질러 데이터를 공유할 수 있게 해 주는 React의 내장 기능입니다.
로그인한 사용자 정보, 테마(다크 모드/라이트 모드), 언어 설정 등과 같이 전역적으로 접근해야 하는 데이터에 특히 유용합니다.
Context API의 작동 원리createContext: 값을 전달할 Context 객체와 기본값을 정의합니다. 상태는 별도의 useState나 useReducer 등이 보관합니다.
Provider: Context 객체가 제공하는 Provider 컴포넌트를 사용하여 Context 값을 설정합니다.
하위 클라이언트 소비자는 같은 Context의 가장 가까운 Provider 값을 읽습니다. Server Component 자체는 이 Client Context를 소비하지 않습니다.
useContext: 자식 컴포넌트에서 useContext 훅을 사용하여 Provider가 제공한 Context 값을 읽습니다.
- 프롭스 드릴링 해결: 여러 레벨을 거쳐
props를 전달할 필요 없이, 필요한 컴포넌트에서 직접 전역 상태에 접근할 수 있습니다. - React 내장: 별도의 라이브러리 설치 없이 React 자체 기능으로 사용 가능합니다.
- 모든 컨텍스트 소비자 리렌더링: Context 값이 변경되면 해당 Context를 사용하는 모든 컴포넌트가 리렌더링될 수 있어, 불필요한 리렌더링이 발생할 가능성이 있습니다. (최적화가 필요할 수 있음)
- 상태 전환 로직은 별도: Context는 값 전달을 담당합니다. 복잡한 전환은 useReducer 등과 함께 구성할 수 있으며, 미들웨어·구독 최적화 요구에 따라 외부 도구를 비교합니다.
간단한 테마 토글 기능을 Context API를 사용하여 구현해 보겠습니다.
src/app/context-api/ThemeContext.tsx 파일 생성 (클라이언트 컴포넌트):
Context를 정의하고 Provider 컴포넌트를 만듭니다.
"use client";
import React, { createContext, useContext, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
// 1. Context 객체 생성 (기본값 설정)
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
interface ThemeProviderProps {
children: ReactNode;
}
// 2. Provider 컴포넌트 정의
export function ThemeProvider({ children }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>('light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 3. useContext 훅을 간편하게 사용할 커스텀 훅
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}src/app/context-api/ThemeToggler.tsx 파일 생성 (클라이언트 컴포넌트):
Context 값을 사용하는 컴포넌트입니다.
"use client";
import React from 'react';
import { useTheme } from './ThemeContext';
export default function ThemeToggler() {
const { theme, toggleTheme } = useTheme(); // useTheme 훅으로 Context 값 접근
return (
<button
onClick={toggleTheme}
style={{
padding: '10px 20px',
fontSize: '1em',
backgroundColor: theme === 'dark' ? '#555' : '#eee',
color: theme === 'dark' ? 'white' : '#333',
border: `1px solid ${theme === 'dark' ? '#777' : '#ccc'}`,
borderRadius: '5px',
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
>
현재 테마: {theme === 'light' ? '밝음 ☀️' : '어두움 🌙'}
</button>
);
}src/app/context-api/ThemePanel.tsx 파일 생성 (클라이언트 컴포넌트):
Context의 theme 값을 실제 DOM의 data-theme 속성과 패널 색상에 반영합니다.
"use client";
import { useTheme } from './ThemeContext';
import ThemeToggler from './ThemeToggler';
export default function ThemePanel() {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<section
data-theme={theme}
style={{
padding: '20px',
maxWidth: '600px',
margin: '20px auto',
border: `1px solid ${isDark ? '#555' : '#ccc'}`,
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '20px',
backgroundColor: isDark ? '#222' : '#fff',
color: isDark ? '#f0f0f0' : '#222',
transition: 'background-color 0.2s ease, color 0.2s ease',
}}
>
<h1>Context API를 이용한 테마 변경</h1>
<p>Provider의 상태를 읽는 패널이 실제 화면 테마를 적용합니다.</p>
<ThemeToggler />
<p>이 텍스트와 패널 배경은 테마에 따라 변경됩니다.</p>
</section>
);
}src/app/context-api/page.tsx 파일 생성 (서버 컴포넌트):
페이지 컴포넌트에서 ThemeProvider로 클라이언트 컴포넌트들을 감싸 줍니다.
import { ThemeProvider } from './ThemeContext'; // 클라이언트 Provider 임포트
import ThemePanel from './ThemePanel';
export default function ContextApiPage() {
return (
<ThemeProvider>
<ThemePanel />
</ThemeProvider>
);
}서버 페이지는 Provider 경계와 패널을 조합하고, 상태를 읽어 DOM을 바꾸는 코드는 ThemePanel 클라이언트 컴포넌트가 담당합니다.
실습 확인:
http://localhost:3000/context-api로 접속하여 현재 테마: 밝음 버튼을 클릭해 보세요.
버튼뿐 아니라 패널의 data-theme, 배경색, 테두리와 텍스트 색상이 함께 토글되는 것을 확인할 수 있습니다.
NEXT · CH8-4
theme 상태는 Provider에, 화면 반영은 소비자에 둡니다
선은 부모가 자식을 렌더링하는 관계입니다. ThemeProvider가 useState의 theme과 toggleTheme을 제공하고, ThemePanel·ThemeToggler가 useTheme으로 읽습니다.
서버 page는 Client Context를 소비하지 않습니다. ThemePanel은 data-theme·패널 색상을, ThemeToggler는 버튼과 토글을 담당합니다. 새 value 객체와 소비자 갱신 비용은 실제 측정하며 Context 추가만으로 렌더 횟수가 줄지는 않습니다.
예시
처음 theme은 light입니다. ThemeToggler의 버튼을 클릭해 toggleTheme을 실행하면 ThemeProvider 안의 updater가 light를 dark로 바꾸고 새 value를 제공합니다. ThemePanel은 그 값으로 data-theme·패널 색상을, ThemeToggler는 버튼 문구·색상을 바꿉니다. 서버 page가 useTheme으로 이 값을 읽는 단계는 없습니다. Context는 값의 전달 경로이고 theme 상태는 Provider 안의 useState가 보관합니다.
Provider를 두 개 두면 공유되는지 확인하기
같은 페이지에서 서로 형제인 ThemeProvider A·B가 각자 ThemePanel을 하나씩 감싼다고 가정합니다. 둘 다 원 코드의 light에서 시작하고, A 안의 버튼만 한 번 클릭합니다. A·B의 패널 색상은 각각 무엇일까요? B를 지우지 않은 채 A 바깥으로 옮긴 ThemeToggler가 어느 값을 읽을지도, 해당 위치 위에 다른 ThemeProvider가 없다는 조건에서 설명해 보세요.
가장 가까운 Provider와 상태 수명의 해설
각 ThemeProvider 인스턴스가 별도 useState를 갖기 때문에 A는 dark, B는 light입니다. 같은 Context 객체를 쓴다는 이유만으로 두 Provider의 상태가 하나가 되지는 않습니다. 각 소비자는 자신 위에 있는 가장 가까운 해당 Provider의 값을 읽습니다.
Provider 밖의 ThemeToggler는 기본값 undefined를 읽고, 원 useTheme의 검사에서 오류를 던집니다. 자동으로 B나 A를 검색해 가져오지 않습니다. 같은 값을 공유하고 싶다면 필요한 소비자를 하나의 공통 Provider 아래에 둡니다. Provider를 제거하고 다시 마운트하면 원 코드의 light에서 시작하며, 이 예제에는 저장된 테마를 복원하는 기능이 없습니다.
이 예제는 렌더링마다 새 value 객체를 만듭니다. 부모의 재렌더링까지 고려한 구독 비용은 실제로 측정하고 최적화하며, Context를 추가했다는 이유만으로 렌더링 횟수가 줄어들지는 않습니다.
테마 값과 value 객체의 정체성 구분하기
theme이 계속 "light"여도 { theme, toggleTheme }를 다시 만들면 이전 객체와 새 객체는 서로 다릅니다. React는 Provider의 이전·다음 value를 Object.is로 비교하므로 같은 테마 문자열이라는 이유만으로 Context 갱신이 생략되지는 않습니다. 소비자를 memo로 감싸도 새로운 Context 값을 받는 갱신까지 막지 않습니다. 필요하면 상태와 구독 범위를 나누거나 함수·value의 참조를 안정화하는 방식을 비교하되, 실제 비용이 없는 작은 예제에 최적화를 먼저 덧붙일 필요는 없습니다.
중첩한 Provider도 상태를 합치지 않습니다. 바깥 Provider가 light를 제공하고 안쪽 Provider가 dark를 제공하면, 안쪽의 ThemePanel은 가장 가까운 안쪽 값을 읽습니다. 같은 Context 객체는 값을 찾는 경로를 정하고 각 Provider 인스턴스의 상태 수명은 별도입니다.
외부 상태 관리 라이브러리
Context API는 간단한 전역 상태 관리에 적합하지만, 복잡한 비즈니스 로직이나 비동기 상태가 많아지면 외부 상태 관리 라이브러리를 검토합니다.
이 라이브러리의 React 상태 훅은 클라이언트 컴포넌트에서 사용합니다. TanStack Query처럼 서버 프리패치와 하이드레이션을 지원하는 도구도 있으므로 모든 준비 작업이 브라우저 전용이라는 뜻은 아닙니다.
가장 널리 사용되는 몇 가지 라이브러리는 다음과 같습니다.
TanStack Query (React Query)
TanStack Query (이전 React Query)는 주로 서버 상태(Server State) 관리에 특화된 라이브러리입니다.
fetch 등을 통해 서버에서 가져오는 비동기 데이터를 캐싱, 동기화, 업데이트하는 과정을 매우 효율적으로 관리해 줍니다.
데이터 로딩 상태(로딩 중, 에러, 성공), 재검증(refetching), 뮤테이션(mutation) 등을 자동으로 처리하여 개발자가 데이터 페칭 로직에 덜 신경 쓰도록 돕습니다.
주요 특징- 자동 캐싱 및 백그라운드 재검증: 오래된 데이터를 표시하면서 백그라운드에서 최신 데이터를 가져와 UI를 업데이트합니다.
- 간편한 로딩/에러/성공 상태 관리:
isLoading,isError,data와 같은 훅 반환값으로 쉽게 UI를 구성할 수 있습니다. - 뮤테이션: 서버 데이터 변경(POST, PUT, DELETE)을 위한 기능을 제공하고, 변경 후 onSuccess 등에서 invalidateQueries 또는 setQueryData를 명시적으로 호출해 관련 캐시를 갱신합니다.
- 개발자 도구: React Query Devtools는 캐시 상태를 시각적으로 확인하고 디버깅하는 데 도움이 됩니다.
- REST API 등 서버에서 데이터를 가져와야 할 때.
- 복잡한 캐싱 전략이 필요할 때.
- 로딩, 에러, 성공 상태를 일관되게 관리해야 할 때.
- 데이터 변경 후 UI 자동 업데이트가 중요할 때.
서버 변경과 조회 캐시 갱신은 별도 단계
재고 조회의 캐시가 10을 표시하고 서버 변경 요청이 성공해 DB 값이 9가 됐다고 합시다. 이 성공만으로 기존 조회 캐시의 값도 반드시 9가 되는 것은 아닙니다. 성공 후 해당 조회 키를 무효화해 다시 읽거나, 응답의 새 값을 적절한 캐시에 명시적으로 반영합니다. TanStack Query의 invalidateQueries는 일치하는 조회를 오래된 것으로 표시하고 기본적으로 활성 조회를 다시 읽게 할 수 있지만, 비활성·비활성화된 조회 등은 조건이 다릅니다. 재조회가 끝나기 전의 이전 값과 로딩·오류 상태도 화면에서 다룹니다. 로컬 상태 도구를 바꿨다는 사실만으로 서버 변경과 캐시 동기화가 완성되지는 않습니다.
Zustand / Jotai
이 라이브러리들은 아톰(Atom) 기반 또는 플럭스(Flux) 패턴 기반의 경량 상태 관리 솔루션입니다.
선택자·상태 단위·구독 방식에 따라 필요한 값을 따로 구독할 수 있고, 간결하게 상태를 공유하는 구성을 제공합니다. Context를 사용한 같은 화면보다 항상 빠르다고 결론 내리기보다는, 구독 범위와 상태 변경 패턴을 정해 실제 갱신 비용을 비교합니다.
- Zustand: 작고 간결한 훅 기반 store를 제공하며, 필요한 상태 조각을 selector로 구독합니다. Redux DevTools와 persist 같은 미들웨어를 선택적으로 붙일 수 있습니다.
- Jotai: 작은 상태 단위인 atom을 조합하는 모델을 사용합니다. 서로 독립적인 상태와 파생 상태가 많은 화면에 적합합니다.
Recoil 공식 저장소는 2025년 1월부터 읽기 전용으로 보관되어 신규 도입 전에 유지보수 상태를 확인해야 합니다.
기존 Recoil 프로젝트는 사용 범위와 React 호환성을 확인하고, 전환이 필요하다면 비용과 대안을 비교합니다.
주요 특징- 선택적 구독: 선택한 상태 조각이나 atom에 필요한 구독을 연결합니다. 선택값의 비교 방식·파생 상태·부모의 렌더링도 영향을 주므로, 해당 값이 바뀐 소비자만 언제나 렌더링된다거나 최소 횟수를 보장한다고 해석하지 않습니다.
- 간결한 API:
useState와 유사한 훅 기반의 API를 제공하여 학습 곡선이 낮습니다. - 상태 공유 용이: 같은 store 인스턴스나 Provider 범위에 연결된 소비자가 상태를 공유합니다. 다른 Provider·다른 store·서버 컴포넌트까지 자동으로 같은 값을 읽는 것은 아닙니다.
- 클라이언트 측에서만 관리되는 전역 상태가 많을 때 (예: UI 테마, 장바구니, 필터 설정).
- Context API의 리렌더링 성능이 문제가 될 때.
- Redux의 복잡성이 부담스러울 때.
Redux Toolkit
Redux Toolkit은 Redux를 더 쉽게 사용하도록 만들어진 공식 권장 도구 세트입니다.
여전히 Flux 아키텍처와 예측 가능한 상태 관리라는 Redux의 핵심 개념을 따르지만, 보일러플레이트 코드를 대폭 줄이고 개발자 경험을 향상시켰습니다.
주요 특징- 정형화된 상태 관리: 예측 가능하고 디버깅하기 쉬운 상태 변경 로직을 강제합니다.
- 미들웨어 지원: 비동기 작업(Redux Thunk, Redux Saga 등)이나 로깅, 분석 등 추가 기능을 미들웨어로 통합할 수 있습니다.
- DevTools: Redux DevTools는 상태 변화를 시간 순서로 추적하며 디버깅할 수 있는 기능을 제공합니다.
- 상태 관리 로직이 매우 복잡하고 광범위한 대규모 애플리케이션.
- 명확한 상태 변경 로직과 중앙 집중식 상태 관리가 필요한 경우.
- Redux 생태계의 다양한 미들웨어 및 확장 도구를 활용하고 싶을 때.
App Router에서의 상태 관리 전략 선택
Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 역할 분담을 고려하여 상태 관리 도구를 선택하는 것이 중요합니다.
-
서버 컴포넌트
- 상호작용 상태를
useState로 보관하거나useEffect로 관리하지 않습니다. 요청 중의 지역 변수·계산과 서버 데이터 읽기까지 금지한다는 뜻은 아닙니다. - 데이터 페칭은
async/await를 사용하여 서버에서 직접 수행합니다. - Layout·Page는 필요한 데이터를 props로 전달하거나 Client Provider에 초기값을 줄 수 있습니다. Client Provider가 서버에서 만들어진 children을 감싸더라도 Server Component에 Context 값을 전달하는 것은 아닙니다.
- 상호작용 상태를
-
클라이언트 컴포넌트
- 사용자 상호작용에 따른 동적 상태 변화를 담당합니다.
useState,useContext를 사용하거나, TanStack Query, Zustand, Jotai, Redux Toolkit과 같은 외부 라이브러리를 사용하여 클라이언트 측 상태를 관리합니다.
브라우저의 공유 상태와 서버 프로세스의 전역 값 구분하기 App Router의 초기 서버 렌더링에서는 여러 사용자 요청이 같은 서버 프로세스를 사용할 수 있습니다. 사용자별 초깃값을 모듈의 하나뿐인 store에 저장하면 다른 요청의 값과 섞일 수 있으므로, SSR에 쓰는 store·조회 클라이언트는 요청별 범위를 정합니다. 브라우저에서는 필요한 소비자가 같은 인스턴스를 공유하고, 서버 HTML과 첫 클라이언트 출력의 초기 데이터를 맞춥니다. RSC가 사용자별 전역 UI store를 읽거나 수정하도록 만들지 않습니다. 클라이언트의 사용자 정보·장바구니 상태는 화면용 정보이며, 서버 작업의 인증·소유권 검사는 별도로 수행합니다.
도구 선택 전에 상태의 원본, 공유 범위와 최신성 요구를 정합니다. 입력 중인 값은 로컬 상태로 충분할 수 있고, 서버에서 가져온 데이터의 캐시와 브라우저 UI 상태는 서로 다른 책임입니다.
Context 소비 범위는 React useContext, 변경 뒤 캐시 갱신은 TanStack Query mutation 무효화, 서버 준비는 서버 렌더링 안내를 기준으로 확인합니다.
추가 연습: 상태의 범위와 갱신 조건 찾기
- 같은 Context로 만든 바깥 Provider A와 그 안의 Provider B가 모두 light에서 시작합니다. B의 버튼을 한 번, A에 직접 속한 버튼을 두 번 누릅니다. B 안의 ThemePanel은 어느 테마를 읽을까요? 각 클릭은 해당 Provider의 원
toggleTheme만 실행한다고 가정합니다. - 부모가 다시 렌더링돼 원 ThemeProvider가 새
{ theme, toggleTheme }를 만들었습니다. theme은 여전히 light입니다. 이전·다음 value 객체를Object.is로 비교하면 같을까요? 소비자의memo만으로 Context 값 갱신을 막을까요? - 재고 조회 캐시에는 10이 있습니다. 서버 변경은 성공해 DB를 9로 바꿨지만 조회 캐시를 갱신하지 않았고, 별도의 자동 재조회도 일어나지 않았습니다. 성공 메시지만으로 화면의 조회값이 9라고 할 수 있을까요? 필요한 단계를 설명하세요.
- SSR 요청 A와 B가 한 서버 프로세스의 사용자별 UI store를 공유합니다. A가 저장한 초깃값을 B가 읽을 수 있는 구조라면 어떤 범위를 바꿔야 할까요? 브라우저 store에 로그인한 사용자 ID가 있다는 것만으로 서버 작업 권한도 인정할까요?
Provider·참조·캐시·요청 범위 풀이
- B는 light에서 dark가 되고, A는 light→dark→light로 돌아옵니다. B 안의 패널은 가장 가까운 B의 dark를 읽습니다. A의 상태 전환이 B의 별도 상태를 덮거나 B가 두 상태를 합치는 것은 아닙니다.
- 다릅니다. 새 객체는 이전 객체와 같은 참조가 아니므로
Object.is는 false입니다. Context 소비자는 새 value를 받으며memo만으로 이를 막지 않습니다. 테마 문자열의 동등성과 value 객체의 동등성을 구분하고, 필요할 때 실제 갱신 비용에 맞춰 참조·구독 범위를 검토합니다. - 할 수 없습니다. 제시된 조건에서는 조회 캐시가 여전히 10일 수 있습니다. 성공 뒤 관련 조회 키를 무효화해 다시 읽거나 응답의 새 값을 캐시에 명시적으로 반영합니다. 실제 재조회·반영이 끝나 표시값이 9로 바뀌었는지까지 확인합니다. 서버 변경의 성공과 클라이언트 캐시 동기화는 별도 관측입니다.
- SSR에 쓰는 사용자별 store를 요청마다 독립적으로 만들고 초기 데이터가 다른 요청에 섞이지 않게 합니다. 브라우저에서 공유할 범위는 별도로 정합니다. 사용자 ID가 UI store에 있다는 사실은 인증 증명이 아니므로 서버는 검증한 세션과 해당 작업의 권한을 확인해야 합니다.