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

- Name
- Youngju Kim
- @fjvbn20031
프롤로그 — "Redux 하나로 다 하던 시절은 끝났다"
2016년의 프런트엔드는 단순했다. "상태는 Redux에 넣는다." 그게 정답이었고, 모두가 그렇게 했다. 그러다 사이즈가 커지면서 사람들은 깨달았다. "로그인 여부"와 "API 응답 캐시"와 "URL에서 읽은 탭 인덱스"와 "입력 중인 폼 값"을 똑같은 Redux store에 넣는 게 맞는가?
2025년의 대답은 명확하다: 아니다. 상태는 그 성격에 따라 최소 네 가지로 나뉘고, 각 축마다 최적의 도구가 다르다.
| 축 | 본질 | 대표 도구 |
|---|---|---|
| Server state | 서버가 주인, 캐시일 뿐 | TanStack Query, SWR, RSC fetch |
| URL state | URL이 주인, 공유·북마크 가능 | 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 드로어 상태 - "프로필 편집 폼" → 폼 상태 + 제출 후 서버 갱신
- "테마 다크모드" → 로컬(+
localStoragepersist) 또는 서버(계정 연동 시)
핵심 교훈: 하나의 도구로 네 축을 모두 다루지 마라. 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에 공식 어댑터.
왜 이렇게 커졌나
- 캐시·재검증·낙관적 업데이트의 반복 패턴을 라이브러리가 처리
- 로딩·에러·성공 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/shippingvs/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 DB → API → Client state → Component
RSC 구도
Server DB → Server Component (state) → Stream → Client
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 패턴들.
카카오·네이버·토스 결제 플로우
- 상품 선택 → 장바구니 담기
- 주문서 작성 (배송지·수령인·옵션)
- 결제 수단 선택 (카드·계좌·페이·포인트)
- 결제 진행 (외부 모듈·리다이렉트·인증)
- 결과 확인
각 단계가 실패·취소·뒤로가기·새로고침에 견뎌야 한다. 상태 머신(XState) + URL state + 서버 세션 키의 합작.
다음 우편번호·카카오 주소 API
- 모달로 열고, 선택 후 부모 폼에 inject
- RHF의
setValue로address·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개)
- 상태를 서버·URL·로컬·폼 4축으로 먼저 분류
- 서버 상태는 TanStack Query·SWR·RSC 사용
- URL 상태는 URL에 저장 + nuqs 같은 타입 안전 래퍼
- 로컬 상태는 먼저
useState, 성장 시 Zustand/Jotai - 폼 상태는 RHF·TanStack Form·Conform + Zod
- 복잡 UX 플로우는 XState
- 낙관적 업데이트는
onMutate/onError/onSettled3종 세트 - Persist는 꼭 필요한 상태에만 (localStorage 오남용 금지)
- 브라우저 뒤로가기·새로고침 테스트 (URL state 설계 검증)
- DevTools로 상태 변화 관찰 (Zustand·Redux·Jotai 모두 지원)
- 상태 이름은 무엇이 아니라 어떤 성격인지 드러내기
- Selector로 필요한 슬라이스만 구독
- RSC 시대에는 초기 데이터 = 서버 페칭, 이후 = Query 캐시
상태 관리 안티패턴 TOP 10
- Redux에 모든 것 넣기 — 서버 응답·URL·폼까지
- 로컬 상태를 Context로 — Provider 위치·리렌더 폭발
- URL 없이 탭 인덱스 관리 — 뒤로가기 파괴
- 폼마다 useState 20개 — RHF·TanStack Form 써라
- Zustand에 서버 데이터 수동 캐시 — TanStack Query 써라
- 낙관적 업데이트 없는 UX — "버튼 눌렀는데 아무 반응 없음"
- State Machine을 useState 조합으로 — 불가능 상태 폭발
- Persist에 토큰·PII 저장
- 깊은 props drilling을 방치
- 상태를 저장소에서 꺼내 매번 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에? 메모리에? 폼 라이프사이클에? 이 질문에 대답하는 순간, 쓸 도구는 저절로 정해진다."