필사 모드: 현대 프론트엔드 상태 관리의 진화 — 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대 원칙:**
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.
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".
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처럼"
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');
}
// 컴포넌트에서 그대로 호출
**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 (
{errors.email && <span>Error</span>}
);
**2024년 npm 주간 다운로드 7M+.** 업계 표준.
TanStack Form (2024)
Tanner Linsley가 직접. 프레임워크 중립(React/Vue/Solid/Lit). 타입 안전성이 업계 최상위.
Formik — 쇠퇴
2018-2020년 지배했지만 성능·타입 이슈로 React Hook Form에 추월. **유지보수 모드.**
Zod + 폼
2024-2025년 사실상 표준:
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**는 이걸 수학적으로 정리한다.
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
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
"앱을 해킹당하지 않는 법"의 실무편. 다음 글에서.
현재 단락 (1/307)
"HTML은 문서, CSS는 스타일, JS는 동작"이던 시대는 끝났다. **2025년의 UI는 살아있는 상태 기계**다.