상태 관리의 철학
상태 관리의 철학 — 클라이언트 상태와 서버 상태
React 앱의 상태 관리는 라이브러리가 많아 혼란을 부릅니다. 그러나 가장 큰 분기점은 라이브러리 선택이 아니라 상태가 클라이언트에 속하는가, 서버에 속하는가 입니다.
1. 두 종류의 상태
TanStack Query 의 문서가 정리한 구분이 자주 인용됩니다.
클라이언트 상태 (client state) 는 클라이언트가 소유합니다. 버튼이 눌렸는가, 사이드바가 열렸는가, 폼에 무엇을 입력했는가.
서버 상태 (server state) 는 서버가 소유하고 클라이언트는 사본을 들고 있을 뿐입니다. 다른 사용자가 바꿀 수 있고, 시간이 지나면 신선하지 않습니다 (stale).
이 둘을 같은 도구로 다루려 하면 어색해집니다.
- 서버 상태를 Redux store 에 넣으면 캐시 무효화·백그라운드 재요청·stale 처리를 직접 짜야 합니다.
- 클라이언트 상태를 React Query 에 욱여넣으면 의미 없는 query key 가 생깁니다.
2. 클라이언트 상태 도구
| 자리 | 도구 | 비고 |
|---|---|---|
| 컴포넌트 안 | useState · useReducer |
React 내장. |
| 컴포넌트 트리 안 | Context API | 값이 자주 바뀌면 리렌더 비용이 큼. |
| 앱 전역 (가벼움) | zustand · jotai · valtio | 작고 보일러플레이트 적음. |
| 앱 전역 (구조화) | Redux Toolkit · MobX | DevTools · 큰 팀에 일관된 패턴. |
라이브러리 출처 (라이선스는 모두 MIT):
| 도구 | 첫 릴리스 | 만든이 |
|---|---|---|
| Redux | 2015 | Dan Abramov · Andrew Clark. |
| Redux Toolkit (RTK) | 2019 | Redux 공식. boilerplate 축소. |
| MobX | 2015 | Michel Weststrate. observable 기반. |
| Recoil | 2020, Meta | atom/selector. 2025 년 1 월 아카이브 공지로 사실상 동결. 후속은 Jotai. |
| zustand | 2019 | pmndrs (Paul Henschel). bear store. |
| jotai | 2020 | pmndrs. atom 단위, Recoil 스타일. |
| valtio | 2021 | pmndrs. proxy 기반. |
| XState | 2017 | David Khourshid. 상태 머신. |
zustand 와 jotai 의 모델 차이가 자주 비교됩니다.
- zustand — 한 store 에 여러 값을 두고 selector 로 구독. "store 중심".
- jotai — 작은 atom 을 조합. "atom 중심", Recoil 의 영향을 명시적으로 인용.
3. 서버 상태 도구
서버 상태에는 캐시·재요청·낙관적 갱신·중복 제거가 필요합니다.
| 도구 | 첫 릴리스 | 만든이 |
|---|---|---|
| Apollo Client | 2016 | GraphQL 위주. 정규화 캐시. |
| SWR | 2019, Vercel | "stale-while-revalidate" RFC 5861 의 이름. |
| React Query → TanStack Query | 2020 (RQ v1) | Tanner Linsley. v4 (2022) 부터 TanStack 으로 개명. |
| RTK Query | 2021 | Redux Toolkit 동봉. |
| Relay | 2015 | Meta. GraphQL 전용. |
대부분 라이브러리가 다음 패턴을 공유합니다.
const { data, isLoading, error } = useQuery({
queryKey: ["posts", page],
queryFn: () => fetch(`/api/posts?p=${page}`).then(r => r.json()),
staleTime: 60_000,
})
- queryKey — 캐시 식별자. 같은 키는 같은 데이터.
- staleTime — "이 시간 동안은 신선하다고 간주" (다시 안 부른다).
- gcTime / cacheTime — 사용되지 않는 캐시를 메모리에서 비우는 시간.
- invalidate — 변경 후 관련 키를 무효화해 다음 사용 시점에 재요청.
4. Server Component 와의 관계
React Server Components (Next.js App Router 등) 가 등장하면서 서버 상태의 일부는 render 타임에 서버에서 직접 읽습니다. 이 경우 클라이언트 측 캐시 라이브러리가 필요 없을 수 있습니다.
export default async function Page() {
const posts = await db.post.findMany()
return <List items={posts} />
}
다만 인터랙션 가능한 상태 (낙관적 갱신 · 폴링 · 무한 스크롤) 는 여전히 클라이언트 캐시가 필요합니다. SWR/TanStack Query 와 RSC 는 경쟁이 아니라 분담에 가깝습니다.
또한 19 의 useOptimistic · useActionState 가 작은 폼 수준의 서버 상태를 다루는 표준 자리를 만들고 있습니다.
5. zustand
import { create } from "zustand"
type Store = {
count: number
inc: () => void
}
export const useCounter = create<Store>((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}))
// 컴포넌트
const count = useCounter((s) => s.count)
const inc = useCounter((s) => s.inc)
6. TanStack Query
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"
const qc = new QueryClient()
export function Root() {
return (
<QueryClientProvider client={qc}>
<App />
</QueryClientProvider>
)
}
function Posts() {
const { data, isPending } = useQuery({
queryKey: ["posts"],
queryFn: () => fetch("/api/posts").then(r => r.json()),
staleTime: 30_000,
})
if (isPending) return <p>...</p>
return <ul>{data.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>
}
7. SWR
import useSWR from "swr"
const fetcher = (url: string) => fetch(url).then(r => r.json())
function Posts() {
const { data, error, isLoading } = useSWR("/api/posts", fetcher)
if (isLoading) return <p>...</p>
if (error) return <p>error</p>
return <ul>{data.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>
}
8. 자주 걸리는 자리
서버 상태를 Redux 에 넣음 — 가능은 하지만 캐시 무효화·재요청을 수동으로 짜야 합니다. 큰 비용입니다.
useEffect 안에서 fetch — 작은 앱은 괜찮지만 동시성·취소·중복 제거를 직접 다뤄야 합니다. 페이지가 늘어날수록 SWR 또는 TanStack Query 같은 도구가 가벼워집니다.
Context 의 과사용 — 한 Context 가 자주 바뀌면 그 트리 전체가 리렌더됩니다. zustand · jotai 의 selector 가 이 자리를 잘 메웁니다.
queryKey 설계 — 키가 너무 거칠면 무효화 범위가 넓어지고, 너무 잘게 나누면 invalidate 조합이 폭발합니다. 자원 + 식별자 + 필터 조합이 권장되는 출발점입니다.
localStorage 동기화의 SSR 문제 — 서버에서는 window 가 없습니다. zustand persist 미들웨어 등도 hydration mismatch 를 일으킬 수 있어 if (typeof window !== "undefined") 가드 또는 useEffect 동기화가 필요합니다.
stale-while-revalidate 의 의미 — "오래된 응답을 잠시 보여주고 백그라운드에서 새로 받는다" 는 RFC 5861 의 캐시 모델. SWR 의 이름이 여기서 왔습니다.
하고픈 말
상태 관리는 도구 선택보다 "이 상태가 어디 속하는가" 의 결정이 더 큰 차이를 만듭니다. 클라이언트 상태와 서버 상태를 처음부터 다른 도구로 다루는 흐름이 운영 부담을 줄입니다. RSC 가 등장하면서 서버 상태의 일부가 더 가까이 왔지만 인터랙션이 있는 자리는 여전히 클라이언트 캐시가 답입니다.
Next
- styling-tailwind
- tauri-over-electron
TanStack Query 문서 · SWR 공식 · Redux Toolkit · zustand · Jotai · RFC 5861 · Choosing the State Structure · XState 를 참고합니다.