Skip to content

✍️ 필사 모드: 상태 관리의 르네상스 2025 — Zustand·Jotai·Valtio·TanStack Query·Signals·XState·RSC 완전 가이드

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

프롤로그 — "Redux 하나로 다 하던 시절은 끝났다"

2016년의 프런트엔드는 단순했다. "상태는 Redux에 넣는다." 그게 정답이었고, 모두가 그렇게 했다. 그러다 사이즈가 커지면서 사람들은 깨달았다. "로그인 여부"와 "API 응답 캐시"와 "URL에서 읽은 탭 인덱스"와 "입력 중인 폼 값"을 똑같은 Redux store에 넣는 게 맞는가?

2025년의 대답은 명확하다: 아니다. 상태는 그 성격에 따라 최소 네 가지로 나뉘고, 각 축마다 최적의 도구가 다르다.

본질대표 도구
Server state서버가 주인, 캐시일 뿐TanStack Query, SWR, RSC fetch
URL stateURL이 주인, 공유·북마크 가능Next.js searchParams, nuqs, TanStack Router
Local state컴포넌트 또는 앱 내부useState, Zustand, Jotai, Valtio, Signals
Form state입력·유효성·제출 라이프사이클React Hook Form, TanStack Form, Conform

여기에 **머신 상태(State Machine)**로 복잡한 UX 플로우(위저드·결제·업로드)를 모델링하는 XState가 더해진다.

그리고 2024년 Signals의 재발견RSC(React Server Components) 시대의 상태 개념 재정의가 2025년 프런트엔드의 두 축을 뒤흔들었다.

이번 글은 이 모든 지형을 13개 챕터로 답사한다.


1장 · 상태의 네 가지 성격을 구분하자

Server state

  • 출처: 서버 DB
  • 특징: 비동기, stale/fresh 개념, 다른 사용자가 바꿀 수 있음
  • 문제: 로딩·에러·재시도·캐시·무효화·낙관적 업데이트
  • 도구: TanStack Query·SWR·RTK Query·Apollo Client·RSC fetch

URL state

  • 출처: URL path·query·hash
  • 특징: 공유·북마크·뒤로가기 가능, 새로고침에 유지됨
  • : ?tab=reviews&sort=recent&page=2
  • 도구: Next.js useSearchParams, nuqs, qs, TanStack Router search params

Local state

  • 출처: 앱 메모리
  • 특징: 새로고침에 사라짐, 컴포넌트 생명주기에 귀속
  • : 모달 열림 여부, 테마, 언어, 드래프트 입력
  • 도구: useState, useReducer, Context, Zustand, Jotai, Valtio, Signals

Form state

  • 출처: 사용자 입력
  • 특징: 유효성 검증, 제출 라이프사이클, 에러 표시
  • 도구: React Hook Form, Formik, TanStack Form, Conform

경계선 예시

  • "장바구니" → 서버에서 결제까지 유지 + URL?cartId=xxx + 로컬에 UI 드로어 상태
  • "프로필 편집 폼" → 상태 + 제출 후 서버 갱신
  • "테마 다크모드" → 로컬(+localStorage persist) 또는 서버(계정 연동 시)

핵심 교훈: 하나의 도구로 네 축을 모두 다루지 마라. Redux 하나에 서버 응답을 박아두면 캐시 무효화 로직이 800줄이 되고, URL을 로컬 상태로만 관리하면 뒤로가기가 깨진다.


2장 · Redux의 자리 — 2025년에도 필요한가?

결론부터: Redux가 죽은 건 아니다. 다만 역할이 좁아졌다.

Redux가 여전히 빛나는 상황

  • 수십 개 도메인 간 복잡한 상호작용 (OLAP 대시보드, 디자인 툴, 게임 엔진, IDE 스타일 앱)
  • 대규모 팀의 컨벤션 통일이 개별 최적화보다 중요한 조직
  • 시간 여행 디버깅·DevTools가 필수인 상황
  • 이미 수년치 Redux 코드 베이스

Redux Toolkit (RTK) — 2025 공식 권장

2019년부터 Redux 팀이 공식 권장. boilerplate 감소, Immer 기본 내장, RTK Query 제공.

import { createSlice, configureStore } from "@reduxjs/toolkit";

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [], total: 0 },
  reducers: {
    addItem: (state, action) => {
      state.items.push(action.payload);
      state.total += action.payload.price;
    },
  },
});

export const { addItem } = cartSlice.actions;
export const store = configureStore({
  reducer: { cart: cartSlice.reducer },
});

RTK Query — Redux 진영의 서버 상태 답변

TanStack Query와 유사하지만 Redux store 안에서 동작. 이미 Redux를 쓰는 팀에게는 자연스럽다.

일반 앱에서는

Zustand + TanStack Query 조합이 95%의 상황에서 Redux보다 작고 빠르고 간단하다.


3장 · TanStack Query — 서버 상태의 새 표준

2021~2025년 서버 상태 관리의 de facto. React 외에도 Vue·Svelte·Solid에 공식 어댑터.

왜 이렇게 커졌나

  1. 캐시·재검증·낙관적 업데이트의 반복 패턴을 라이브러리가 처리
  2. 로딩·에러·성공 3상태를 컴포넌트에서 선언적으로 처리
  3. stale-while-revalidate 철학: "오래된 데이터라도 일단 보여주고 백그라운드 재검증"

기본 사용

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function UserProfile({ id }: { id: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ["user", id],
    queryFn: () => fetchUser(id),
    staleTime: 60_000,
    gcTime: 5 * 60_000,
  });
  if (isLoading) return <Spinner />;
  if (error) return <ErrorBox />;
  return <Profile user={data} />;
}

Mutation + Invalidation

function useAddComment() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: addComment,
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["comments"] });
    },
  });
}

Optimistic Update — UX의 질을 올리는 핵심 기법

useMutation({
  mutationFn: toggleLike,
  onMutate: async (postId) => {
    await qc.cancelQueries({ queryKey: ["post", postId] });
    const prev = qc.getQueryData(["post", postId]);
    qc.setQueryData(["post", postId], (old) => ({
      ...old,
      liked: !old.liked,
      likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
    }));
    return { prev };
  },
  onError: (_, postId, ctx) => {
    qc.setQueryData(["post", postId], ctx.prev);
  },
  onSettled: (_, __, postId) => {
    qc.invalidateQueries({ queryKey: ["post", postId] });
  },
});

TanStack Query v5 (2023~2025) 주요 업데이트

  • useSuspenseQuery — Suspense 경계에서 자동 로딩 처리
  • Offline support 강화
  • 전용 DevTools
  • RSC와의 통합 가이드(HydrationBoundary)

SWR (Vercel)

가벼운 대안. 핵심 API는 useSWR 하나. Next.js와의 통합이 우수. 대규모 앱에서는 TanStack Query의 기능 우위.


4장 · Zustand — 가볍지만 강력한 글로벌 상태

2020년 등장, 2024~2025년 React 생태계 로컬/글로벌 상태 1위.

왜 사랑받나

  • 4KB 미만
  • Context나 Provider 불필요 (원하면 쓸 수 있음)
  • 선택적 구독 — 필요한 슬라이스만 rerender
  • Redux DevTools 호환 middleware
  • immer·persist·subscribeWithSelector middleware

기본 사용

import { create } from "zustand";

type CartStore = {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  clear: () => void;
};

export const useCart = create<CartStore>((set) => ({
  items: [],
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
  clear: () => set({ items: [] }),
}));

// 컴포넌트
function Cart() {
  const items = useCart((s) => s.items);
  return <div>{items.length}개 상품</div>;
}

Persist

import { persist } from "zustand/middleware";

export const useAuth = create(
  persist(
    (set) => ({ user: null, setUser: (u) => set({ user: u }) }),
    { name: "auth", storage: createJSONStorage(() => localStorage) }
  )
);

Slice 패턴 — 대형 스토어 분할

const useBoundStore = create((...a) => ({
  ...createCartSlice(...a),
  ...createUserSlice(...a),
  ...createThemeSlice(...a),
}));

Zustand가 적합하지 않을 때

  • 세밀한 원자성 상태가 많을 때 → Jotai
  • 상태 간 의존 관계가 파이프라인처럼 복잡할 때 → Signals·XState

5장 · Jotai — Atomic State의 미니멀리즘

Recoil의 정신적 후계자. atom 단위로 상태를 쪼개고, 조합은 useAtom 소비 시점에 결정.

기본

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 (
    <>
      <p>{count} · 2배는 {doubled}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </>
  );
}

비동기 atom

const userAtom = atom(async (get) => {
  const id = get(userIdAtom);
  const res = await fetch(`/api/users/${id}`);
  return res.json();
});

// 컴포넌트에서 사용 — Suspense 자동 처리
function Profile() {
  const [user] = useAtom(userAtom);
  return <div>{user.name}</div>;
}

언제 Jotai가 빛나나

  • 복잡한 파생 계산이 많고 서로 의존 관계가 있을 때
  • 컴포넌트 트리가 깊고, 특정 서브트리만 특정 상태를 쓸 때
  • "모든 상태는 atom이다"라는 철학이 맞을 때

주의

초보자에게는 "atom을 어디에 선언하느냐"가 혼란스러울 수 있다. 대형 앱에서는 atomFamily·useAtomValue·useSetAtom 같은 API를 조합해야 한다.


6장 · Valtio — Proxy 기반의 "변경 추적"

Jotai·Zustand 창시자와 같은 팀(Poimandres)의 또 하나의 작품. 객체를 Proxy로 감싸 직접 변경하면 구독자가 자동 업데이트.

import { proxy, useSnapshot } from "valtio";

const state = proxy({ count: 0, items: [] });

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

장점

  • 가장 자연스러운 변경 구문state.count++처럼 뮤테이트
  • 깊은 중첩 객체도 자동 추적
  • 게임·에디터 스타일 앱에 적합

단점

  • 불변성 철학과 충돌 → 어떤 팀은 혼란스러워함
  • SSR 상태 전파 시 조심스러움

Valtio는 "객체지향 관성이 강한 팀"이나 "빠른 프로토타입"에 잘 어울린다.


7장 · Signals — React 밖에서 불어온 바람

Solid.js(2021), Preact(2022), Angular(2023), Vue Vapor(2024), Svelte 5 Runes(2024)가 모두 Signals 패러다임을 채택하면서 React 외 진영에서 공통의 언어가 됐다.

Signal의 핵심 개념

  • 값(value)과 구독자(subscriber)가 묶인 원시 단위
  • 읽기·쓰기가 투명하게 추적됨
  • 의존 관계가 런타임에 자동 형성
  • VDOM 없이 정확히 바뀐 부분만 업데이트

Solid.js 예

import { createSignal, createEffect, createMemo } from "solid-js";

const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);

createEffect(() => {
  console.log("count is now", count());
});

setCount(5); // "count is now 5"

Svelte 5 Runes

<script>
  let count = $state(0);
  let doubled = $derived(count * 2);
</script>

<button on:click={() => count++}>{count}</button>

React에서의 Signal?

  • @preact/signals-react — React에서 Signal 문법 사용
  • React 19의 useOptimistic·useActionState·useFormStatus는 Signal에 가까운 미세한 반응성 제공
  • 그러나 React의 근간은 여전히 VDOM + 컴포넌트 리렌더

왜 Signals가 부상했나

  • VDOM 없이 정확한 업데이트 → 성능·메모리 우위
  • Ripple·Diff 계산 없음
  • 작은 앱·대시보드에서 극단적으로 가볍다

2025년의 선택: "이미 React·Next 기반이면 계속 Hook·Zustand·Jotai. 새 프로젝트라면 Solid·Svelte·Vue Vapor도 진지하게 검토."


8장 · XState — 복잡한 UX를 상태 머신으로

"이 버튼은 로딩 중에는 비활성화, 에러 후엔 재시도 가능, 성공 후엔 확인 버튼으로 변하고, 도중에 네트워크가 끊기면..."

이런 UX를 useState 여러 개로 관리하다 보면 **"무효한 상태 조합"**이 터진다. 상태 머신은 애초에 허용된 상태와 전이만 정의해 불가능한 조합을 원천 봉쇄.

XState 기본

import { createMachine, assign } from "xstate";

const fetchMachine = createMachine({
  id: "fetch",
  initial: "idle",
  context: { data: null, error: null, retries: 0 },
  states: {
    idle: {
      on: { FETCH: "loading" },
    },
    loading: {
      invoke: {
        src: "fetchData",
        onDone: { target: "success", actions: assign({ data: (_, e) => e.data }) },
        onError: { target: "failure", actions: assign({ error: (_, e) => e.data }) },
      },
    },
    success: {
      on: { REFRESH: "loading" },
    },
    failure: {
      on: {
        RETRY: {
          target: "loading",
          actions: assign({ retries: (ctx) => ctx.retries + 1 }),
        },
      },
    },
  },
});

XState의 힘

  • 불가능한 상태를 코드 레벨에서 제거
  • 시각적 모델링 (XState Visualizer, Stately.ai)
  • 전이 로그·디버깅 최상
  • 테스트 용이 — 모든 전이를 모델 기반으로 자동 생성

적합한 영역

  • 결제 위저드, 업로드 플로우, 게임 AI, 오디오 플레이어
  • 채팅·메시징·실시간 협업 세션 상태
  • 폼의 다단계 분기(주문자/수령인/결제수단)

주의

모든 곳에 XState를 쓰면 선언적이지만 장황해진다. "상태 조합이 4개 이하"면 useState 조합이 더 단순하다.


9장 · URL State — 가장 저평가된 상태

뒤로가기·공유·북마크·새로고침을 견디는 상태는 URL에 있어야 한다.

예시

  • 탭 인덱스: ?tab=overview
  • 필터·정렬: ?category=shoes&sort=price&order=desc
  • 페이지네이션: ?page=3&per=20
  • 모달 열림: ?modal=contact
  • 다단계 위저드: /checkout/shipping vs /checkout/payment

Next.js App Router

"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";

function TabBar() {
  const sp = useSearchParams();
  const tab = sp.get("tab") ?? "overview";
  const router = useRouter();
  const pathname = usePathname();

  const setTab = (t: string) => {
    const params = new URLSearchParams(sp);
    params.set("tab", t);
    router.replace(`${pathname}?${params.toString()}`);
  };

  return (
    <nav>
      <button onClick={() => setTab("overview")}>개요</button>
      <button onClick={() => setTab("reviews")}>리뷰</button>
    </nav>
  );
}

nuqs — URL ↔ Typed State 변환 라이브러리

import { useQueryState, parseAsInteger, parseAsString } from "nuqs";

function Filters() {
  const [sort, setSort] = useQueryState("sort", parseAsString.withDefault("recent"));
  const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
  // ...
}

타입 안전한 URL 상태. Next.js App Router에 최적화. 2025년 de facto.


10장 · Form State — React Hook Form·Conform·TanStack Form

기존 방식의 문제

useState 10개, onChange 10개, 유효성 검증 try/catch 도배. **폼은 "하나의 라이프사이클을 가진 상태 기계"**로 봐야 한다.

React Hook Form (RHF)

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

function Login() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit(async (data) => { ... })}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="password" {...register("password")} />
      <button type="submit">로그인</button>
    </form>
  );
}

장점:

  • Uncontrolled (ref 기반) → 리렌더 최소
  • Zod·Yup·Valibot 통합
  • 2024년 기준 주간 다운로드 700만+

TanStack Form

타입 안전·프레임워크 무관. Solid·Vue·Svelte에도 동일 API. "TanStack 올인" 선호 팀에게 매력적.

Conform (Next.js Server Actions 친화)

Progressive Enhancement 기반. <form action={serverAction}>과 잘 어울림. RSC 시대의 자연스러운 선택.

한국형 복잡 폼의 팁

  • 조건부 필드 렌더링: watch()로 다른 필드 값에 따라 보이기
  • 다단계 위저드: 각 단계별 schema 분리 + 최종 merge
  • 자동 주소 검색 (다음 우편번호 API): setValue()로 여러 필드 동시 주입
  • 휴대폰 번호 포맷팅: onChange에서 정규식, valueAsNumber 지양

11장 · RSC 시대의 상태 재정의

React Server Components(RSC)가 바꾼 것:

"컴포넌트가 서버에서 실행되면, 그 안의 state는 이미 '서버 상태'다."

예전 구도

Server DBAPIClient state → Component

RSC 구도

Server DBServer Component (state)StreamClient

Server Component에서 fetch()하면, 그 결과는 별도의 TanStack Query 없이도 자동 캐시·재검증 대상이 된다(Next.js 15의 fetch + cache·revalidate).

그럼 클라이언트 상태는 어디 있나

여전히 필요한 상태:

  • 모달 열림 여부
  • 폼 입력 중인 값
  • 애니메이션 상태
  • 로컬 드래프트
  • 사용자 UI preference

RSC가 "데이터 페칭"의 많은 부분을 서버로 옮겼지만, 인터랙션 상태는 여전히 클라이언트에 있다.

하이브리드 전략

  • 초기 데이터 → Server Component에서 fetch
  • 갱신·재검증·낙관적 업데이트 → TanStack Query + RSC Hydration
  • 인터랙션·UI 상태 → Zustand·Jotai
  • Form → RHF·Conform + Server Action
// Server Component
export default async function Page() {
  const initial = await fetchPosts();
  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <Posts initial={initial} />
    </HydrationBoundary>
  );
}

// Client Component
"use client";
function Posts({ initial }) {
  const { data } = useQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
    initialData: initial,
  });
  // ...
}

12장 · 한국적 맥락 — 폼·결제·위저드

한국 서비스의 흔한 UX 패턴들.

카카오·네이버·토스 결제 플로우

  1. 상품 선택 → 장바구니 담기
  2. 주문서 작성 (배송지·수령인·옵션)
  3. 결제 수단 선택 (카드·계좌·페이·포인트)
  4. 결제 진행 (외부 모듈·리다이렉트·인증)
  5. 결과 확인

각 단계가 실패·취소·뒤로가기·새로고침에 견뎌야 한다. 상태 머신(XState) + URL state + 서버 세션 키의 합작.

다음 우편번호·카카오 주소 API

  • 모달로 열고, 선택 후 부모 폼에 inject
  • RHF의 setValueaddress·zipcode·addressDetail 동시 주입
  • shouldValidate: true로 유효성 재검증

본인인증·휴대폰 인증

  • 3rd party iframe·팝업 → postMessage 통신
  • 상태 머신: idle → sent → verifying → success|expired|failed

사업자 번호 검증

  • 실시간 국세청 API 호출 → TanStack Query + debounce
  • blur 시점에만 호출, onChange마다는 금지

한국 주소·전화번호 포맷팅

  • 010-1234-5678 입력 중 자동 하이픈 삽입
  • RHF onChange에서 정규식 정리
  • inputMode="tel"로 모바일 숫자 키패드

13장 · 체크리스트·안티패턴·다음 글 예고

상태 관리 체크리스트 (13개)

  1. 상태를 서버·URL·로컬·폼 4축으로 먼저 분류
  2. 서버 상태는 TanStack Query·SWR·RSC 사용
  3. URL 상태는 URL에 저장 + nuqs 같은 타입 안전 래퍼
  4. 로컬 상태는 먼저 useState, 성장 시 Zustand/Jotai
  5. 폼 상태는 RHF·TanStack Form·Conform + Zod
  6. 복잡 UX 플로우는 XState
  7. 낙관적 업데이트는 onMutate/onError/onSettled 3종 세트
  8. Persist는 꼭 필요한 상태에만 (localStorage 오남용 금지)
  9. 브라우저 뒤로가기·새로고침 테스트 (URL state 설계 검증)
  10. DevTools로 상태 변화 관찰 (Zustand·Redux·Jotai 모두 지원)
  11. 상태 이름은 무엇이 아니라 어떤 성격인지 드러내기
  12. Selector로 필요한 슬라이스만 구독
  13. RSC 시대에는 초기 데이터 = 서버 페칭, 이후 = Query 캐시

상태 관리 안티패턴 TOP 10

  1. Redux에 모든 것 넣기 — 서버 응답·URL·폼까지
  2. 로컬 상태를 Context로 — Provider 위치·리렌더 폭발
  3. URL 없이 탭 인덱스 관리 — 뒤로가기 파괴
  4. 폼마다 useState 20개 — RHF·TanStack Form 써라
  5. Zustand에 서버 데이터 수동 캐시 — TanStack Query 써라
  6. 낙관적 업데이트 없는 UX — "버튼 눌렀는데 아무 반응 없음"
  7. State Machine을 useState 조합으로 — 불가능 상태 폭발
  8. Persist에 토큰·PII 저장
  9. 깊은 props drilling을 방치
  10. 상태를 저장소에서 꺼내 매번 deep copy — 불변성 과도 집착

다음 글 예고 — Season 6 Ep 11: "테스트 전략의 현대화"

상태 설계가 명확해지면 테스트도 명확해진다. Ep 11은 2025년 프런트엔드 테스트.

  • Vitest·Jest·Bun Test 2025 비교
  • Testing Library·Playwright·Cypress·Storybook Test Runner
  • Visual Regression: Chromatic·Percy·Lost Pixel
  • MSW(Mock Service Worker)로 서버 모킹
  • Component Test vs E2E 비율 (Trophy 모델)
  • AI가 테스트 작성을 돕는 2025 (Copilot Tests·CodiumAI)
  • Accessibility 테스트 자동화(axe-core·Pa11y CI)
  • Flaky 테스트 원인과 해결
  • CI에서 테스트 병렬화·샤딩
  • Test Data Factory·Faker·Snapshot 관리

"테스트는 버그를 잡는 도구가 아니다. 리팩터링을 두려워하지 않게 하는 용기의 도구다."

다음 글에서 만나자.


"상태는 어디에 살 것인가부터 결정하자. 서버의 DB에? URL에? 메모리에? 폼 라이프사이클에? 이 질문에 대답하는 순간, 쓸 도구는 저절로 정해진다."

현재 단락 (1/384)

2016년의 프런트엔드는 단순했다. **"상태는 Redux에 넣는다."** 그게 정답이었고, 모두가 그렇게 했다. 그러다 사이즈가 커지면서 사람들은 깨달았다. "로그인 여부"와 "...

작성 글자: 0원문 글자: 12,261작성 단락: 0/384