프론트엔드 상태관리 가이드

2025년 11월 19일

프론트엔드 상태관리 가이드

1. 상태의 종류

프론트엔드 개발에서 상태는 크게 두 가지로 나뉩니다:

클라이언트 상태

UI 상태, 폼 입력값, 모달 열림/닫힘, 테마 설정 등 애플리케이션 내부에서만 유효한 상태입니다. 클라이언트 상태는 애플리케이션이 실행되는 동안 메모리에만 존재하며, 페이지를 새로고침하면 초기화됩니다.

예시:

  • 사용자 인터페이스 상태 (모달 열림/닫힘, 사이드바 토글)

  • 폼 입력값

  • 테마 설정 (다크모드/라이트모드)

  • 언어 설정

  • 로컬 UI 상태

서버 상태

API에서 가져온 데이터로, 항상 최신 상태가 아닐 수 있으며 캐싱과 동기화가 필요합니다. 서버 상태는 서버에 저장되어 있으며, 클라이언트는 이를 가져와서 사용합니다.

예시:

  • 사용자 프로필 정보

  • 게시글 목록

  • 상품 데이터

  • 실시간 채팅 메시지


2. 클라이언트 상태 관리

2.1 기존 Context API의 한계

React의 Context API는 전역 상태를 공유하는 기본적인 방법이지만, 몇 가지 문제점이 있습니다:

1. 불필요한 리렌더링 문제


// Context API 예시
const AppContext = createContext({
  user: null,
  theme: 'light',
  cart: [],
  notifications: [],
});

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [cart, setCart] = useState([]);
  const [notifications, setNotifications] = useState([]);

 // 하나의 값만 변경되어도 전체 value 객체가 새로 생성됨
  const value = {
    user,
    theme,
    cart,
    notifications,
    setUser,
    setTheme,
    setCart,
    setNotifications,
  };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

// 문제: theme만 변경되어도 cart를 사용하는 컴포넌트도 리렌더링됨!
function CartIcon() {
  const { cart } = useContext(AppContext);
  // theme이 변경되어도 이 컴포넌트가 리렌더링됨 😱
  return <span>{cart.length}</span>;
}

2. Provider Hell(Provider 지옥)

불필요한 리렌더링을 방지하려면 Context를 분리해야 하는데, 이렇게 되면 Provider가 중첩되어 복잡해집니다:


function App() {
  return (
    <ThemeProvider>
      <AuthProvider>
        <CartProvider>
          <NotificationProvider>
            <LanguageProvider>
              <UserProvider>
                <SettingsProvider>
                  {/* 실제 앱 컴포넌트 */}
                  <MainContent />
                </SettingsProvider>
              </UserProvider>
            </LanguageProvider>
          </NotificationProvider>
        </CartProvider>
      </AuthProvider>
    </ThemeProvider>
  );
}

Context의 value 객체가 변경되면 모든 Consumer가 리렌더링 됩니다. 그렇다고 Context를 분리해도 Provider 중첩이 복잡해지는 Provider Hell이 발생하기 때문에 추천하지는 않습니다. 메모이제이션으로 불필요한 리렌더링 방지를 할 수 있지만 코드가 복잡해집니다.

2.2 클라이언트 상태 관리 패턴

이러한 문제를 해결하기 위해 여러 상태관리 라이브러리가 등장했습니다. 프론트엔드 상태관리의 주요 아키텍처 패턴은 크게 3가지로 분류됩니다:

1. Flux Pattern (플럭스 패턴)

  • 개념: 단방향 데이터 흐름을 강제하는 아키텍처

  • 흐름: View → Action → Dispatcher → Store → View

  • 특징:

  • 모든 상태 변경이 Action을 통해 추적 가능 (예측 가능)

  • Redux DevTools로 디버깅 용이

  • 보일러플레이트가 많음 (Action, Reducer, Store 설정 필요)

  • 대표 라이브러리: Redux, Zustand

실전 예시


// 1. Action 정의
const increment = () => ({ type: 'INCREMENT' });
const decrement = () => ({ type: 'DECREMENT' });

// 2. Reducer (순수 함수)
function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    default:
      return state;
  }
}

// 3. Store 생성
import { createStore } from 'redux';
const store = createStore(counterReducer);

// 4. 컴포넌트에서 사용
function Counter() {
  const count = useSelector(state => state.count);
  const dispatch = useDispatch();
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
    </div>
  );
}

핵심 요약: Flux Pattern은 모든 상태 변경을 Action으로 추적하여 예측 가능한 상태 관리를 제공합니다. Redux가 대표적이며, 대규모 프로젝트에서 안정적인 상태 관리가 필요할 때 적합합니다.

2. Atomic Pattern (원자적 패턴)

  • 개념: 상태를 작은 단위(atom)로 분리하여 관리

  • 특징:

  • 상향식(bottom-up) 접근: 작은 atom을 조합하여 복잡한 상태 구성

  • 세밀한 구독: 필요한 atom만 구독하여 불필요한 리렌더링 최소화

  • 파생 상태 자동 계산: 의존하는 atom이 변경되면 자동으로 재계산

  • 대표 라이브러리: Jotai, Recoil

실전 예시


import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';

// 기본 atom
const countState = atom({
  key: 'countState',
  default: 0
});

// 파생 상태 (selector)
const doubleCountState = selector({
  key: 'doubleCountState',
  get: ({ get }) => get(countState) * 2
});

function Counter() {
  const [count, setCount] = useRecoilState(countState);
  const doubleCount = useRecoilValue(doubleCountState);
  
  return (
    <div>
      <p>Count: {count}</p>
      <p>Double: {doubleCount}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}

핵심 요약: Atomic Pattern은 상태를 작은 단위로 분리하여 필요한 부분만 구독할 수 있게 합니다. 복잡한 파생 상태나 상태 간 의존성이 많은 경우에 적합하며, Jotai와 Recoil이 대표적입니다.

3. Proxy Pattern (프록시 패턴)

  • 개념: JavaScript의 Proxy 객체를 사용하여 상태 변경을 자동 감지

  • 특징:

  • 일반 객체처럼 사용 가능 (state.count++)

  • 자동 추적: 어떤 속성이 사용되었는지 자동으로 추적

  • 직관적이지만 Proxy 동작을 이해하지 못하면 예상치 못한 동작 가능

  • 대표 라이브러리:

  • Valtio: 비교적 새로운 라이브러리로 사용자가 점차 늘어나고 있음

  • MobX: 한때 널리 사용되었지만 현재는 사용 빈도가 줄어든 추세

실전 예시: Valtio (Proxy Pattern)


import { proxy, useSnapshot } from 'valtio';

// 일반 객체처럼 상태 정의
const state = proxy({
  count: 0,
  name: 'John',
  increment: () => {
    state.count++; // 직접 수정 가능!
  },
  decrement: () => {
    state.count--;
  }
});

function Counter() {
  const snap = useSnapshot(state); // Proxy를 스냅샷으로 변환
  
  return (
    <div>
      <p>Count: {snap.count}</p>
      <p>Name: {snap.name}</p>
      <button onClick={() => state.increment()}>+1</button>
      <button onClick={() => state.decrement()}>-1</button>
    </div>
  );
}

// React 외부에서도 직접 접근 가능
state.count = 10; // 자동으로 리렌더링 트리거!

실전 예시: MobX (Proxy Pattern)


import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

// Store 클래스
class CounterStore {
  count = 0;
  name = 'John';
  
  constructor() {
    makeAutoObservable(this);
  }
  
  increment() {
    this.count++;
  }
  
  decrement() {
    this.count--;
  }
}

const store = new CounterStore();

// 컴포넌트
const Counter = observer(() => {
  return (
    <div>
      <p>Count: {store.count}</p>
      <p>Name: {store.name}</p>
      <button onClick={() => store.increment()}>+1</button>
      <button onClick={() => store.decrement()}>-1</button>
    </div>
  );
});

핵심 요약: Proxy Pattern은 일반 객체처럼 상태를 다룰 수 있어 직관적입니다. JavaScript의 Proxy를 활용하여 자동으로 변경을 감지하지만, Proxy 동작을 이해하지 못하면 예상치 못한 버그가 발생할 수 있습니다. Valtio와 MobX가 대표적입니다.

2.3 클라이언트 상태 관리 라이브러리

최근에 여러가지 상태관리 라이브러리가 나왔고, 그 중에 러닝커브가 낮고 상대적으로 많이 각광받고 있는 라이브러리들을 소개해드리겠습니다.

Redux와 Recoil도 좋은 상태관리 라이브러리이지만, Redux는 최근에 감소하는 추세이고 보일러플레이트가 많고 오래된 상태관리 라이브러리입니다. 그리고 Recoil은 더이상 업데이트가 되지 않고 Jotai로 마이그레이션 되는 추세이기에 생략하도록 하겠습니다.

Jotai

  • "원자적(atomic)" 상태 관리를 지향합니다.

  • Recoil에서 영감을 받아 만들어졌으며, 상태를 작은 단위의 atom으로 나누어 관리합니다.

  • "상향식(bottom-up)" 접근 방식을 채택하여, 필요한 만큼만 상태를 정의하고 조합할 수 있습니다.

  • React 내부에서 관리됩니다.

적용예시


import { atom, useAtom } from 'jotai'

const countAtom = atom(0)
const doubleCountAtom = atom((get) => get(countAtom) * 2)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const [doubleCount] = useAtom(doubleCountAtom)
  
  return (
    <div>
      <p>Count: {count}</p>
      <p>Double: {doubleCount}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

Jotai가 적합한 경우

상태 간 의존성이 복잡한 경우

컴포넌트별로 독립적인 상태가 많은 경우

파생 상태(derived state)를 많이 사용하는 경우


// atoms/dashboard.ts
import { atom } from 'jotai';

// 기본 데이터
export const salesAtom = atom<number[]>([]);
export const expensesAtom = atom<number[]>([]);
export const usersAtom = atom<User[]>([]);

// 파생 상태들 - 자동으로 의존성 추적
export const totalSalesAtom = atom((get) => 
  get(salesAtom).reduce((sum, val) => sum + val, 0)
);

export const totalExpensesAtom = atom((get) => 
  get(expensesAtom).reduce((sum, val) => sum + val, 0)
);

export const profitAtom = atom((get) => 
  get(totalSalesAtom) - get(totalExpensesAtom)
);

export const profitMarginAtom = atom((get) => {
  const sales = get(totalSalesAtom);
  return sales > 0 ? (get(profitAtom) / sales) * 100 : 0;
});

// 여러 atom을 조합한 복잡한 계산
export const userEngagementAtom = atom((get) => {
  const users = get(usersAtom);
  const sales = get(totalSalesAtom);
  return users.length > 0 ? sales / users.length : 0;
});

// atom간 의존성이 자동 추적됨
// salesAtom이 변경되면 관련 파생 atom만 업데이트
// 각 통계를 독립적으로 관리하고 조합 가능

Zustand

  • "단순함"을 최우선 가치로 삼습니다.

  • Redux의 복잡성에 지친 개발자들을 위해 만들어졌으며, 보일러플레이트 없이 전역 상태를 관리할 수 있습니다. (작은 상태 관리를 설정할때도, action, reducer, dispatch뿐만 아니라, store나 타입 설정도 해야 됨)

  • "하향식(top-down)" 접근으로 중앙 집중식 스토어를 만들지만, Redux보다 훨씬 가볍습니다.

  • React 외부에서 관리됩니다

적용예시


import create from 'zustand'

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}))

function Counter() {
  const { count, increment } = useStore()
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
    </div>
  )
}

Zustand가 적합한 경우

전역 상태가 명확하게 정의되어 있는 경우, 예를 들어 언어 설정이나, 테마, 알림 설정 등


// stores/settingsStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface SettingsStore {
  theme: 'light' | 'dark';
  language: 'ko' | 'en' | 'ja';
  notifications: boolean;
  sidebarOpen: boolean;
  setTheme: (theme: 'light' | 'dark') => void;
  setLanguage: (lang: 'ko' | 'en' | 'ja') => void;
  toggleNotifications: () => void;
  toggleSidebar: () => void;
}

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'ko',
      notifications: true,
      sidebarOpen: false,
      
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      toggleNotifications: () => set((state) => ({ 
        notifications: !state.notifications 
      })),
      toggleSidebar: () => set((state) => ({ 
        sidebarOpen: !state.sidebarOpen 
      })),
    }),
    {
      name: 'settings-storage', // localStorage에 자동 저장
    }
  )
);

// 사용 예시
function ThemeToggle() {
  const { theme, setTheme } = useSettingsStore();
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      {theme}
    </button>
  );
}

function Sidebar() {
  const sidebarOpen = useSettingsStore((state) => state.sidebarOpen);
  const toggleSidebar = useSettingsStore((state) => state.toggleSidebar);
  
  return (
    <div className={sidebarOpen ? 'open' : 'closed'}>
      <button onClick={toggleSidebar}>Toggle</button>
    </div>
  );
}

3. 서버 상태 관리

3.1 서버 상태의 특성과 문제점

서버 상태는 클라이언트 상태와 완전히 다른 특성을 가지고 있습니다:

  • 항상 최신이 아님: 서버에 저장된 데이터는 다른 사용자나 프로세스에 의해 변경될 수 있습니다.

  • 캐싱 필요: 같은 데이터를 여러 번 요청할 필요가 없습니다.

  • 동기화 필요: 데이터가 변경되었는지 확인하고 업데이트해야 합니다.

  • 로딩/에러 상태: 네트워크 요청이므로 로딩과 에러 상태를 관리해야 합니다.

3.2 TanStack Query가 나오기 전의 문제점

TanStack Query가 나오기 전에는 API를 불러올 때 많은 문제가 발생했습니다:

첫 번째 문제: 중복 요청

같은 사용자 정보를 표시하는 컴포넌트가 여러 개 있다면? 각 컴포넌트마다 API를 호출하게 됩니다. 네트워크 낭비이자 서버 부담입니다.

두 번째 문제: 캐싱 부재

사용자가 다른 페이지에 갔다가 다시 돌아오면? 이미 받았던 데이터를 또 받아야 합니다. 불필요한 로딩 시간이 발생합니다.

세 번째 문제: 동기화 이슈

데이터가 업데이트되었는지 어떻게 알 수 있을까요? 사용자는 새로고침을 해야만 최신 데이터를 볼 수 있습니다.

네 번째 문제: 보일러플레이트

loading, error, data 상태를 매번 선언하고 관리해야 합니다. 반복적이고 지루한 작업입니다.

3.3 TanStack Query

TanStack Query는 서버 상태를 다루기 위해 특별히 설계된 라이브러리입니다. 클라이언트 상태 관리 라이브러리와는 완전히 다른 목적을 가지고 있습니다.

TanStack Query 사용 예시


import { useQuery } from '@tanstack/react-query'

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 2 * 60 * 1000,  // 2분 동안 신선함
    gcTime: 10 * 60 * 1000,     // 10분 동안 메모리에 보관
  })

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
  return <div>{data.name}</div>
}

3.4 TanStack Query가 적합한 경우

  • API 데이터를 다루는 모든 경우

  • 실시간 데이터 동기화가 필요한 경우

  • 캐싱 전략이 복잡한 경우

  • 옵티미스틱 업데이트나 낙관적 UI가 필요한 경우

핵심 요약: TanStack Query는 서버 상태 관리를 위한 전용 라이브러리입니다. 자동 캐싱, 중복 요청 방지, 자동 리페칭 등의 기능을 제공하여 서버 데이터를 효율적으로 관리할 수 있게 해줍니다.


3.5 서버 상태 관리 라이브러리 비교

이 문서에서는 TanStack Query를 주로 다뤘지만, 다른 서버 상태 관리 라이브러리들도 존재합니다:

TanStack Query (React Query)

  • 가장 널리 사용: REST API와 GraphQL 모두 지원

  • 활발한 커뮤니티: 지속적인 업데이트와 풍부한 문서

  • 범용성: 대부분의 프로젝트에 적합

  • 추천: 대부분의 경우 이것으로 충분합니다

SWR

  • Vercel에서 개발: 간단하고 가벼운 API

  • 사용 빈도: TanStack Query보다 낮음

  • 적합한 경우: 작은 프로젝트나 Vercel 생태계 사용 시

Apollo Client

  • GraphQL 전용: GraphQL만 사용하는 경우에만 적합

  • 범용성 낮음: REST API를 사용하는 프로젝트에는 부적합

  • 적합한 경우: GraphQL만 사용하는 대규모 프로젝트

RTK Query

  • Redux Toolkit의 일부: Redux를 이미 사용하는 프로젝트에 적합

  • 사용 빈도: Redux 사용자에게만 관련 있음

  • 적합한 경우: Redux를 사용하는 프로젝트에서 서버 상태 관리가 필요할 때

Relay

  • Facebook의 GraphQL 클라이언트: 주로 Facebook 내부에서 사용

  • 사용 빈도: 매우 낮음

  • 적합한 경우: Facebook의 특정 요구사항이 있는 경우

왜 TanStack Query를 주로 다루는가?

1. 가장 널리 사용: 대부분의 React 프로젝트에서 사용되는 표준

2. 범용성: REST API와 GraphQL 모두 지원하여 대부분의 프로젝트에 적용 가능

3. 활발한 개발: 지속적인 업데이트와 커뮤니티 지원

4. 학습 효율: 하나의 라이브러리를 깊이 이해하는 것이 여러 개를 얕게 아는 것보다 실용적

다른 라이브러리들은 특수한 경우(예: GraphQL만 사용, Redux 사용 중)에만 고려하면 됩니다.

4. 결론

상태 종류

추천 라이브러리

패턴

주요 특징

서버 상태

TanStack Query

-

자동 캐싱, 중복 요청 방지, 자동 리페칭

복잡한 파생 상태

Jotai

Atomic Pattern

작은 atom 조합, 자동 의존성 추적

간단한 전역 상태

Zustand

-

보일러플레이트 없음, 간단한 API

대규모 프로젝트

Redux

Flux Pattern

예측 가능한 상태 관리, 강력한 디버깅

일반 객체처럼 사용

Valtio

Proxy Pattern

일반 객체처럼 직접 수정 가능

Reference