✍️ 필사 모드: 현대 프론트엔드 상태 관리의 진화 — Redux, Zustand, Jotai, Signals, TanStack Query, RSC, XState 완전 해부 (2025)
한국어왜 상태는 프론트엔드의 영원한 난제인가
"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대 원칙:
- Single Source of Truth — 하나의 스토어.
- State is Read-Only — action을 통해서만 변경.
- 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년경부터 피로감이 쌓였다. 주된 이유:
- Boilerplate 폭발 — 기능 하나 추가에 action type, action creator, reducer, selector, thunk 5개 파일.
- 비동기가 2등 시민 — redux-thunk/saga/observable 중 택하라는 3파전.
- 서버 상태와 클라이언트 상태 혼재 — 캐싱·재검증·optimistic을 수동으로.
- Immutability 강제가 실수의 원천 —
state.items.push()한 번에 버그. - 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 API —
ref/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 Updates —
onMutate로 롤백 가능한 낙관적 변경. - 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분류
- 서버 상태 → TanStack Query / SWR / RTK Query
- URL 상태 → nuqs / next/navigation / 직접
- 글로벌 클라이언트 상태 → Zustand / Jotai / Redux Toolkit
- 로컬 UI 상태 → useState / useReducer
- 폼 상태 → React Hook Form / TanStack Form
- 복잡한 플로우 → 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항목)
- 서버 상태와 클라이언트 상태 분리 — 이게 대전제.
- 글로벌 상태는 최소화 — 대부분은 로컬이면 충분.
- URL에 담을 수 있는 건 URL로 — 공유 가능성을 얻는다.
- 폼은 전용 라이브러리 — 글로벌 상태에 넣지 말라.
- Optimistic UI는 롤백과 함께 — 실패 케이스 UX가 핵심.
- 무한 리렌더링 근본 원인은 90% 객체 참조 — useMemo/selectors.
- Suspense를 활용하라 — 로딩 상태를 선언적으로.
- React DevTools Profiler로 정기 측정.
- XState는 '정말 복잡한' 곳에만 — CRUD에는 과잉.
- 폼 검증은 Zod + 스키마 — 프론트·백엔드 공유.
- 서버 컴포넌트를 먼저 시도하라 — 필요할 때만 클라이언트.
- 상태 트리를 그림으로 그려라 — 문서화의 가장 큰 효과.
Part 13 — 10대 안티패턴
- 모든 걸 Redux에 — 로컬 UI 상태까지 글로벌화.
- 서버 데이터를 Redux에 넣고 수동 캐싱 — TanStack Query가 할 일.
- 폼을 전역 상태에 — 페이지 전환 시 혼돈.
- URL 상태를 글로벌에 복제 — 진실의 두 소스.
- useState 연쇄로 복잡 로직 모델링 — XState가 맞는 순간이 있다.
- setState 후 바로 읽기 — 비동기성 무시.
- useEffect로 데이터 fetching — 2024년 이후는 TanStack Query / RSC.
- 컴포넌트 트리 전체에 Context — 리렌더 폭주.
- 깊은 중첩 없이 정규화만 — 작은 앱에 Normalizr 오버엔지.
- "이거 한 줄로 해결돼요" — 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
"앱을 해킹당하지 않는 법"의 실무편. 다음 글에서.
현재 단락 (1/311)
"HTML은 문서, CSS는 스타일, JS는 동작"이던 시대는 끝났다. **2025년의 UI는 살아있는 상태 기계**다.