상태관리 패턴 비교 & 횡단관심사 관리 전략
상태를 종류별로 나누고, 도구 선택 기준과 횡단관심사 배치까지
Memo: React 프로젝트를 개발하면서 정리한 상태관리 패턴 비교와 횡단관심사 관리 전략. 라이브러리 나열이 아니라, “언제 무엇을 선택하는가”에 초점을 맞춘다.
도입: 상태관리, 왜 이렇게 어려운가?
React 생태계에는 상태관리 라이브러리가 10개가 넘는다. Redux, Zustand, Jotai, MobX, Recoil, Valtio, XState, Signals… 매년 새로운 라이브러리가 등장하고, 프로젝트를 시작할 때마다 같은 질문이 반복된다.
“우리 프로젝트는 뭘 써야 하지?”
이 질문이 어려운 이유는 간단하다. "상태"라는 단어가 너무 많은 것을 가리키고 있기 때문이다. API 응답 캐시도 상태, 로그인 여부도 상태, URL의 필터 파라미터도 상태, 폼의 입력값도 상태. 이 전부를 하나의 도구로 관리하려 하면 복잡해질 수밖에 없다.
이 글에서는 상태의 종류를 먼저 구분하고, 각 종류에 맞는 도구를 비교한 뒤, 실제 프로젝트에서 어떤 선택을 했고 왜 그랬는지를 공유한다. 마지막으로, 상태관리만큼 중요하지만 덜 논의되는 횡단관심사(Cross-Cutting Concerns) 관리 전략도 다룬다.
1. 먼저 정리: 상태의 종류
상태관리 라이브러리를 비교하기 전에, "상태"를 종류별로 나누자. 대부분의 상태관리 고민은 이 구분만으로 해소된다.
| 종류 | 특징 | 예시 | 적합한 도구 |
|---|---|---|---|
| 서버 상태 | 원본이 서버에 있음. 캐싱, 동기화, 무효화가 핵심 | API 응답, 목록 데이터, 상세 정보 | TanStack Query, SWR |
| 클라이언트 상태 | 원본이 클라이언트에만 있음. UI 로직 | 모달 open/close, 테마, 인증 세션 | Zustand, Jotai, Redux |
| URL 상태 | URL 쿼리 파라미터와 동기화. 공유/뒤로가기 지원 | 필터, 페이지네이션, 정렬, 탭 | nuqs, useSearchParams |
| 폼 상태 | 입력값 + 유효성 검증 + 에러. 제출 시 소멸 | 로그인 폼, 생성/수정 폼 | React Hook Form, Zod |
Note: 핵심 인사이트: "어떤 상태관리 라이브러리를 쓸까?"가 아니라, “이 상태는 어떤 종류인가?”를 먼저 물어야 한다. 종류가 다르면 도구도 달라야 한다.
흔한 실수: 모든 상태를 하나의 스토어에 넣기
// Bad: Redux에 서버 상태까지 넣는 패턴
const store = {
user: { data: null, loading: false, error: null }, // 서버 상태
posts: { data: [], loading: false, error: null }, // 서버 상태
theme: 'light', // 클라이언트 상태
filters: { search: '', page: 1 }, // URL 상태
}
// Good: 종류별로 분리
// 서버 상태 → TanStack Query (캐시, 리페치 자동)
// 클라이언트 상태 → Zustand (가벼운 전역 스토어)
// URL 상태 → nuqs (URL ↔ 상태 동기화)
// 폼 상태 → React Hook Form + Zod (제출 시 소멸)
2. 클라이언트 상태관리 패턴 비교
상태의 종류를 구분했으니, 이제 클라이언트 상태에 집중해서 라이브러리를 비교하자. 단순 Counter가 아니라, 실전에 가까운 인증 세션 스토어를 기준으로 비교한다.
2-1. Redux Toolkit (Flux → Slice 패턴)
상태와 액션을 분리하는 것이 원래 철학이었지만, RTK에서 slice로 한곳에 모으는 방향으로 진화했다.
// Redux Toolkit — Slice 패턴
const sessionSlice = createSlice({
name: 'session',
initialState: { user: null, token: null } as SessionState,
reducers: {
login: (state, action: PayloadAction<LoginPayload>) => {
state.user = action.payload.user
state.token = action.payload.token
},
logout: (state) => {
state.user = null
state.token = null
},
},
})
// 컴포넌트에서 사용
const user = useSelector((s: RootState) => s.session.user)
dispatch(sessionSlice.actions.login({ user, token }))
- 상태 + 리듀서(액션)가 한 파일에 공존
- Immer 내장으로 불변성 자동 처리
- 여전히
dispatch(action)간접 호출 방식 - React 트리 밖에서 접근하려면
store.getState()/store.dispatch()필요
2-2. Zustand (단일 스토어 + 액션 통합)
상태와 액션을 하나의 객체에 평탄하게 선언한다.
// Zustand — 상태와 액션이 하나의 객체
const useSessionStore = create<SessionStore>((set, get) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
// get()으로 현재 상태 읽기
isAuthenticated: () => get().token !== null,
}))
// 컴포넌트에서 사용
const user = useSessionStore((s) => s.user)
useSessionStore.getState().login(user, token)
// React 트리 밖에서도 접근 가능 (인터셉터 등)
useSessionStore.getState().logout()
- dispatch 없이
store.login()직접 호출 - 보일러플레이트 최소
- React 트리 밖에서
getState()접근 가능 — 이것이 인터셉터/미들웨어에서 결정적 장점 - slice 패턴으로 분리도 가능 (
...createSessionSlice(set, get))
2-3. MobX (Observable + Action 데코레이터)
클래스 기반으로 상태(observable)와 액션(action)을 한 클래스에 선언한다.
// MobX — 클래스 기반 Observable
class SessionStore {
user: User | null = null
token: string | null = null
constructor() {
makeAutoObservable(this)
}
login(user: User, token: string) {
this.user = user // 직접 변이 허용
this.token = token
}
logout() {
this.user = null
this.token = null
}
get isAuthenticated() { // computed (자동 캐싱)
return this.token !== null
}
}
makeAutoObservable이 프로퍼티→observable, 메서드→action, getter→computed 자동 매핑- 뮤테이션 직접 허용 (Proxy 기반 추적)
- OOP 스타일에 익숙하면 자연스러움
2-4. Jotai (Atomic 패턴)
상태를 atom 단위로 쪼개고, 파생 상태는 derived atom으로 만든다.
// Jotai — Atom 단위 분산
const userAtom = atom<User | null>(null)
const tokenAtom = atom<string | null>(null)
// derived atom (읽기 전용)
const isAuthenticatedAtom = atom((get) => get(tokenAtom) !== null)
// write atom (액션)
const loginAtom = atom(null, (get, set, payload: LoginPayload) => {
set(userAtom, payload.user)
set(tokenAtom, payload.token)
})
const logoutAtom = atom(null, (get, set) => {
set(userAtom, null)
set(tokenAtom, null)
})
- 상태와 액션이 별도 atom으로 분리
- 컴포넌트 트리 아무 곳에서나 구독 가능
- 글로벌 스토어 없이 bottom-up 구성
- 상태가 많아지면 atom 파일이 분산되어 전체 그림 파악이 어려울 수 있음
2-5. XState (상태 머신 패턴)
상태와 액션을 유한 상태 머신 그래프로 선언한다.
// XState — 유한 상태 머신
const authMachine = createMachine({
id: 'auth',
initial: 'unauthenticated',
context: { user: null, token: null },
states: {
unauthenticated: {
on: { LOGIN: { target: 'authenticated', actions: 'setSession' } },
},
authenticated: {
on: { LOGOUT: { target: 'unauthenticated', actions: 'clearSession' } },
},
},
})
- 가능한 상태 전이를 명시적으로 정의
- "불가능한 상태"를 타입 레벨에서 차단 (예: 로그아웃 상태에서 LOGOUT 이벤트 불가)
- 복잡한 비동기 흐름(로딩/에러/재시도)에 강점
- 단순한 토글/CRUD에는 과도함
2-6. Signals (새로운 패러다임)
2024~25년부터 주목받는 패러다임. Solid.js에서 시작해 Preact, Angular, Vue 등으로 확산 중.
// Preact Signals
import { signal, computed } from '@preact/signals-react'
const user = signal<User | null>(null)
const token = signal<string | null>(null)
const isAuthenticated = computed(() => token.value !== null)
// 변경 시 구독 중인 컴포넌트만 리렌더 (가상 DOM diffing 없이)
function login(u: User, t: string) {
user.value = u
token.value = t
}
- 세밀한 반응성: atom처럼 개별 값 단위 구독
- 리렌더 최소화: 가상 DOM diffing 없이 DOM 직접 업데이트 (프레임워크에 따라 다름)
- React에서는 아직 실험적 —
@preact/signals-react가 있지만, React의 Concurrent Mode와 충돌 가능성 - 현재 상태: React 메인스트림은 아니지만, 패러다임 자체는 주목할 가치
패턴 요약 비교
| 라이브러리 | 패턴 | 상태+액션 위치 | 뮤테이션 방식 | React 밖 접근 | 번들 (gzip) |
|---|---|---|---|---|---|
| Redux TK | Flux (slice) | slice 파일에 통합 | Immer (불변) | store.getState() | ~11 KB |
| Zustand | 단일 스토어 | create() 안에 통합 | set() (불변) | getState() 직접 | ~1.2 KB |
| MobX | Observable | 클래스에 통합 | 직접 변이 (Proxy) | 인스턴스 직접 | ~16 KB |
| Jotai | Atomic | atom별 분산 | set() (불변) | createStore() | ~2.4 KB |
| XState | 상태 머신 | machine 정의에 통합 | assign() (불변) | interpret() | ~12 KB |
| Signals | 반응형 값 | signal별 분산 | 직접 할당 | 직접 참조 | ~1.5 KB |
리렌더 특성 비교
| 라이브러리 | 구독 범위 | 리렌더 최적화 |
|---|---|---|
| Redux TK | useSelector 반환값 비교 | selector 결과가 바뀔 때만 리렌더. shallowEqual 주의 |
| Zustand | selector 함수로 선택 | (s) => s.user처럼 필요한 값만 구독. 자동 최적화 |
| MobX | 렌더 중 접근한 observable | 자동 추적. observer() HOC 필요 |
| Jotai | atom 단위 | 사용하는 atom이 바뀔 때만. 가장 세밀함 |
| XState | useSelector로 선택 | selector 결과가 바뀔 때만 |
| Signals | signal 단위 | vDOM diffing 없이 DOM 직접 업데이트 (이론적 최적) |
Note: 공통 트렌드: 최근 라이브러리들은 상태와 액션을 한 곳에 공존(co-location) 시키는 방향으로 수렴하고 있다. 예전 Redux처럼 action types / action creators / reducers를 3개 파일로 분리하던 시대는 지났고, "관련된 것은 가까이 두자"가 대세다.
3. 서버 상태는 "상태관리"가 아니다
가장 흔한 실수 중 하나: API 응답을 Redux나 Zustand에 넣는 것. 서버 상태는 클라이언트 상태와 근본적으로 다르다.
| 특성 | 클라이언트 상태 | 서버 상태 |
|---|---|---|
| 원본 위치 | 브라우저 메모리 | 서버 DB |
| 소유자 | 프론트엔드 | 백엔드 |
| 동기화 | 필요 없음 | 항상 stale할 수 있음 |
| 핵심 과제 | UI 반응성 | 캐싱, 무효화, 리페치, 낙관적 업데이트 |
TanStack Query의 패러다임
TanStack Query(구 React Query)는 서버 상태를 캐시로 취급한다. 직접 상태를 관리하는 게 아니라, "이 데이터가 언제 stale해지는가?"를 선언하면 라이브러리가 알아서 리페치한다.
// Redux식 서버 상태 관리 (수동으로 모든 것을 관리)
dispatch(fetchUsersStart())
try {
const users = await api.getUsers()
dispatch(fetchUsersSuccess(users))
} catch (e) {
dispatch(fetchUsersFailure(e))
}
// TanStack Query (선언적)
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: () => api.getUsers(),
staleTime: 5 * 60 * 1000, // 5분간 fresh
})
// 로딩, 에러, 리페치, 캐시 무효화 — 전부 자동
Query Keys Factory 패턴
엔티티가 많아지면 queryKey 관리가 중요해진다. Factory 패턴으로 체계화할 수 있다.
// entities/account/api/useAccountQueries.ts
export const accountKeys = {
all: ['account'] as const,
lists: () => [...accountKeys.all, 'list'] as const,
list: (filters: AccountFilters) =>
[...accountKeys.lists(), filters] as const,
details: () => [...accountKeys.all, 'detail'] as const,
detail: (id: string) => [...accountKeys.details(), id] as const,
}
// 사용
useQuery({ queryKey: accountKeys.list(filters), ... })
// 캐시 무효화 — 관련 쿼리만 정확히 무효화
queryClient.invalidateQueries({ queryKey: accountKeys.lists() })
SWR vs TanStack Query
| 항목 | TanStack Query | SWR |
|---|---|---|
| 캐시 무효화 | queryKey 기반 (세밀한 제어) | mutate() 수동 호출 |
| Mutation | useMutation 내장 (onSuccess, onError, invalidation) | 별도 구현 필요 |
| DevTools | 공식 DevTools (캐시 상태 시각화) | 커뮤니티 |
| Suspense | useSuspenseQuery 공식 지원 | suspense 옵션 |
| 번들 | ~12 KB | ~4 KB |
| 적합 | CRUD 중심 백오피스, 복잡한 캐시 전략 | 읽기 위주, 가벼운 프로젝트 |
4. 프로젝트에서의 선택과 근거
React 프로젝트를 개발하면서 내린 선택과 그 이유를 공유한다.
최종 선택
| 상태 종류 | 선택한 도구 | 이유 |
|---|---|---|
| 서버 상태 | TanStack Query | 20개 이상 엔티티의 캐시/무효화 체계가 필요. Query Keys Factory로 관리 |
| 클라이언트 상태 | Zustand | React 트리 밖(API 인터셉터)에서 접근 필요. 번들 1.2KB. 보일러플레이트 최소 |
| URL 상태 | nuqs | 다수 필터 페이지의 URL ↔ 상태 동기화. 뒤로가기/공유 URL 지원 |
| 폼 상태 | React Hook Form + Zod | 멀티스텝 폼, 복잡한 유효성 검증. 리렌더 최적화 내장 |
Zustand를 선택한 결정적 이유: React 밖에서의 접근
인증 토큰 갱신 로직은 API 인터셉터에서 동작한다. 인터셉터는 React 컴포넌트가 아니므로, 훅을 쓸 수 없다. 이것이 Zustand 선택의 결정적 이유였다.
// Zustand: React 트리 밖에서도 getState()로 접근 가능
const token = useSessionStore.getState().token
// 중복 방지 — 여러 401이 동시에 와도 refresh는 1회만
let refreshPromise: Promise<string> | null = null
async function refreshToken() {
if (refreshPromise) return refreshPromise
refreshPromise = doRefresh().finally(() => { refreshPromise = null })
return refreshPromise
}
Context나 Jotai였다면 이 코드를 작성할 수 없었을 것이다. 같은 이유로 토스트(알림) 스토어도 Zustand로 만들었다 — 인터셉터에서 에러 발생 시 토스트를 띄워야 했기 때문이다.
탈락한 선택지들
Redux Toolkit — 왜 안 썼나?
- 이 프로젝트에서 클라이언트 상태는 인증 세션과 토스트 정도. 슬라이스 2개를 위해 RTK(~11KB)를 쓰는 건 과도
- dispatch 간접 호출이 Zustand의 직접 호출보다 번거로움
- 서버 상태는 TanStack Query가 담당하므로 RTK Query도 불필요
Jotai — 왜 안 썼나?
- Atomic 패턴은 상태가 많고 서로 파생 관계가 복잡할 때 강점
- 이 프로젝트의 클라이언트 상태는 단순(세션, 토스트). atom으로 쪼갤 이점이 적음
- React 밖에서 접근하려면
createStore()를 별도 설정해야 함 — Zustand보다 번거로움
XState — 왜 안 썼나?
- 비즈니스 프로세스는 10개 이상 상태 × 정상/비정상 분기로 복잡. XState의 유한 상태 머신이 적합해 보임
- 하지만 이 상태의 원본은 서버. 프론트는 서버가 보내주는 status를 표시만 하는 것이지, 프론트에서 상태 전이를 관리하는 게 아님
- 서버 상태를 "받아서 시각화"하는 건 TanStack Query + 유틸 함수로 충분. XState는 클라이언트가 상태 전이를 제어할 때 빛남
5. 횡단관심사 — 상태관리의 숨은 절반
상태관리 도구를 선택했으면, 다음 질문은 이것이다:
“인증 토큰 주입은 어디서? 에러 핸들링은? 토스트는? 이 로직을 컴포넌트마다 반복할 건가?”
이런 횡단관심사(Cross-Cutting Concerns)는 여러 기능에 걸쳐 존재하지만, 특정 기능에 속하지 않는 로직이다. 상태관리만큼 중요하지만 상대적으로 덜 다뤄진다.
횡단관심사의 두 가지 분류
암묵적 횡단관심사 (투명하게 동작)
컴포넌트가 몰라도 되는 것들 — 자동으로 끼어든다.
| 관심사 | 처리 방식 | 실전 예시 |
|---|---|---|
| API 인터셉터 (토큰 주입, 401 처리) | 미들웨어/인터셉터 | api-setup.ts에서 side-effect import |
| 글로벌 에러 핸들링 | QueryClient onError | TanStack Query의 전역 에러 콜백 |
| 로깅/모니터링 | 인터셉터 or ErrorBoundary | API 응답 로깅, 에러 리포팅 |
명시적 횡단관심사 (의식적으로 사용)
컴포넌트가 알아야 하는 것들 — 직접 호출/구독한다.
| 관심사 | 처리 방식 | 실전 예시 |
|---|---|---|
| 인증 상태 (로그인 여부) | Zustand 스토어 | useSessionStore((s) => s.user) |
| 권한 체크 | 가드 컴포넌트, 훅 | RoleBasedRoute, ProtectedRoute |
| 토스트/알림 | 전역 스토어 | useToastStore로 인터셉터에서도 토스트 |
| 로딩 오버레이 | 전역 스토어 or React 상태 | mutation 중 로딩 표시 |
| 테마/다크모드 | Context or CSS 변수 | 변경 빈도 낮으므로 Context 충분 |
관리 전략 4가지
전략 1: Provider 체인 (React Context)
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
<RouterProvider />
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
- 장점: React 트리에 자연스럽게 녹아듦
- 단점: Provider Hell, 리렌더 전파 위험
- 적합: 테마, 로케일 등 저빈도 변경 값
전략 2: 전역 스토어 (Zustand)
const useToastStore = create<ToastStore>((set) => ({
toasts: [],
add: (toast) => set(s => ({ toasts: [...s.toasts, { id: Date.now(), ...toast }] })),
remove: (id) => set(s => ({ toasts: s.toasts.filter(t => t.id !== id) })),
}))
// React 밖에서도 토스트를 띄울 수 있음
useToastStore.getState().add({ type: 'error', message: '오류가 발생했습니다.' })
- 장점: React 트리 밖에서도 접근 가능
- 단점: 남용하면 어디서든 상태를 바꿀 수 있어 추적이 어려움
- 적합: 토스트, 인증 세션, 글로벌 로딩
전략 3: 미들웨어/인터셉터 (암묵적 주입)
// 토큰 주입 + 401 처리 — 비즈니스 로직은 이 존재를 모름
api.use({
onRequest: ({ request }) => {
const token = useSessionStore.getState().token
if (token) request.headers.set('Authorization', `Bearer ${token}`)
},
onResponse: async ({ response }) => {
if (response.status === 401) await refreshToken()
},
})
- 장점: 비즈니스 로직이 횡단관심사를 전혀 모름
- 단점: 디버깅 시 흐름 추적이 어려울 수 있음
- 적합: 인증 토큰, 에러 변환, 로깅
전략 4: 가드 컴포넌트 / 데코레이터 훅
// ProtectedRoute — 인증 필수 라우트
function ProtectedRoute({ children }: Props) {
const isAuthenticated = useSessionStore((s) => s.isAuthenticated)
if (!isAuthenticated) return <Navigate to="/login" />
return children
}
// RoleBasedRoute — 역할 기반 접근 제어
function RoleBasedRoute({ allowedRoles, children }: Props) {
const role = useSessionStore((s) => s.user?.role)
if (!allowedRoles.includes(role)) return <Navigate to="/403" />
return children
}
- 장점: 명시적이고 조합 가능
- 단점: 매번 감싸거나 호출해야 함
- 적합: 권한 체크, 폼 더티 가드, 인증 필수 라우트
안티패턴: 이렇게 하지 마세요
안티패턴 1: 모든 걸 Context에 넣기
// Context에 자주 바뀌는 값을 넣으면 전체 트리 리렌더
const AppContext = createContext({
user: null,
theme: 'light',
notifications: [], // 자주 추가/삭제됨 → 전체 리렌더
sidebarOpen: false, // 토글할 때마다 전체 리렌더
})
해결: 자주 바뀌는 값은 Zustand 스토어로 분리. Context는 저빈도 변경 값(테마, 로케일)만.
안티패턴 2: Zustand 스토어 남용 (God Store)
// 하나의 거대한 스토어에 모든 것을 넣기
const useStore = create((set) => ({
user: null,
theme: 'light',
toasts: [],
sidebarOpen: false,
modalState: null,
filters: {},
// ... 끝없이 추가
}))
해결: 관심사별로 스토어를 분리. useSessionStore, useToastStore, useThemeStore 각각 독립.
안티패턴 3: 서버 상태를 클라이언트 스토어에 복사하기
// API 응답을 Zustand에 저장
const useUserStore = create((set) => ({
users: [],
fetchUsers: async () => {
const data = await api.getUsers()
set({ users: data }) // stale 데이터가 영원히 남음
},
}))
해결: 서버 데이터는 TanStack Query에게 맡기기. 캐시, stale 판단, 리페치를 자동 처리해준다.
6. FSD에서의 배치 원칙
상태관리 도구와 횡단관심사 전략을 정했으면, 마지막 질문은 “이 코드를 어디에 두는가?”이다.
Feature-Sliced Design(FSD)에서는 “이 관심사를 누가 소유하는가”로 배치를 결정한다.
shared/ ← 인프라 레벨 횡단관심사
lib/ ← 유틸 (cn, format, clipboard)
store/ ← 글로벌 스토어 (toast, theme)
api/ ← API 클라이언트, 인터셉터 설정
entities/ ← 도메인 레벨 횡단관심사
session/ ← 인증 상태 (여러 feature가 참조)
app/ ← 조립 레벨 횡단관심사
providers/ ← Provider 체인, 인터셉터 등록, 세션 타임아웃
routes/ ← 라우트 가드 (ProtectedRoute, RoleBasedRoute)
| 판단 기준 | 배치 위치 | 예시 |
|---|---|---|
| 도메인과 무관한 인프라 | shared |
API 클라이언트, 토스트 스토어, 포맷팅 유틸 |
| 도메인 데이터지만 여러 곳에서 읽힘 | entities |
인증 세션 (feature, widget, page 모두 참조) |
| 앱 전체를 감싸는 설정/조립 | app |
Provider 체인, 인터셉터 등록, 라우트 가드 |
| 특정 기능의 사이드이펙트 | 해당 feature |
로그인 mutation, 토큰 갱신 로직 |
실전 판단 플로우
Q: Q1. 이 관심사가 React 트리 안에서만 필요한가?
- Yes → Context 또는 훅
- No (인터셉터, 외부 라이브러리에서도 접근) → Zustand 전역 스토어
Q2. 상태 변경 빈도가 높은가?
- Yes → Zustand (selector로 리렌더 최소화)
- No → Context도 충분
Q3. 컴포넌트가 이 관심사를 알아야 하는가?
- Yes → 훅/가드로 명시적 사용
- No → 미들웨어/인터셉터로 암묵적 처리
7. 결론: 정답은 없지만, 판단 기준은 있다
핵심 테이크어웨이 3가지
1. 상태의 종류를 먼저 구분하라. 서버/클라이언트/URL/폼 — 종류가 다르면 도구도 달라야 한다. "어떤 상태관리 라이브러리?"가 아니라 "이 상태는 어떤 종류인가?"를 먼저 물어라.
2. 서버 상태를 클라이언트 스토어에 넣지 마라. 서버 상태의 핵심은 캐싱과 동기화다. TanStack Query(또는 SWR)에게 맡기면 로딩/에러/리페치/캐시 무효화를 선언적으로 해결할 수 있다.
3. 횡단관심사의 배치를 의식적으로 결정하라. 인증, 에러 핸들링, 토스트 같은 로직은 "어디에 두는가"가 코드 품질을 결정한다. 암묵적(인터셉터) vs 명시적(훅/가드)을 구분하고, React 밖에서도 접근해야 하는지를 기준으로 도구를 선택하라.
프로젝트 규모별 추천 조합
| 규모 | 서버 상태 | 클라이언트 상태 | URL 상태 | 폼 |
|---|---|---|---|---|
| 소규모 (랜딩, 블로그) | SWR | useState / Context | useSearchParams | 네이티브 form |
| 중규모 (대시보드, 백오피스) | TanStack Query | Zustand | nuqs | React Hook Form + Zod |
| 대규모 (SaaS, 복잡한 상태 머신) | TanStack Query | Zustand + XState (복잡 흐름) | nuqs | React Hook Form + Zod |
Note: 마지막 한 마디: 상태관리 라이브러리 선택은 기술적 문제인 동시에 팀 문제다. 팀원 모두가 이해할 수 있는 가장 단순한 조합이 정답이다. 화려한 아키텍처보다 “이 코드, 내일 입사하는 동료가 바로 이해할 수 있는가?”가 더 중요한 기준이다.