Skip to content
Published on

현대 프론트엔드 상태 관리의 진화 — Redux, Zustand, Jotai, Signals, TanStack Query, RSC, XState 완전 해부 (2025)

Authors

왜 상태는 프론트엔드의 영원한 난제인가

"HTML은 문서, CSS는 스타일, JS는 동작"이던 시대는 끝났다. 2025년의 UI는 살아있는 상태 기계다.

  • 사용자 입력
  • 네트워크 응답
  • 웹소켓 푸시
  • URL 쿼리 파라미터
  • 쿠키·로컬스토리지
  • 외부 라이브러리(맵, 에디터, 화상회의)
  • 화면 회전·다크모드
  • 권한·인증 상태
  • 낙관적 업데이트(Optimistic UI) 상태
  • 폼의 dirty/touched/validation
  • 서버 컴포넌트가 가진 상태
  • 에이전트 AI의 스트리밍 토큰

이 모든 것을 한 프레임워크에 구겨넣던 시대의 결말이 Redux였다. 그리고 2025년 우리는 "상태는 한 종류가 아니다"를 전제한 설계를 택한다.

Part 1 — Redux의 시대 (2015-2020)

왜 Redux가 지배했는가

2015년 Dan Abramov가 Flux의 단점을 개선해 공개. React Conf 데모가 "time-travel debugging"으로 업계를 뒤흔들었다.

3대 원칙:

  1. Single Source of Truth — 하나의 스토어.
  2. State is Read-Only — action을 통해서만 변경.
  3. Changes via Pure Functions — reducer는 순수함수.
const reducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    case 'DECREMENT': return state - 1;
    default: return state;
  }
};

왜 사람들이 Redux를 떠났는가

2018년경부터 피로감이 쌓였다. 주된 이유:

  1. Boilerplate 폭발 — 기능 하나 추가에 action type, action creator, reducer, selector, thunk 5개 파일.
  2. 비동기가 2등 시민 — redux-thunk/saga/observable 중 택하라는 3파전.
  3. 서버 상태와 클라이언트 상태 혼재 — 캐싱·재검증·optimistic을 수동으로.
  4. Immutability 강제가 실수의 원천state.items.push() 한 번에 버그.
  5. useContext + useReducer가 대부분의 경우 충분해짐.

Redux Toolkit (RTK) — 2020년 구원

공식 팀이 createSlice + createAsyncThunk + RTK Query로 응답. 보일러플레이트가 70% 줄었다.

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

Immer를 내장해 "직접 수정하는 것처럼 보이는" 코드가 허용됐다. 그러나 이미 대안들이 자라고 있었다.

Part 2 — 미니멀리즘의 3형제 — Zustand, Jotai, Valtio

Zustand — "작고 빠르고 실용적"

Poimandres 팀 제작. 3kb.

import { create } from 'zustand';

const useCounter = create((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
  decrement: () => set((s) => ({ count: s.count - 1 })),
}));

function Counter() {
  const { count, increment } = useCounter();
  return <button onClick={increment}>{count}</button>;
}

특징:

  • Provider 불필요.
  • Redux DevTools 연결 가능.
  • 선택자로 불필요 렌더 방지useCounter(s => s.count).
  • 미들웨어(persist, immer, subscribeWithSelector).

2024년 npm 주간 다운로드: 6M+ — Redux를 거의 따라잡았다.

Jotai — "아톰 단위 상태"

Recoil에서 영감. 각 상태가 "atom".

import { atom, useAtom } from 'jotai';

const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubled] = useAtom(doubledAtom);
  return <button onClick={() => setCount(c => c + 1)}>{count} / {doubled}</button>;
}

장점:

  • 세밀한 구독 — atom 단위로만 리렌더.
  • 비동기 atom도 일급 시민.
  • React Suspense와 자연스럽게 통합.

언제 Jotai? 많은 독립적 상태 단위가 있고, 컴포넌트 트리에 흩어져 있을 때.

Valtio — "Proxy 기반, mutable처럼"

import { proxy, useSnapshot } from 'valtio';

const state = proxy({ count: 0 });

function Counter() {
  const snap = useSnapshot(state);
  return <button onClick={() => state.count++}>{snap.count}</button>;
}

Proxy 기반으로 mutable하게 작성. 접근한 속성만 구독. 객체 중심 앱(게임·에디터)에서 편하다.

Part 3 — Signals의 부활

Signal의 아이디어 (KnockoutJS 2010, Vue 2014, SolidJS 2020)

"값 + 구독자 목록을 갖는 반응형 프리미티브."

// SolidJS
const [count, setCount] = createSignal(0);
return <button onClick={() => setCount(count() + 1)}>{count()}</button>;

// 코드만 보면 React 같지만, 리렌더가 발생하지 않는다.
// 변경된 텍스트 노드만 갱신된다.

왜 다시 주목받는가

React의 "컴포넌트 단위 리렌더"는 대규모 앱에서 성능 천장. Signals는 더 세밀한 반응성으로 해결.

2023-2024 Signals 채택 흐름:

  • SolidJS — signals가 프레임워크의 핵.
  • Preact@preact/signals.
  • Vue 3 Composition APIref/computed가 실질적 signal.
  • Svelte 5 Runes (2024) — $state, $derived, $effect로 signal화.
  • Angular 16+ Signals (2023) — 공식 도입.
  • React — 2024 TC39 Signals proposal 참여. 단, 네이티브 도입은 보수적.

React는 왜 주저하는가

React 팀의 입장: "Signal은 강력하지만 컴파일러(React Compiler)가 더 낫다." 2024년 RC 공개된 React Compiler가 Memoization을 자동화해 Signal의 성능 이점을 상당 부분 흡수.

그럼에도 @preact/signals-react 같은 라이브러리로 React에서도 쓸 수 있다. 세밀한 제어가 필요한 곳(차트·에디터)에 자주 등장.

Part 4 — 서버 상태의 분리 — TanStack Query 혁명

깨달음 (2019, Tanner Linsley)

"서버 상태와 클라이언트 상태는 근본적으로 다르다."

클라이언트 상태서버 상태
소유자서버
최신성항상 최신오래될 수 있음
동기화불필요필수(polling/invalidation)
캐싱선택거의 필수
중복 요청 제거불필요필수

이 깨달음으로 React Query(지금의 TanStack Query)가 태어났다.

핵심 기능

const { data, isLoading, error } = useQuery({
  queryKey: ['users', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  staleTime: 60_000,
});
  • 자동 캐싱 — queryKey 기반.
  • 중복 제거(Dedup) — 동시에 같은 쿼리 호출 시 1회만.
  • 백그라운드 재검증 — 창 재집중, 네트워크 복구 시.
  • Optimistic UpdatesonMutate로 롤백 가능한 낙관적 변경.
  • Infinite Queries — 페이지네이션.
  • Prefetching — 마우스 호버 시 미리 가져오기.

Mutation

const mutation = useMutation({
  mutationFn: updateUser,
  onMutate: async (newUser) => {
    await queryClient.cancelQueries({ queryKey: ['users', newUser.id] });
    const prev = queryClient.getQueryData(['users', newUser.id]);
    queryClient.setQueryData(['users', newUser.id], newUser);
    return { prev };
  },
  onError: (err, newUser, context) => {
    queryClient.setQueryData(['users', newUser.id], context.prev);
  },
  onSettled: (_, __, { id }) => {
    queryClient.invalidateQueries({ queryKey: ['users', id] });
  },
});

**4단계(onMutate → onError → onSuccess → onSettled)**로 낙관적 UI를 안전하게.

경쟁자들

  • SWR (Vercel, 2019) — TanStack Query보다 간단, 기능 적음.
  • RTK Query — Redux 쓰는 팀에 편리.
  • Apollo Client (GraphQL) — GraphQL 생태계 표준.
  • urql (GraphQL) — 더 가벼운 대안.

2025년 현실: 새 프로젝트는 대부분 TanStack Query. GraphQL이면 Apollo 또는 urql.

Part 5 — RSC(React Server Components)가 바꾼 것

RSC의 모델 (2020 제안, 2023 Next.js App Router로 대중화)

일부 컴포넌트는 서버에서만 실행. 데이터 fetching이 컴포넌트와 함께 선언된다.

// 서버 컴포넌트 (기본)
async function UserList() {
  const users = await db.user.findMany();  // 서버에서 직접 DB 접근
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// 클라이언트 컴포넌트 (명시)
'use client';
function LikeButton() {
  const [liked, setLiked] = useState(false);
  return <button onClick={() => setLiked(!liked)}>{liked ? '❤' : '♡'}</button>;
}

RSC가 바꾸는 상태의 개념

  • "서버 상태 fetching"이 더 이상 클라이언트의 문제가 아닌 경우가 많다.
  • 초기 렌더에 필요한 데이터는 서버 컴포넌트에서 가져온다.
  • 클라이언트 상태는 진짜 상호작용 부분에만 집중.
  • TanStack Query의 상당 부분이 서버 측 데이터 로딩으로 대체.

Server Actions

// app/actions.ts
'use server';
export async function updateUser(formData) {
  await db.user.update({ where: {...}, data: {...} });
  revalidatePath('/users');
}

// 컴포넌트에서 그대로 호출
<form action={updateUser}>...</form>

RPC 같은 모델이 React에 들어왔다. REST/GraphQL을 생략할 수 있는 경우가 늘었다.

현실의 혼란

2024-2025년 프론트엔드 커뮤니티의 주된 논쟁:

  • "RSC는 복잡도를 제거하는가, 추가하는가?"
  • "클라이언트 컴포넌트 + TanStack Query vs 서버 컴포넌트, 어떤 게 더 나은가?"
  • **Remix(=React Router 7)**는 RSC 대신 loader/action 모델을 다듬었다.
  • TanStack Start(2024) — RSC를 대체하는 새로운 메타프레임워크.

**답은 "둘 다 쓴다"**로 수렴 중. 서버 컴포넌트는 초기 로딩, 클라이언트 상태는 상호작용.

Part 6 — 폼 상태 — 왜 따로 관리하는가

폼은 특별하다

  • 값(value)
  • 에러(error)
  • touched / dirty
  • 제출 중(submitting)
  • 제출 성공/실패
  • 검증 규칙
  • 필드 간 의존성

이걸 글로벌 상태에 넣으면 폼 하나가 앱의 생명주기를 복잡하게 만든다. 그래서 폼 전용 라이브러리가 필요하다.

React Hook Form

2019년 등장. 언컨트롤드 컴포넌트 + ref 기반으로 리렌더 최소화.

const { register, handleSubmit, formState: { errors } } = useForm();

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <input {...register('email', { required: true, pattern: /.../ })} />
    {errors.email && <span>Error</span>}
  </form>
);

2024년 npm 주간 다운로드 7M+. 업계 표준.

TanStack Form (2024)

Tanner Linsley가 직접. 프레임워크 중립(React/Vue/Solid/Lit). 타입 안전성이 업계 최상위.

Formik — 쇠퇴

2018-2020년 지배했지만 성능·타입 이슈로 React Hook Form에 추월. 유지보수 모드.

Zod + 폼

2024-2025년 사실상 표준:

import { z } from 'zod';
const schema = z.object({
  email: z.string().email(),
  age: z.number().min(18),
});

// React Hook Form + zodResolver
useForm({ resolver: zodResolver(schema) });

Part 7 — XState — 복잡한 로직을 상태 머신으로

왜 상태 머신인가

UI 로직의 대부분은 "상태 전이"의 집합이다. 그런데 if/else로 흩어지면 "불가능한 상태"가 생긴다.

loading && error  → 불가능해야 함
loading && data   → 불가능해야 함

XState는 이걸 수학적으로 정리한다.

import { createMachine } from 'xstate';

const fetchMachine = createMachine({
  initial: 'idle',
  states: {
    idle: { on: { FETCH: 'loading' } },
    loading: {
      on: {
        RESOLVE: 'success',
        REJECT: 'failure',
      }
    },
    success: { type: 'final' },
    failure: { on: { RETRY: 'loading' } }
  }
});

언제 XState가 빛나는가:

  • 결제 플로우
  • 화상회의·미디어 스트림
  • 문서 편집기의 dirty/saved 상태
  • 다단계 위저드
  • 온보딩 흐름

단점: 학습 곡선. 작은 앱엔 과도.

Stately Studio

시각적 상태 머신 편집기. "디자이너가 상태를 설계, 개발자가 받아서 구현"하는 워크플로 가능.

Part 8 — 상태 정규화 & 관계

중첩 구조의 함정

// 나쁜 예
state.users[0].posts[3].comments[2].author.name = 'New';

같은 author가 여러 곳에서 중첩된다. 어디 하나 갱신하면 다른 곳은 낡는다.

정규화

{
  users: { 1: { id: 1, name: 'A', postIds: [10, 11] } },
  posts: { 10: { id: 10, title: '...', authorId: 1, commentIds: [100] } },
  comments: { 100: { id: 100, text: '...', authorId: 1 } },
}

Normalizr 라이브러리가 이 작업을 도와준다. Redux 시대에 유행했지만, TanStack Query가 select로 비슷한 효과를 주면서 최근엔 덜 쓰인다.

Part 9 — URL도 상태다

URL은 무료 전역 상태

  • 공유 가능
  • 뒤로가기/앞으로가기 자동
  • SEO
  • 딥링크

nuqs (2024)

const [filter, setFilter] = useQueryState('filter');

URL 쿼리 파라미터를 상태처럼. Next.js App Router와 완벽 통합. 필터·정렬·페이지네이션·모달 열림은 URL 상태여야 한다.

Part 10 — Undo/Redo와 시간 여행

Command Pattern 기반

class UndoableStore {
  history = [];
  index = -1;

  execute(command) {
    this.history = this.history.slice(0, this.index + 1);
    this.history.push(command);
    this.index++;
    command.do();
  }

  undo() {
    if (this.index >= 0) {
      this.history[this.index].undo();
      this.index--;
    }
  }
}

Immer의 patches

import { produceWithPatches } from 'immer';

const [nextState, patches, inversePatches] = produceWithPatches(state, draft => {
  draft.todos.push({ text: 'New' });
});

patches/inversePatches로 정확한 undo/redo.

Yjs — 협업 편집의 상태

CRDT(Conflict-free Replicated Data Type) 구현. 실시간 협업 앱의 사실상 표준.

  • Notion, Linear, Figma 같은 앱의 기반.
  • 여러 사용자의 편집을 병합하면서 충돌 없이 수렴.

Part 11 — 실무 결정 가이드

상태의 5분류

  1. 서버 상태 → TanStack Query / SWR / RTK Query
  2. URL 상태 → nuqs / next/navigation / 직접
  3. 글로벌 클라이언트 상태 → Zustand / Jotai / Redux Toolkit
  4. 로컬 UI 상태 → useState / useReducer
  5. 폼 상태 → React Hook Form / TanStack Form
  6. 복잡한 플로우 → XState

황금률: 서로 다른 종류의 상태를 같은 도구로 관리하지 말라.

2025년 새 프로젝트 기본 스택 (개인 추천)

  • Framework: Next.js (App Router) or TanStack Start or Remix/RR7
  • Server State: TanStack Query (클라이언트 컴포넌트에서)
  • Client State: Zustand (간단) or Jotai (atom 단위)
  • Form: React Hook Form + Zod
  • URL: nuqs
  • 특수 로직: XState (필요 시)
  • Realtime/Collab: Yjs

Part 12 — 실무 체크리스트 (12항목)

  1. 서버 상태와 클라이언트 상태 분리 — 이게 대전제.
  2. 글로벌 상태는 최소화 — 대부분은 로컬이면 충분.
  3. URL에 담을 수 있는 건 URL로 — 공유 가능성을 얻는다.
  4. 폼은 전용 라이브러리 — 글로벌 상태에 넣지 말라.
  5. Optimistic UI는 롤백과 함께 — 실패 케이스 UX가 핵심.
  6. 무한 리렌더링 근본 원인은 90% 객체 참조 — useMemo/selectors.
  7. Suspense를 활용하라 — 로딩 상태를 선언적으로.
  8. React DevTools Profiler로 정기 측정.
  9. XState는 '정말 복잡한' 곳에만 — CRUD에는 과잉.
  10. 폼 검증은 Zod + 스키마 — 프론트·백엔드 공유.
  11. 서버 컴포넌트를 먼저 시도하라 — 필요할 때만 클라이언트.
  12. 상태 트리를 그림으로 그려라 — 문서화의 가장 큰 효과.

Part 13 — 10대 안티패턴

  1. 모든 걸 Redux에 — 로컬 UI 상태까지 글로벌화.
  2. 서버 데이터를 Redux에 넣고 수동 캐싱 — TanStack Query가 할 일.
  3. 폼을 전역 상태에 — 페이지 전환 시 혼돈.
  4. URL 상태를 글로벌에 복제 — 진실의 두 소스.
  5. useState 연쇄로 복잡 로직 모델링 — XState가 맞는 순간이 있다.
  6. setState 후 바로 읽기 — 비동기성 무시.
  7. useEffect로 데이터 fetching — 2024년 이후는 TanStack Query / RSC.
  8. 컴포넌트 트리 전체에 Context — 리렌더 폭주.
  9. 깊은 중첩 없이 정규화만 — 작은 앱에 Normalizr 오버엔지.
  10. "이거 한 줄로 해결돼요" — 1년 후 기술부채.

마치며 — 상태는 "모델링"이다

React 15(2016)부터 React 19(2024)까지 9년간 프론트엔드가 배운 한 가지는:

상태 관리는 라이브러리 선택의 문제가 아니라, 상태 자체를 올바르게 모델링하는 문제다.

Redux가 실패한 게 아니다. "모든 상태는 하나의 글로벌 스토어"라는 전제가 틀렸을 뿐이다. 2025년 우리는 상태에 종류가 있음을 인정하고, 각각에 맞는 도구를 쓴다.

좋은 상태 설계는:

  • 불가능한 조합을 만들 수 없도록
  • 최소한의 진실(single source of truth)로
  • 파생(derived)을 명시적으로
  • 사이드 이펙트를 예측 가능하게

이 네 가지를 달성하는 것이다. 어떤 라이브러리든 이 원칙을 지키면 옳다.

다음 글 예고 — "웹 보안 방어의 실전" — XSS, CSRF, SSRF, Clickjacking, Prototype Pollution, Supply Chain, CORS 실무 가이드

보안 기본(이전 Security 글)은 개념이었다. 다음은 웹 프론트엔드·백엔드 개발자가 매일 마주치는 공격과 방어다.

  • XSS 세분화 — Reflected / Stored / DOM-based 각각의 방어
  • CSP를 처음부터 구축하는 법 — nonce, hash, strict-dynamic
  • Trusted Types로 XSS를 원천 차단
  • CSRF와 SameSite — 2025년 기본값의 의미
  • SSRF — Capital One 사건과 AWS IMDSv2
  • Clickjacking과 X-Frame-Options / frame-ancestors
  • Prototype Pollution — Node 생태계의 고질병
  • Supply Chain Attack — event-stream, ua-parser-js, xz-utils
  • CORS 완전 이해 — preflight, credentials, wildcard의 함정
  • Rate Limiting과 Bot Defense — Cloudflare Turnstile, hCaptcha

"앱을 해킹당하지 않는 법"의 실무편. 다음 글에서.