LabHub

블로그

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

한국어English日本語

프롤로그 — "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

URL state

Local state

Form state

경계선 예시

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


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

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

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) 주요 업데이트

SWR (Vercel)

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


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

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

왜 사랑받나

기본 사용

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가 적합하지 않을 때


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을 어디에 선언하느냐"가 혼란스러울 수 있다. 대형 앱에서는 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>
  );
}

장점

단점

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


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

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

Signal의 핵심 개념

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?

왜 Signals가 부상했나

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를 쓰면 선언적이지만 장황해진다. "상태 조합이 4개 이하"면 useState 조합이 더 단순하다.


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

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

예시

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>
  );
}

장점:

TanStack Form

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

Conform (Next.js Server Actions 친화)

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

한국형 복잡 폼의 팁


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).

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

여전히 필요한 상태:

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

하이브리드 전략

// 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

본인인증·휴대폰 인증

사업자 번호 검증

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


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년 프런트엔드 테스트.

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

다음 글에서 만나자.


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

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다