PromleeBlog
sitemapaboutMe

posting thumbnail
React 글로벌 상태 관리 (원리부터 라이브러리까지) - React, 알고 쓰자 5일차
React Global State Management (Principles to Libraries) - React Explained Day 5

📅

🚀

들어가기 전에🔗

지금까지 우리는 컴포넌트 내부 상태(useState, useReducer)와 React의 렌더링 및 업데이트 메커니즘에 대해 배웠습니다. 하지만 애플리케이션 규모가 커지면 여러 컴포넌트가 공유해야 하는
전역적인 상태
가 생기기 마련입니다.
예를 들어, 사용자 로그인 정보, 테마 설정, 장바구니 내용 등이 그렇습니다.
이런 상태들을 관리하기 위해 단순히 props를 계속해서 아래 컴포넌트로 전달하는 방식(prop drilling)은 매우 번거롭고 유지보수가 어렵습니다. 오늘은 이렇게 여러 컴포넌트가 함께 사용하는 상태, 즉
글로벌 상태
를 효과적으로 관리하는 다양한 패턴과 도구들을 살펴보겠습니다.React에 내장된 Context API부터 시작해서, 인기 있는 외부 라이브러리들, 그리고 서버 데이터 관리에 특화된 솔루션까지 함께 알아볼 예정입니다.
단순히 도구를 사용하는 것을 넘어, 그 도구들이
어떻게
동작하는지 내부 원리까지 파헤쳐 보겠습니다.

🚀

Context API: 기본기와 최적화🔗

React의 Context API는 props drilling 없이 컴포넌트 트리 전체에 걸쳐 데이터를 전달할 수 있는 공식적인 방법입니다.
Provider가 제공하는 데이터를 트리 내의 어떤 컴포넌트든 Consumer나 useContext 훅을 통해 구독하여 사용할 수 있습니다.

기본 사용법과 문제점🔗

Context API를 사용하는 것은 비교적 간단합니다.
먼저 createContext 함수로 Context 객체를 만듭니다.
import React, { createContext, useState, useContext, useMemo } from 'react'; // useMemo 추가
 
// 1. Context 생성 (기본값 설정 가능)
interface UserContextType {
  username: string | null;
  login: (name: string) => void;
  logout: () => void;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
 
// 2. Provider 컴포넌트 생성 (상태와 상태 변경 함수 제공)
export function UserProvider({ children }: { children: React.ReactNode }) {
  const [username, setUsername] = useState<string | null>(null);
 
  const login = (name: string) => setUsername(name);
  const logout = () => setUsername(null);
 
  // value 객체 메모이제이션 적용
  const value = useMemo(() => ({ username, login, logout }), [username]);
 
  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
 
// 3. useContext 훅으로 Context 값 사용
export function useUser() {
  const context = useContext(UserContext);
  if (context === undefined) {
    throw new Error('useUser must be used within a UserProvider');
  }
  return context;
}
 
// 사용 예시 컴포넌트
function UserProfile() {
  const { username, logout } = useUser();
  return username ? (
    <div>
      <span>안녕하세요, {username}님!</span>
      <button onClick={logout}>로그아웃</button>
    </div>
  ) : (
    <span>로그인이 필요합니다.</span>
  );
}
 
// 앱 최상단 등에서 Provider로 감싸기
function App() {
  return (
    <UserProvider>
      {/* ... 다른 컴포넌트들 ... */}
      <UserProfile />
    </UserProvider>
  );
}
하지만 기본적인 Context API 사용에는 주의할 점이 있습니다.
Provider의 value prop이 변경되면, 해당 Context를 구독하는 모든 자식 컴포넌트들이
불필요하게 리렌더링
될 수 있다는 것입니다.
특히 Provider의 value로 객체를 직접 전달하고, 그 객체가 렌더링마다 새로 생성된다면 이 문제는 더욱 심각해집니다.
하위 객체의 리렌더링
하위 객체의 리렌더링

Provider 분리와 React.memo🔗

불필요한 리렌더링을 막는 몇 가지 최적화 기법이 있습니다.
Q: "Context API의 성능 문제점과 최적화 방법은 무엇인가요?"
A: "Provider의 value 변경 시 모든 Consumer의 불필요한 리렌더링이 발생할 수 있습니다. 최적화 방법으로는 value 메모이제이션(useMemo), Context 분리, 상태 읽기/변경 함수 분리 등이 있습니다."

상태 분리 전략🔗

Context를 설계할 때는 어떤 상태들을 함께 묶을지 신중하게 결정해야 합니다. 자주 함께 변경되는 상태들은 하나의 Context에 두는 것이 관리하기 편할 수 있습니다.
하지만 서로 관련 없는 상태들은 별도의 Context로 분리하여, 한 상태의 변경이 다른 상태를 사용하는 컴포넌트에 영향을 주지 않도록 하는 것이 성능상 유리합니다.

🚀

인기 상태 관리 라이브러리 비교🔗

Context API만으로도 글로벌 상태 관리가 가능하지만, 애플리케이션이 복잡해지면 상태 로직 관리, 비동기 처리, 미들웨어 연동 등 더 많은 기능이 필요해집니다.
이럴 때 상태 관리 라이브러리가 좋은 해결책이 될 수 있습니다. 대표적인 라이브러리 몇 가지를 비교해 보겠습니다.

Redux Toolkit🔗

Redux는 오랫동안 React 생태계에서 가장 많이 사용된 상태 관리 라이브러리 중 하나입니다.
Flux 아키텍처에 기반하여
단일 스토어(single store)
예측 가능한 상태 변화
(액션 -> 리듀서 -> 상태)를 강조합니다.
Redux Toolkit은 기존 Redux의 복잡한 설정과 보일러플레이트 코드를 줄여 개발 경험을 크게 개선한 공식 라이브러리입니다.
// Redux Toolkit 예시 (Slice 정의)
import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux'; // useSelector, useDispatch 추가
 
interface CounterState {
  value: number;
}
 
const initialState: CounterState = { value: 0 };
 
const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: state => { state.value += 1; }, // immer 내장으로 불변성 관리 용이
    decrement: state => { state.value -= 1; },
    incrementByAmount: (state, action: PayloadAction<number>) => { // 액션 페이로드 타입 지정
      state.value += action.payload;
    },
  },
});
 
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
 
// 스토어 설정
export const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
  },
});
 
// RootState 타입 정의 (스토어 상태 전체 타입)
export type RootState = ReturnType<typeof store.getState>;
// AppDispatch 타입 정의 (디스패치 함수 타입)
export type AppDispatch = typeof store.dispatch;
 
// 컴포넌트에서 사용
function CounterComponent() {
  // useSelector 사용 시 RootState 타입 지정
  const count = useSelector((state: RootState) => state.counter.value);
  // useDispatch 사용 시 AppDispatch 타입 사용 (선택적이지만 권장)
  const dispatch: AppDispatch = useDispatch();
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>증가</button>
      <button onClick={() => dispatch(decrement())}>감소</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>+5 증가</button>
    </div>
  );
}

Zustand: 단순함과 유연함🔗

Zustand는 Redux보다 훨씬 단순하고 직관적인 API를 제공하는 경량 상태 관리 라이브러리입니다.
훅(hook) 기반으로 동작하며, 최소한의 보일러플레이트로 상태 관리를 시작할 수 있습니다.
// Zustand 예시 (Store 생성)
import create from 'zustand';
 
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}
 
const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));
 
// 컴포넌트에서 사용
function CounterComponent() {
  // 필요한 상태와 액션만 선택하여 구독 (렌더링 최적화)
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);
  // 또는 아래처럼 객체로 받아올 경우, count가 변하지 않아도 다른 상태 변경 시 리렌더링 될 수 있음 (shallow 옵션 고려)
  // const { count, increment, decrement } = useCounterStore();
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>증가</button>
      <button onClick={decrement}>감소</button>
    </div>
  );
}

Recoil: 원자적 상태와 비동기 처리🔗

Recoil은 Facebook(Meta)에서 개발한 상태 관리 라이브러리로, React의 내부 동작 방식과 잘 어울리도록 설계되었습니다.
상태를 *원자(Atom)*라는 작은 단위로 나누어 관리하며, 파생된 상태(Selector)나 비동기 처리 기능을 내장하고 있는 것이 특징입니다.
// Recoil 예시 (Atom 정의)
import React from 'react'; // React 임포트 추가
import { RecoilRoot, atom, selector, useRecoilState, useRecoilValue } from 'recoil'; // RecoilRoot 추가
 
const counterState = atom({
  key: 'counterState', // 고유한 ID
  default: 0,
});
 
// 파생 상태 예시 (Selector)
const doubledCounterState = selector({
  key: 'doubledCounterState',
  get: ({ get }) => {
    const count = get(counterState);
    return count * 2;
  },
});
 
// 컴포넌트에서 사용
function CounterComponent() {
  const [count, setCount] = useRecoilState(counterState); // 상태 값과 setter 함수
  const doubledCount = useRecoilValue(doubledCounterState); // 읽기 전용 값
 
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled Count: {doubledCount}</p>
      <button onClick={() => setCount(prev => prev + 1)}>증가</button> {/* 함수형 업데이트 권장 */}
      <button onClick={() => setCount(prev => prev - 1)}>감소</button>
    </div>
  );
}
 
// 앱 최상단 등에서 RecoilRoot로 감싸기
function AppRecoil() {
  return (
    <RecoilRoot>
      <CounterComponent />
    </RecoilRoot>
  );
}

어떤 것을 선택해야 할까요? (비교 요약)🔗

Q: "여러 상태 관리 라이브러리(Redux, Zustand, Recoil 등)의 특징과 장단점을 비교 설명해주세요."
A: 각 라이브러리의 핵심 철학, 장단점, 주요 사용 사례
라이브러리특징장점단점
Context API
React 내장 전역 상태 관리 도구별도 라이브러리 설치 불필요
구조가 단순함
최적화 어려움 (불필요 리렌더링 발생 가능)
큰 프로젝트에선 비효율적
Redux Toolkit
Redux 공식 권장 툴킷
Predictable(예측 가능)
강력한 DevTools 지원
미들웨어 및 비동기 처리에 강점
코드가 장황해질 수 있음
비교적 러닝커브 존재
Zustand
간단하고 유연한 경량 상태 관리
Flux 패턴 불필요
사용법이 매우 직관적
필요한 부분만 리렌더링 가능
대규모 앱 아키텍처 설계시 주의 필요
DevTools 기능이 제한적
Recoil
Atom/Selector 단위 상태 관리
비동기 처리를 내장 지원
미세한 단위로 상태 분리 가능
비동기 상태 관리 편리
아직 완전한 안정성 확보는 아님
라이브러리 크기가 다소 있음

🚀

글로벌 상태 관리, 어떻게 동작할까요?🔗

우리가 살펴본 Context API나 여러 상태 관리 라이브러리들은 어떻게 컴포넌트 트리 어디에서든 상태에 접근하고, 상태가 변경되었을 때 필요한 컴포넌트만 콕 집어 업데이트할 수 있을까요?
그 핵심에는 몇 가지 공통적인 원리가 숨어 있습니다.

핵심 원리: 외부 저장소와 구독 시스템🔗

대부분의 글로벌 상태 관리 도구는 다음과 같은 기본 아이디어를 따릅니다.
  1. 상태 저장소 (Store)
    :
    • 컴포넌트 트리와는 별개로, 애플리케이션의 공유 상태를 저장하는 '장소'를 만듭니다.
    • 이 저장소는 일반적인 JavaScript 객체, 클래스 인스턴스 또는 클로저를 활용한 모듈 형태일 수 있습니다.
    • 중요한 것은 이 저장소가 컴포넌트의 생명주기에 직접적으로 묶여 있지 않다는 점입니다.
  2. 상태 접근 및 변경 API
    :
    • 컴포넌트가 이 저장소의 상태를 읽거나 변경할 수 있는 명확한 방법을 제공합니다.
    • 상태를 읽을 때는 주로 훅(hook)이나 특정 함수를 사용합니다 (예: useContext, useSelector, useRecoilValue).
    • 상태를 변경할 때는 정해진 규칙을 따르는 함수나 메서드를 호출합니다 (예: dispatch, setState, action). 이는 상태 변경을 예측 가능하게 하고 추적하기 쉽게 만듭니다.
  3. 구독 (Subscription) 메커니즘
    :
    • 상태를 사용하는 컴포넌트는 이 저장소의 변경 사항을 '구독'합니다.
    • 즉, "이 상태가 바뀌면 나에게 알려줘!"라고 저장소에 등록하는 과정입니다.
    • 저장소는 자신을 구독하고 있는 컴포넌트(또는 콜백 함수) 목록을 내부적으로 관리합니다.
    • 이는 디자인 패턴 중
      옵저버 패턴(Observer Pattern)
      또는 *발행-구독 패턴(Publish-Subscribe Pattern)*과 매우 유사합니다.
  4. 변경 알림 및 리렌더링
    :
    • 저장소의 상태가 변경되면, 저장소는 자신을 구독하고 있는 컴포넌트들에게 변경 사실을 알립니다.
    • 알림을 받은 컴포넌트는 React에게 리렌더링을 요청하게 되고, 변경된 상태를 반영하여 화면이 업데이트됩니다.
Subscribe 시각화
Subscribe 시각화

Context API는 어떻게 동작할까요?🔗

Context API는 React 자체에 내장된 메커니즘을 활용합니다.

라이브러리는 어떻게 최적화할까요? 선택자(Selector)의 역할🔗

Redux, Zustand, Recoil 같은 라이브러리들은 Context API의 기본적인 리렌더링 문제를 해결하기 위해 더 정교한 메커니즘을 사용합니다.
핵심은
선택자(Selector)
메모이제이션(Memoization)
입니다.
이 선택자 기반의 구독 및 최적화 덕분에, 상태 저장소의 일부만 변경되더라도 전체 상태를 구독하는 것이 아니라, 변경된 데이터 조각을 실제로 사용하는 컴포넌트만 정확하게 리렌더링할 수 있습니다.
Zustand나 Recoil(Atom 단위 구독)도 내부적으로 유사한 원리를 통해 불필요한 리렌더링을 최소화합니다.
상태 저장소는 내부적으로 상태를
트리(Tree)
해시 테이블(Hash Table/Map)
형태로 관리할 수 있습니다. 구독자 목록은
배열(Array)
이나
연결 리스트(Linked List)
로 관리될 수 있습니다. 선택자의 메모이제이션은
캐시(Cache)
메커니즘을 활용합니다.
이처럼 글로벌 상태 관리 도구들은 단순히 상태를 한 곳에 모아두는 것을 넘어, 효율적인 구독 관리와 선택적 리렌더링을 위한 정교한 내부 메커니즘을 가지고 있습니다.
이 원리를 이해하면 각 도구의 장단점을 더 깊이 파악하고, 성능 문제를 진단하거나 최적화하는 데 큰 도움이 될 것입니다.

🚀

서버 상태 관리: 클라이언트 상태와 분리하기🔗

애플리케이션의 상태는 크게 두 종류로 나눌 수 있습니다.
앞서 다룬 Context API나 Redux 등은 주로 클라이언트 상태 관리에 적합합니다.
서버 상태는 로딩 상태, 에러 처리, 캐싱, 데이터 동기화, 재요청 등 고려해야 할 점이 훨씬 많기 때문에, 이를 위한 전문적인 도구를 사용하는 것이 효율적입니다.

왜 서버 상태 관리가 필요할까요?🔗

서버에서 가져온 데이터를 단순히 useState나 Redux 스토어에 저장하는 것만으로는 부족할 때가 많습니다.
이런 복잡한 요구사항들을 직접 구현하는 것은 매우 어렵고 반복적인 작업입니다.

React Query / SWR 핵심 개념🔗

React Query와 SWR은 이런 서버 상태 관리를 매우 편리하게 만들어주는 대표적인 라이브러리입니다.
두 라이브러리는 유사한 철학을 공유하며 다음과 같은 핵심 기능을 제공합니다.
➡️

캐싱과 키 관리🔗

데이터를 가져오는 요청마다 고유한
키(key)
를 부여합니다. 라이브러리는 이 키를 기반으로 가져온 데이터를 내부 캐시에 저장합니다.
동일한 키로 다시 데이터를 요청하면, 네트워크 요청을 보내는 대신 캐시된 데이터를 즉시 반환하여 빠른 UI 응답성을 제공합니다.
키는 문자열이나 배열 형태로 정의할 수 있으며, 요청 매개변수를 포함하여 동적으로 생성하는 경우가 많습니다.
// 예시: API 호출 함수 (가정)
async function fetchTodos(status?: string) {
  const url = status ? `/api/todos?status=${status}` : '/api/todos';
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
}
 
// React Query 예시
import { useQuery } from 'react-query'; // TanStack Query v4 기준, v5는 @tanstack/react-query
// 키: ['todos', { status: 'completed' }] -> 쿼리 함수에 객체로 전달됨
const { data, isLoading, error } = useQuery(
  ['todos', { status: 'completed' }],
  () => fetchTodos('completed') // 쿼리 함수는 Promise를 반환해야 함
);
 
// SWR 예시
import useSWR from 'swr';
// fetcher 함수 정의 (SWR은 키를 fetcher의 첫 인자로 전달)
const fetcher = (url: string) => fetch(url).then(res => {
  if (!res.ok) throw new Error('An error occurred while fetching the data.');
  return res.json();
});
// 키: '/api/todos?status=completed' -> fetcher 함수에 url로 전달됨
const { data: swrData, error: swrError } = useSWR('/api/todos?status=completed', fetcher);
➡️

데이터 재검증 (Revalidation)🔗

캐시된 데이터가 항상 최신 상태임을 보장하기 위해, 특정 조건에서 데이터를 자동으로 다시 가져오는
재검증(revalidation)
기능을 수행합니다.
이런 자동 재검증 덕분에 개발자는 수동으로 데이터 동기화 로직을 작성할 필요 없이 항상 최신 데이터를 사용자에게 보여줄 수 있습니다.
물론 이런 옵션들은 필요에 따라 커스터마이징 가능합니다.
서버 상태 관리 라이브러리의 동작 흐름도
서버 상태 관리 라이브러리의 동작 흐름도

🚀

결론🔗

오늘은 React 애플리케이션의 글로벌 상태를 관리하는 다양한 방법들과 그 내부 동작 원리를 살펴보았습니다.
상태 관리는 React 개발의 핵심적인 부분이며, 어떤 도구를 선택하고 그 원리를 이해하는지가 애플리케이션의 구조, 성능, 유지보수성에 큰 영향을 미칩니다.
오늘 배운 내용들을 바탕으로 여러분의 프로젝트에 가장 적합한 상태 관리 전략을 수립하는 데 도움이 되기를 바랍니다.
다음 시간에는 React Router를 사용한
라우팅 시스템
에 대해 알아보겠습니다. 사용자 요청에 따라 다른 페이지(컴포넌트)를 보여주는 방법과 중첩 라우트, 코드 스플리팅 등 관련 개념들을 함께 살펴보겠습니다.

참고🔗