devlog.

React 상태 관리 전략 비교: useState부터 Zustand까지

·8분 읽기

React 프로젝트가 커지면 가장 먼저 고민하게 되는 것이 상태 관리입니다. "useState만으로 충분할까?", "Redux를 써야 할까?", "요즘 Zustand가 낫다던데?" 이 글에서 각 방식의 장단점을 정리하고 선택 기준을 제시합니다.

useState — 로컬 상태#

컴포넌트 내부에서만 사용하는 상태는 useState로 충분합니다.

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

언제 쓸까?

  • 폼 입력값, 모달 열림/닫힘, 로딩 상태 등 컴포넌트 하나에서만 쓰는 상태

Props Drilling 문제#

깊이 중첩된 컴포넌트에 상태를 전달할 때 발생합니다.

// ❌ 모든 중간 컴포넌트가 user를 전달해야 함
function App() {
  const [user, setUser] = useState(null)
  return <Layout user={user} />
}

function Layout({ user }) {
  return <Sidebar user={user} />
}

function Sidebar({ user }) {
  return <UserCard user={user} />
}

function UserCard({ user }) {
  return <div>{user.name}</div>  // 실제 사용은 여기서만
}

이 문제를 해결하는 방법이 Context API와 외부 상태 관리 라이브러리입니다.

Context API — 전역 상태 (기본)#

React 내장 기능으로, 설치 없이 사용 가능합니다.

// context 생성
const UserContext = createContext<User | null>(null)

// Provider로 감싸기
function App() {
  const [user, setUser] = useState<User | null>(null)

  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  )
}

// 어느 자식 컴포넌트에서든 접근 가능
function UserCard() {
  const user = useContext(UserContext)
  return <div>{user?.name}</div>
}

Context의 단점#

Context 값이 바뀌면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다.

// ❌ theme과 user를 같은 Context에 넣으면
// theme이 바뀔 때 user만 쓰는 컴포넌트도 리렌더링
const AppContext = createContext({ theme: 'dark', user: null })

// ✅ Context를 분리해서 관리
const ThemeContext = createContext('dark')
const UserContext = createContext(null)

언제 쓸까?

  • 테마, 언어 설정, 로그인 유저 정보처럼 자주 바뀌지 않는 전역 상태

Redux — 대규모 앱 상태 관리#

복잡한 상태 로직을 예측 가능하게 관리합니다.

// Redux Toolkit 사용
import { createSlice, configureStore } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value++ },
    decrement: (state) => { state.value-- },
  },
})

const store = configureStore({
  reducer: { counter: counterSlice.reducer }
})

// 컴포넌트에서 사용
function Counter() {
  const count = useSelector(state => state.counter.value)
  const dispatch = useDispatch()

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => dispatch(increment())}>+1</button>
    </div>
  )
}

언제 쓸까?

  • 복잡한 상태 로직이 많은 대규모 앱
  • 상태 변경 이력 추적(Redux DevTools)이 필요한 경우
  • 팀 규모가 크고 일관된 패턴이 중요한 경우

Zustand — 가볍고 직관적인 전역 상태#

최근 가장 빠르게 성장하는 상태 관리 라이브러리입니다.

import { create } from 'zustand'

interface CounterStore {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

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

// 컴포넌트에서 사용 — Provider 불필요!
function Counter() {
  const { count, increment } = useCounterStore()

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>+1</button>
    </div>
  )
}

선택적 구독으로 리렌더링 최적화#

// count가 바뀔 때만 리렌더링
const count = useCounterStore((state) => state.count)

// increment만 필요한 컴포넌트는 count가 바뀌어도 리렌더링 안 됨
const increment = useCounterStore((state) => state.increment)

언제 쓸까?

  • 소중형 앱의 전역 상태
  • Redux가 너무 복잡하게 느껴질 때
  • 빠른 개발이 필요한 스타트업 프로젝트

라이브러리 비교#

기준ContextReduxZustand
번들 크기0 (내장)~48KB~1KB
보일러플레이트적음많음매우 적음
리렌더링 최적화어려움쉬움매우 쉬움
DevTools없음강력함기본 제공
학습 곡선낮음높음낮음
TypeScript 지원보통좋음매우 좋음

상태 종류별 선택 가이드#

상태 종류에 따른 선택:

로컬 상태 (하나의 컴포넌트)
└─→ useState, useReducer

서버 상태 (API 데이터, 캐싱)
└─→ TanStack Query (React Query), SWR

전역 UI 상태 (테마, 모달, 알림)
└─→ 단순: Context API
    복잡/자주 바뀜: Zustand

복잡한 비즈니스 로직
└─→ Redux Toolkit

마무리#

정답은 없습니다. 프로젝트 규모와 팀 상황에 맞게 선택하세요.

  • 사이드 프로젝트 / 스타트업: Zustand + TanStack Query
  • 대규모 엔터프라이즈: Redux Toolkit
  • 간단한 설정값 공유: Context API

한 가지 덧붙이면, 상태 관리 라이브러리 선택보다 어떤 상태를 어디에 둘 것인가를 잘 설계하는 것이 더 중요합니다.

관련 포스트