FEInterview Prep

state-management

React 상태 관리의 진화: Props Drilling → Context → Redux → Zustand/Jotai

상태 관리는 프론트엔드 아키텍처의 가장 중요한 결정 중 하나입니다. 7년차라면 단순히 Redux를 "쓸 줄 아는" 수준을 넘어, 각 솔루션이 등장한 이유와 해결하는 문제, 그리고 프로젝트 규모와 팀 성숙도에 맞는 선택 기준을 설명할 수 있어야 합니다. "Redux를 왜 쓰나요?"에 "많이 쓰니까"라고 답하면 탈락입니다.

시작 전
이해도
매우 낮음

학습 개요

탄생 배경

해결하려 했던 문제

React의 단방향 데이터 흐름은 예측 가능성을 높이지만, 깊은 컴포넌트 트리에서 데이터를 공유할 때 Props Drilling 문제가 발생합니다. 중간 컴포넌트는 해당 데이터가 필요 없음에도 단순 전달을 위해 props를 받아야 합니다. 이는 불필요한 리렌더링, 유지보수 어려움, 리팩토링 취약성을 만듭니다.

역사적 맥락

2014년 Facebook이 Flux 아키텍처 발표 → 2015년 Dan Abramov가 Redux 발표(Flux의 단순화 버전) → 2018년 React Context API 안정화(v16.3) → 2019년 Redux Toolkit(RTK) 출시(보일러플레이트 감소) → 2020년 Recoil(Facebook, Atomic 모델) → 2021년 Zustand 인기 급상승, Jotai 출시 → 2022년 TanStack Query로 서버 상태 분리 패러다임 확산 → 2023년 Redux 사용률 감소, Zustand/Jotai/TanStack Query가 주류로

이전에는 어떻게 했나

상태 관리 도구가 없을 때는 부모 컴포넌트에서 모든 상태를 관리하고 props로 전달하는 "Lifting State Up" 패턴이 유일한 방법이었습니다. 복잡한 애플리케이션에서는 이것이 곧 Props Drilling 지옥으로 이어졌습니다.

멘탈 모델

동작 원리

[Props Drilling 문제] A → B → C → D (D에서 A의 데이터 필요) B와 C는 데이터가 필요 없음에도 props를 받아서 전달해야 합니다. → 컴포넌트 결합도 증가, A의 데이터 구조 변경 시 B, C도 수정 필요 [Context API 동작 원리] Provider 내의 value가 바뀌면, 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다. 이것이 Context의 가장 큰 한계입니다: 하나의 Context에 너무 많은 값을 넣으면 일부 값만 필요한 컴포넌트도 무관한 값 변경으로 인해 리렌더링됩니다. 해결책: Context를 잘게 쪼개거나, memo를 활용하거나, 상태 관리 라이브러리 사용 [Redux와 Flux 아키텍처] Flux: Action → Dispatcher → Store → View (단방향 데이터 흐름의 패턴화) Redux: Flux를 단순화. Store 하나, Reducer 순수 함수 핵심 원칙: 1. Single Source of Truth: 하나의 Store 2. State is Read-Only: Action을 통해서만 상태 변경 3. Changes are Pure Functions: Reducer는 순수 함수 Redux 동작 흐름: 사용자 액션 → dispatch(action) → Reducer(현재 state + action → 새 state) → Store 업데이트 → 구독 컴포넌트 리렌더링 [Redux의 문제점] - 엄청난 보일러플레이트: action type 상수, action creator, reducer, selector 각각 파일 - 비동기 처리를 위한 미들웨어 추가 필요 (thunk 또는 saga) - 단순한 기능도 많은 파일 수정 필요 → 생산성 저하 - Redux Toolkit(RTK)이 이를 크게 개선했으나 여전히 개념적 복잡성 존재 [Atomic 모델: Recoil과 Jotai] Context와 Redux의 단점을 해결: 전역 상태를 Atom이라는 최소 단위로 분리 컴포넌트는 필요한 Atom만 구독 → 해당 Atom이 바뀔 때만 리렌더링 파생 상태는 Selector(Recoil)/derived atom(Jotai)으로 표현 Jotai vs Recoil 차이: - Jotai: 더 작고 단순 (TypeScript 친화적, atom에 key 불필요) - Recoil: Facebook 개발, selector 개념 더 풍부, 더 크고 복잡 [Zustand 설계 철학] Redux의 단방향 데이터 흐름을 유지하면서 보일러플레이트를 극단적으로 제거 store를 훅으로 사용: const count = useStore(state => state.count) 선택자(selector)로 필요한 부분만 구독 → 불필요한 리렌더링 방지 비동기 액션도 특별한 미들웨어 없이 바로 작성 가능 [서버 상태 vs 클라이언트 상태 분리] 서버 상태: 서버에서 가져온 데이터 (사용자 목록, 게시물 등) - 특성: 비동기, 캐싱 필요, 여러 사용자가 공유, staleness 개념 클라이언트 상태: UI에서만 존재하는 상태 (모달 열림 여부, 폼 입력값) - 특성: 동기, 로컬, 단일 사용자 2022년 이후 패러다임: 서버 상태는 TanStack Query/SWR, 클라이언트 상태는 Zustand/Jotai Redux는 오직 복잡한 클라이언트 상태에만 사용하는 것이 현대적 접근

핵심 구성 요소

Store

애플리케이션의 전역 상태를 보관하는 단일 저장소

Action

상태 변경 의도를 표현하는 객체 ({ type, payload })

Reducer

현재 상태 + 액션 → 새 상태를 반환하는 순수 함수

Atom

Jotai/Recoil에서 최소 상태 단위. 컴포넌트가 개별 구독

Selector

Atom을 조합/변환하여 파생 상태를 만드는 함수

Context

React 내장 전역 상태 공유 메커니즘. 리렌더링 최적화 필요

흐름 설명


[Redux 데이터 흐름]
1. 사용자: 버튼 클릭
2. 컴포넌트: dispatch({ type: 'INCREMENT', payload: 1 })
3. Redux: 모든 Reducer에 action 전달
4. Reducer: (currentState, action) => newState (순수 함수)
5. Store: newState로 업데이트
6. 구독 컴포넌트: 필요한 state 선택자(selector)가 바뀌면 리렌더링

[Zustand 데이터 흐름]
1. store 정의: create((set) => ({ count: 0, increment: () => set(state => ({ count: state.count + 1 })) }))
2. 컴포넌트: const count = useStore(state => state.count)
3. 사용자 액션: useStore.getState().increment()
4. 구독 컴포넌트 중 count를 선택한 컴포넌트만 리렌더링
    

코드 예제


// Props Drilling 문제
function GrandParent() {
  const [user, setUser] = useState({ name: 'Kim' });
  return <Parent user={user} />; // Parent는 user가 필요 없지만 전달
}
function Parent({ user }: { user: User }) {
  return <Child user={user} />; // Child에 전달하기 위해서만 받음
}
function Child({ user }: { user: User }) {
  return <div>{user.name}</div>; // 실제로 사용
}

// Context API (리렌더링 문제 있음)
const UserContext = createContext<User | null>(null);
function App() {
  const [user, setUser] = useState({ name: 'Kim', age: 30 });
  return (
    <UserContext.Provider value={user}>
      {/* user.age만 바뀌어도 name만 필요한 컴포넌트도 리렌더링! */}
      <DeepChild />
    </UserContext.Provider>
  );
}

// Zustand (현대적 접근)
import { create } from 'zustand';

interface UserStore {
  user: { name: string; age: number } | null;
  setUser: (user: UserStore['user']) => void;
}

const useUserStore = create<UserStore>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

function NameDisplay() {
  // name만 선택 → name이 바뀔 때만 리렌더링
  const name = useUserStore((state) => state.user?.name);
  return <div>{name}</div>;
}

function AgeDisplay() {
  // age만 선택 → age가 바뀔 때만 리렌더링
  const age = useUserStore((state) => state.user?.age);
  return <div>{age}</div>;
}

// Jotai (Atomic 모델)
import { atom, useAtom } from 'jotai';

const nameAtom = atom('Kim');
const ageAtom = atom(30);
// 파생 상태
const profileAtom = atom((get) => ({
  name: get(nameAtom),
  age: get(ageAtom),
}));

function NameComponent() {
  const [name, setName] = useAtom(nameAtom); // name만 구독
  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

// Redux Toolkit (현대적 Redux)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1; }, // Immer로 불변성 자동 처리
    decrement: (state) => { state.value -= 1; },
  },
});
    

비교 분석

React

vs

Context API

비교 관점
이 방식
Context API
리렌더링 최적화
Zustand: selector로 필요한 부분만 구독
Context: Provider value 전체 변경 시 모든 소비자 리렌더링
보일러플레이트
Zustand: create 함수 하나로 store 정의
Context: Provider, Consumer, createContext 설정 필요
비동기 처리
Zustand: 액션 내에서 자유롭게 async/await
Context: 별도 처리 로직 필요
적합 규모
Zustand: 중소~대규모 애플리케이션
Context: 변경 빈도가 낮은 전역 설정 (테마, 언어 등)

React

vs

Redux (RTK)

비교 관점
이 방식
Redux (RTK)
학습 곡선
Zustand: 매우 낮음 (create 함수 하나)
Redux: 중간 (slice, selector, thunk, Provider 등)
보일러플레이트
Zustand: 최소화
Redux: RTK로 줄었으나 여전히 Zustand보다 많음
DevTools
Zustand: Redux DevTools 미들웨어로 지원 가능
Redux: 최고 수준의 DevTools (타임 트래블 디버깅)
팀 규모 적합성
Zustand: 소~중규모, 빠른 개발
Redux: 대규모 팀, 엄격한 아키텍처 필요 시
미들웨어 생태계
Zustand: 제한적
Redux: RTK Query, saga, thunk 등 풍부

트레이드오프

이상적인 사용 사례

면접 질문