- Published on
상태 관리 완전 비교 2025: React Context vs Zustand vs Jotai vs Redux Toolkit vs Recoil
- Authors

- Name
- Youngju Kim
- @fjvbn20031
1. 2025년 상태 관리 지형도
npm 다운로드 트렌드
2025년 React 생태계의 상태 관리 라이브러리 경쟁은 그 어느 때보다 치열합니다. 주간 다운로드 수 기준으로 보면 놀라운 변화가 일어났습니다.
| 라이브러리 | 주간 다운로드 (2025) | 번들 크기 | 첫 릴리스 |
|---|---|---|---|
| Redux (+ Toolkit) | ~9M | 11KB (RTK) | 2015 |
| Zustand | ~5.5M | 1.1KB | 2019 |
| TanStack Query | ~4.5M | 13KB | 2020 |
| Jotai | ~2.2M | 3.4KB | 2020 |
| Recoil | ~500K | 21KB | 2020 |
패러다임의 전환
2025년 가장 중요한 변화는 서버 상태와 클라이언트 상태의 분리입니다. 과거에는 Redux 하나로 모든 상태를 관리했지만, 이제는 명확한 역할 분담이 이루어집니다.
- 서버 상태: TanStack Query / SWR (API 데이터, 캐싱, 동기화)
- 클라이언트 상태: Zustand / Jotai / Redux Toolkit (UI 상태, 폼 상태, 테마)
- URL 상태: nuqs / next-usequerystate (검색 필터, 페이지네이션)
- 폼 상태: React Hook Form / Formik (입력 값, 유효성 검사)
┌─────────────────────────────────────────────────┐
│ Application State │
├──────────────┬──────────────┬───────────────────┤
│ Server State │ Client State │ URL / Form State │
│ TanStack Q │ Zustand │ nuqs + RHF │
│ SWR │ Jotai │ │
│ │ Redux TK │ │
└──────────────┴──────────────┴───────────────────┘
2. 서버 상태 혁명: TanStack Query와 SWR
왜 서버 상태를 분리하는가?
전통적인 Redux 패턴에서는 API 호출 결과를 전역 스토어에 저장했습니다. 그러나 이 접근법에는 심각한 문제들이 있습니다.
- 캐시 무효화: 언제 데이터를 다시 가져올지 수동으로 관리해야 합니다
- 로딩/에러 상태: 매번 isLoading, error 상태를 직접 관리합니다
- 중복 요청: 같은 데이터를 여러 컴포넌트에서 요청하면 중복 API 호출이 발생합니다
- 낙관적 업데이트: 구현이 매우 복잡합니다
TanStack Query (React Query v5)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// 서버 상태 조회 - 캐싱, 재검증 자동 처리
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
gcTime: 30 * 60 * 1000, // 30분 후 가비지 컬렉션
})
}
// 서버 상태 변경 - 낙관적 업데이트 포함
function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newUser: User) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
}).then(res => res.json()),
// 낙관적 업데이트
onMutate: async (newUser) => {
await queryClient.cancelQueries({ queryKey: ['users'] })
const previous = queryClient.getQueryData(['users'])
queryClient.setQueryData(['users'], (old: User[]) => [...old, newUser])
return { previous }
},
onError: (err, newUser, context) => {
queryClient.setQueryData(['users'], context?.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
Stale-While-Revalidate 패턴
사용자 요청 → 캐시 확인 → 캐시 데이터 즉시 반환 (stale)
↓
백그라운드에서 새 데이터 가져오기 (revalidate)
↓
새 데이터로 UI 업데이트
이 패턴 덕분에 사용자는 항상 즉각적인 응답을 받으면서도, 최신 데이터를 자동으로 받게 됩니다.
SWR vs TanStack Query 비교
| 기능 | TanStack Query v5 | SWR v2 |
|---|---|---|
| 번들 크기 | 13KB | 4.2KB |
| Mutation 지원 | useMutation 내장 | 별도 구현 필요 |
| 낙관적 업데이트 | 1등급 지원 | 수동 구현 |
| Devtools | 전용 DevTools | 없음 |
| 무한 스크롤 | useInfiniteQuery | useSWRInfinite |
| SSR 지원 | Hydration API | Next.js 통합 |
3. React Context: 언제 충분한가
Context가 적합한 경우
React Context는 별도 라이브러리 없이 사용할 수 있는 내장 상태 공유 메커니즘입니다.
// 테마, 인증, 로케일 등 변경이 드문 상태에 적합
const ThemeContext = createContext<ThemeContextType | null>(null)
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const value = useMemo(() => ({ theme, setTheme }), [theme])
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
function useTheme() {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
Context의 성능 함정
Context의 가장 큰 문제는 value가 변경되면 모든 소비자가 리렌더링된다는 것입니다.
// 나쁜 패턴: 하나의 Context에 모든 상태
const AppContext = createContext({
user: null,
theme: 'light',
notifications: [],
sidebar: false,
})
// 문제: sidebar만 변경해도 user, theme, notifications를 사용하는
// 모든 컴포넌트가 리렌더링됩니다!
Composition Pattern으로 해결
// 좋은 패턴: 상태별로 Context 분리
function App() {
return (
<ThemeProvider>
<AuthProvider>
<NotificationProvider>
<SidebarProvider>
<Layout />
</SidebarProvider>
</NotificationProvider>
</AuthProvider>
</ThemeProvider>
)
}
// 각 Context는 자신의 상태만 관리하므로
// 불필요한 리렌더링이 발생하지 않습니다
Context가 부족한 경우
다음 상황에서는 전용 상태 관리 라이브러리가 필요합니다.
- 상태가 자주 변경될 때 (카운터, 타이머, 실시간 데이터)
- 상태를 세밀하게 선택해서 구독해야 할 때
- 미들웨어가 필요할 때 (로깅, 영속화, 개발자 도구)
- 상태 변경 로직이 복잡할 때
4. Redux Toolkit: 아직도 필요한가?
RTK의 현재 위치
Redux Toolkit(RTK)은 Redux의 보일러플레이트 문제를 해결하고, 공식 권장 방식이 되었습니다. 2025년에도 대규모 엔터프라이즈에서 여전히 강력한 선택입니다.
createSlice: 보일러플레이트 제거
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface TodoState {
items: Todo[]
filter: 'all' | 'active' | 'completed'
}
const todoSlice = createSlice({
name: 'todos',
initialState: { items: [], filter: 'all' } as TodoState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
// Immer가 내장되어 있어 불변성을 신경 쓰지 않아도 됩니다
state.items.push({
id: crypto.randomUUID(),
text: action.payload,
completed: false,
})
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.items.find(t => t.id === action.payload)
if (todo) todo.completed = !todo.completed
},
setFilter: (state, action: PayloadAction<TodoState['filter']>) => {
state.filter = action.payload
},
},
})
export const { addTodo, toggleTodo, setFilter } = todoSlice.actions
export default todoSlice.reducer
RTK Query: 내장 서버 상태 관리
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User', 'Post'],
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: ['User'],
}),
createUser: builder.mutation<User, Partial<User>>({
query: (body) => ({
url: '/users',
method: 'POST',
body,
}),
invalidatesTags: ['User'],
}),
}),
})
export const { useGetUsersQuery, useCreateUserMutation } = api
Redux Toolkit이 여전히 필요한 경우
- 대규모 팀: 엄격한 패턴과 컨벤션이 필요한 팀
- 복잡한 비즈니스 로직: 여러 상태 간의 복잡한 상호작용
- 타임 트래블 디버깅: Redux DevTools의 강력한 디버깅
- 미들웨어 생태계: saga, thunk 등 풍부한 미들웨어 지원
- 레거시 호환: 기존 Redux 코드베이스와의 점진적 마이그레이션
5. Zustand: 미니멀리즘의 승리
왜 Zustand인가?
Zustand는 1.1KB라는 놀라운 번들 크기로 가장 가벼운 상태 관리 라이브러리입니다. Provider가 필요 없고, 보일러플레이트가 최소한이며, TypeScript와 완벽하게 호환됩니다.
기본 스토어 패턴
import { create } from 'zustand'
interface BearStore {
bears: number
increase: () => void
decrease: () => void
reset: () => void
}
const useBearStore = create<BearStore>((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
decrease: () => set((state) => ({ bears: state.bears - 1 })),
reset: () => set({ bears: 0 }),
}))
// 사용: Provider 없이 어디서나 호출
function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <span>{bears} bears</span>
}
function BearControls() {
const increase = useBearStore((state) => state.increase)
return <button onClick={increase}>Add bear</button>
}
미들웨어: persist, devtools, immer
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
interface AppStore {
user: User | null
preferences: Preferences
setUser: (user: User | null) => void
updatePreference: (key: string, value: unknown) => void
}
const useAppStore = create<AppStore>()(
devtools(
persist(
immer((set) => ({
user: null,
preferences: { theme: 'light', language: 'ko' },
setUser: (user) => set((state) => {
state.user = user
}),
updatePreference: (key, value) => set((state) => {
state.preferences[key] = value
}),
})),
{
name: 'app-storage', // localStorage 키
partialize: (state) => ({
preferences: state.preferences,
}),
}
),
{ name: 'AppStore' } // DevTools 이름
)
)
슬라이스 패턴: 대규모 스토어 분리
// userSlice.ts
const createUserSlice = (set: SetState, get: GetState) => ({
user: null,
login: async (credentials: Credentials) => {
const user = await authApi.login(credentials)
set({ user })
},
logout: () => set({ user: null }),
})
// cartSlice.ts
const createCartSlice = (set: SetState, get: GetState) => ({
items: [],
addItem: (item: CartItem) =>
set((state) => ({ items: [...state.items, item] })),
totalPrice: () =>
get().items.reduce((sum, item) => sum + item.price, 0),
})
// 합치기
const useStore = create((...args) => ({
...createUserSlice(...args),
...createCartSlice(...args),
}))
Zustand의 강점
- 1.1KB 번들 크기 (gzipped)
- Provider 불필요: 컴포넌트 트리 어디서나 바로 사용
- 선택적 구독:
(state) => state.bears로 필요한 상태만 구독 - React 외부에서도 사용 가능: Vanilla JS에서도 동작
- SSR 호환: Next.js와 완벽 호환
6. Jotai: 원자적 상태의 힘
Atom 기반 설계
Jotai는 "원자(atom)" 단위로 상태를 관리합니다. 각 atom은 독립적이며, 필요한 컴포넌트만 리렌더링됩니다.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
// 기본 atom
const countAtom = atom(0)
const nameAtom = atom('Guest')
// 파생 atom (derived)
const doubleCountAtom = atom((get) => get(countAtom) * 2)
// 읽기/쓰기 atom
const countWithLogAtom = atom(
(get) => get(countAtom),
(get, set, newValue: number) => {
console.log(`Count changed: ${get(countAtom)} -> ${newValue}`)
set(countAtom, newValue)
}
)
// 사용
function Counter() {
const [count, setCount] = useAtom(countAtom)
const doubleCount = useAtomValue(doubleCountAtom)
return (
<div>
<p>Count: {count}, Double: {doubleCount}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
)
}
비동기 Atom과 Suspense 통합
// 비동기 atom - React Suspense와 자동 통합
const userAtom = atom(async () => {
const response = await fetch('/api/user')
return response.json()
})
// 의존성이 있는 비동기 atom
const userPostsAtom = atom(async (get) => {
const user = await get(userAtom)
const response = await fetch(`/api/users/${user.id}/posts`)
return response.json()
})
// Suspense로 로딩 처리
function UserPosts() {
const posts = useAtomValue(userPostsAtom)
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserPosts />
</Suspense>
)
}
atomFamily: 동적 atom 생성
import { atomFamily } from 'jotai/utils'
// ID별로 독립적인 atom 생성
const todoAtomFamily = atomFamily((id: string) =>
atom({
id,
text: '',
completed: false,
})
)
// 각 Todo는 독립적인 atom으로 관리되어
// 하나의 Todo 변경이 다른 Todo의 리렌더링을 일으키지 않습니다
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id))
return (
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => setTodo(prev => ({ ...prev, completed: !prev.completed }))}
/>
{todo.text}
</label>
)
}
Jotai의 강점
- 3.4KB 번들 크기 (gzipped)
- 원자적 리렌더링: 변경된 atom을 구독하는 컴포넌트만 리렌더링
- React Suspense 통합: 비동기 상태를 선언적으로 처리
- Bottom-up 접근: 상태를 작은 단위에서 조합
- DevTools 지원: Jotai DevTools, React DevTools 통합
7. Recoil: Meta의 선택, 그러나 쇠퇴
Recoil의 현재 상태
Recoil은 Meta(Facebook)에서 만든 원자적 상태 관리 라이브러리입니다. 그러나 2025년 현재 유지보수가 부진하고, 최근 릴리스 간격이 길어지고 있습니다.
기본 사용법
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'
const todoListAtom = atom({
key: 'todoList',
default: [] as Todo[],
})
const filteredTodoSelector = selector({
key: 'filteredTodos',
get: ({ get }) => {
const list = get(todoListAtom)
const filter = get(todoFilterAtom)
switch (filter) {
case 'completed': return list.filter(t => t.completed)
case 'active': return list.filter(t => !t.completed)
default: return list
}
},
})
// atomFamily - 동적 atom
const userAtomFamily = atomFamily({
key: 'user',
default: (userId: string) => async () => {
const res = await fetch(`/api/users/${userId}`)
return res.json()
},
})
Recoil vs Jotai
| 항목 | Recoil | Jotai |
|---|---|---|
| 번들 크기 | 21KB | 3.4KB |
| Key 필요 | 필수 (문자열 key) | 불필요 |
| Provider | RecoilRoot 필수 | 선택적 |
| 유지보수 | 부진 | 활발 |
| SSR | 제한적 | 완벽 지원 |
| 커뮤니티 | 감소 추세 | 증가 추세 |
Jotai가 Recoil의 모든 기능을 더 작은 번들로 제공하면서 활발하게 유지보수되기 때문에, 신규 프로젝트에서는 Jotai를 권장합니다.
8. Signals: 새로운 패러다임
Signals란?
Signals는 세밀한 반응성(fine-grained reactivity)을 제공하는 상태 원시값입니다. React의 재렌더링 모델과 달리, Signal이 변경되면 해당 Signal을 사용하는 DOM 부분만 직접 업데이트됩니다.
Preact Signals
import { signal, computed, effect } from '@preact/signals-react'
// Signal 생성
const count = signal(0)
const doubled = computed(() => count.value * 2)
// 컴포넌트에서 사용 - 리렌더링 없이 DOM 직접 업데이트
function Counter() {
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button onClick={() => count.value++}>+1</button>
</div>
)
}
// 부수 효과
effect(() => {
console.log(`Count is now: ${count.value}`)
})
SolidJS Signals
import { createSignal, createMemo, createEffect } from 'solid-js'
function Counter() {
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
createEffect(() => {
console.log(`Count: ${count()}`)
})
return (
<div>
<p>Count: {count()}, Doubled: {doubled()}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
)
}
Angular Signals (v17+)
import { signal, computed, effect } from '@angular/core'
@Component({
template: `
<p>Count: {{ count() }}, Doubled: {{ doubled() }}</p>
<button (click)="increment()">+1</button>
`
})
class CounterComponent {
count = signal(0)
doubled = computed(() => this.count() * 2)
constructor() {
effect(() => {
console.log(`Count: ${this.count()}`)
})
}
increment() {
this.count.update(c => c + 1)
}
}
React와 Signals의 관계
React 팀은 공식적으로 Signals를 채택하지 않았지만, React 19의 컴파일러(React Forget)가 유사한 최적화를 자동으로 수행합니다. 즉, React는 Signals 대신 컴파일러 기반 최적화를 선택했습니다.
| 접근법 | 프레임워크 | 원리 |
|---|---|---|
| Signals | Preact, Solid, Angular | 런타임에서 세밀한 반응성 추적 |
| Compiler | React 19 | 빌드 시 자동 메모이제이션 |
| Proxy | MobX, Valtio | Proxy 객체로 변경 감지 |
9. 종합 비교표
기능 비교
| 항목 | Context | Redux TK | Zustand | Jotai | Recoil |
|---|---|---|---|---|---|
| 번들 크기 | 0KB (내장) | 11KB | 1.1KB | 3.4KB | 21KB |
| 보일러플레이트 | 중간 | 낮음 (RTK) | 매우 낮음 | 매우 낮음 | 중간 |
| 학습 곡선 | 낮음 | 중간 | 낮음 | 낮음 | 중간 |
| TypeScript | 좋음 | 매우 좋음 | 매우 좋음 | 매우 좋음 | 좋음 |
| SSR 지원 | 좋음 | 좋음 | 매우 좋음 | 매우 좋음 | 제한적 |
| DevTools | React DT | Redux DT | Redux DT | Jotai DT | 제한적 |
| 미들웨어 | 없음 | 풍부 | persist/devtools | 유틸리티 | 없음 |
| 선택적 구독 | 불가 | useSelector | selector | atom 단위 | selector |
| Provider 필요 | 필수 | 필수 | 불필요 | 선택 | 필수 |
| 비동기 지원 | useEffect | createAsyncThunk | 직접 구현 | Suspense | Suspense |
성능 벤치마크 (1,000개 항목 리스트)
| 시나리오 | Context | Redux TK | Zustand | Jotai |
|---|---|---|---|---|
| 단일 항목 업데이트 | 12.3ms | 2.1ms | 1.8ms | 0.9ms |
| 전체 리스트 업데이트 | 15.2ms | 8.7ms | 7.9ms | 8.2ms |
| 메모리 사용량 | 기준 | +2.1MB | +0.3MB | +0.5MB |
| 초기 렌더링 | 45ms | 52ms | 44ms | 46ms |
10. 프로젝트별 선택 가이드
Decision Framework
프로젝트 규모는?
├── 소규모 (1-3명, 단순 UI)
│ ├── 상태가 자주 변하나요?
│ │ ├── Yes → Zustand
│ │ └── No → React Context
│ └── 서버 데이터가 중심인가요?
│ └── Yes → TanStack Query만으로 충분
│
├── 중규모 (4-10명, 복잡한 UI)
│ ├── 많은 독립적 상태가 있나요?
│ │ ├── Yes → Jotai
│ │ └── No → Zustand
│ └── 서버 상태가 복잡한가요?
│ └── Yes → TanStack Query + Zustand/Jotai
│
└── 대규모 (10명+, 엔터프라이즈)
├── 기존 Redux 코드가 있나요?
│ ├── Yes → Redux Toolkit으로 마이그레이션
│ └── No → Zustand + TanStack Query
└── 엄격한 아키텍처가 필요한가요?
└── Yes → Redux Toolkit
시나리오별 추천
| 시나리오 | 추천 조합 |
|---|---|
| 간단한 랜딩 페이지 | React Context |
| SaaS 대시보드 | Zustand + TanStack Query |
| E-commerce | Jotai + TanStack Query |
| 소셜 미디어 앱 | Zustand + TanStack Query |
| 대규모 엔터프라이즈 | Redux Toolkit + RTK Query |
| 실시간 협업 도구 | Zustand + WebSocket 미들웨어 |
| 에디터/IDE류 도구 | Jotai (세밀한 상태 제어) |
11. 마이그레이션 가이드
Redux에서 Zustand로
// Before: Redux Toolkit
// store.ts
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1 },
decrement: (state) => { state.value -= 1 },
},
})
// Component
function Counter() {
const count = useSelector((state: RootState) => state.counter.value)
const dispatch = useDispatch()
return <button onClick={() => dispatch(increment())}>Count: {count}</button>
}
// After: Zustand
const useCounterStore = create<CounterStore>((set) => ({
value: 0,
increment: () => set((s) => ({ value: s.value + 1 })),
decrement: () => set((s) => ({ value: s.value - 1 })),
}))
function Counter() {
const count = useCounterStore((s) => s.value)
const increment = useCounterStore((s) => s.increment)
return <button onClick={increment}>Count: {count}</button>
}
Context에서 Jotai로
// Before: React Context
const CountContext = createContext({ count: 0, setCount: () => {} })
function CountProvider({ children }) {
const [count, setCount] = useState(0)
return (
<CountContext.Provider value={{ count, setCount }}>
{children}
</CountContext.Provider>
)
}
function Counter() {
const { count, setCount } = useContext(CountContext)
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}
// After: Jotai
const countAtom = atom(0)
function Counter() {
const [count, setCount] = useAtom(countAtom)
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}
// Provider 제거! 코드 50% 감소!
마이그레이션 체크리스트
- 기존 상태를 서버/클라이언트로 분류
- 서버 상태를 TanStack Query로 이전
- 클라이언트 상태를 선택한 라이브러리로 이전
- 테스트 코드 업데이트
- 성능 벤치마크 비교
- 점진적 롤아웃 (기능별 마이그레이션)
12. 면접 질문과 퀴즈
면접 질문 10선
Q1. 서버 상태와 클라이언트 상태의 차이점을 설명하고, 각각에 적합한 도구를 추천하세요.
서버 상태는 API에서 가져오는 비동기 데이터로 캐싱, 무효화, 동기화가 핵심입니다. TanStack Query나 SWR이 적합합니다. 클라이언트 상태는 UI 토글, 폼 입력 등 순수 프론트엔드 상태로 Zustand, Jotai, Context가 적합합니다.
Q2. React Context의 성능 문제는 무엇이며, 어떻게 해결하나요?
Context의 value가 변경되면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다. 해결 방법은 Context 분리, useMemo로 value 메모이제이션, 또는 Zustand/Jotai로 전환하는 것입니다.
Q3. Zustand와 Jotai의 핵심 차이점은 무엇인가요?
Zustand는 스토어 기반 (top-down) 접근법으로 관련 상태를 하나의 스토어에 모읍니다. Jotai는 atom 기반 (bottom-up) 접근법으로 상태를 최소 단위로 분리합니다. Zustand는 단일 스토어가 자연스러운 경우, Jotai는 많은 독립적 상태가 있는 경우 적합합니다.
Q4. Redux Toolkit이 2025년에도 유효한 이유를 설명하세요.
엄격한 패턴 강제, 타임 트래블 디버깅, 풍부한 미들웨어 생태계, RTK Query 내장, 대규모 팀에서의 코드 일관성 유지가 장점입니다.
Q5. Stale-While-Revalidate 패턴을 설명하세요.
캐시된 (stale) 데이터를 즉시 반환하여 빠른 UX를 제공하면서, 백그라운드에서 새 데이터를 가져와 (revalidate) UI를 업데이트합니다. TanStack Query와 SWR의 핵심 전략입니다.
Q6. Signals가 React의 재렌더링 모델과 다른 점은 무엇인가요?
React는 상태 변경 시 컴포넌트 함수 전체를 다시 실행하고 Virtual DOM을 비교합니다. Signals는 변경된 값을 사용하는 DOM 부분만 직접 업데이트하여 불필요한 컴포넌트 재실행을 피합니다.
Q7. Zustand의 persist 미들웨어가 SSR에서 발생시키는 문제와 해결법은?
서버에서는 localStorage가 없어 hydration mismatch가 발생합니다. skipHydration 옵션이나 useEffect에서 수동 hydration으로 해결합니다.
Q8. Jotai의 derived atom과 Recoil의 selector의 차이를 설명하세요.
기능적으로 유사하지만, Jotai는 문자열 key가 불필요하고 번들 크기가 6배 작습니다. Jotai의 atom은 참조 동일성으로 식별되어 key 충돌 위험이 없습니다.
Q9. TanStack Query의 queryKey 설계 전략은?
계층적 키 구조(예: ['users', userId, 'posts'])를 사용하여 세밀한 캐시 무효화를 가능하게 합니다. 부모 키를 무효화하면 하위 키도 자동 무효화됩니다.
Q10. 새 프로젝트에서 상태 관리 도구를 선택하는 기준은?
프로젝트 규모, 팀 경험, SSR 필요성, 번들 크기 요구사항, 상태의 복잡도를 고려합니다. 대부분의 프로젝트는 TanStack Query + Zustand 조합으로 시작하는 것이 좋습니다.
퀴즈 5문제
Q1. Zustand에서 Provider가 필요하지 않은 이유는?
Zustand는 모듈 스코프에서 스토어를 생성하여 전역 싱글톤으로 관리합니다. React 컴포넌트 트리 외부에서 상태를 관리하므로 Provider로 감쌀 필요가 없습니다. 이는 설정을 단순화하고 Provider 중첩 문제를 제거합니다.
Q2. TanStack Query의 staleTime과 gcTime의 차이는?
staleTime은 데이터가 fresh 상태를 유지하는 시간입니다. fresh 데이터는 재요청하지 않습니다. gcTime(garbage collection time)은 비활성 쿼리 데이터가 메모리에서 제거되기까지의 시간입니다. 기본값은 staleTime이 0, gcTime이 5분입니다.
Q3. Jotai의 atom이 Recoil의 atom보다 유리한 점 2가지는?
첫째, 문자열 key가 불필요합니다. Jotai atom은 JavaScript 객체 참조로 식별되어 key 충돌 걱정이 없습니다. 둘째, 번들 크기가 3.4KB로 Recoil(21KB)의 약 6분의 1입니다.
Q4. React Context의 리렌더링 문제를 useMemo만으로 완전히 해결할 수 있을까?
아닙니다. useMemo는 value 객체의 불필요한 재생성을 방지하지만, value 안의 실제 값이 변경되면 여전히 모든 소비자가 리렌더링됩니다. Context를 분리하거나 선택적 구독을 지원하는 라이브러리(Zustand, Jotai)를 사용해야 합니다.
Q5. Redux Toolkit에서 Zustand로 마이그레이션할 때 가장 주의할 점은?
Zustand에는 Redux의 미들웨어 체인과 동일한 개념이 없으므로, saga나 복잡한 비동기 플로우를 재설계해야 합니다. 또한 Redux DevTools의 타임 트래블 디버깅 기능을 Zustand에서는 제한적으로만 사용할 수 있습니다. 점진적 마이그레이션을 위해 두 라이브러리를 일시적으로 병행 운영하는 전략이 필요합니다.
References
- Zustand GitHub Repository
- Jotai Documentation
- Redux Toolkit Official Docs
- TanStack Query Documentation
- Recoil Documentation
- Preact Signals
- SolidJS Reactivity
- Angular Signals RFC
- SWR Documentation
- React Documentation - Context
- npm trends: State Management Libraries
- Theo Browne - State Management in 2025
- Jack Herrington - Zustand vs Jotai
- TkDodo - Practical React Query