ReactHard#16
React에서 상태 관리 방법들을 비교해주세요. (useState, Context, Redux, Zustand 등)
#React#상태관리#Redux#Zustand
힌트
상태의 범위, 보일러플레이트, 성능, 학습 곡선을 기준으로 비교해보세요.
정답 및 해설
React에서 상태 관리 방법들을 비교해주세요. (useState, Context, Redux, Zustand 등)
React 애플리케이션의 복잡도가 높아질수록 상태를 어디서, 어떻게 관리할지가 중요한 설계 결정이 됩니다. useState부터 시작해 Context API, Redux, Zustand, Jotai까지 다양한 옵션이 존재하며, 각각 적합한 사용 시나리오가 다릅니다. 상태의 범위(로컬 vs 글로벌), 업데이트 빈도, 팀 규모, 앱 복잡도를 고려해 적절한 도구를 선택해야 합니다.
상태의 종류와 관리 범위
로컬 상태 (Local State)
└─ 단일 컴포넌트 내부에서만 사용
└─ 예: 폼 입력값, 토글 상태, UI 상태
공유 상태 (Shared State)
└─ 여러 컴포넌트가 함께 사용
└─ 예: 로그인 사용자 정보, 장바구니, 테마
서버 상태 (Server State)
└─ 원격 서버의 데이터와 동기화
└─ 예: API 데이터, 캐싱, 로딩/에러 상태
URL 상태 (URL State)
└─ URL에 반영되는 상태
└─ 예: 검색 쿼리, 페이지 번호, 필터 조건
1. useState: 로컬 상태 관리
개념과 적합한 사용 사례
컴포넌트 내부에서만 사용하는 단순한 상태에 적합합니다.
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await loginAPI({ email, password });
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
{error && <p className="error">{error}</p>}
<button disabled={isLoading}>{isLoading ? '로그인 중...' : '로그인'}</button>
</form>
);
}
Prop Drilling 문제
// useState만 사용할 때의 prop drilling 문제
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} setUser={setUser} />;
}
function Layout({ user, setUser }) {
return <Header user={user} setUser={setUser} />; // 단순 전달
}
function Header({ user, setUser }) {
return <UserMenu user={user} setUser={setUser} />; // 단순 전달
}
function UserMenu({ user, setUser }) {
return <div>{user?.name}</div>; // 실제 사용
}
// Layout, Header는 user를 사용하지 않고 단순히 아래로 전달 → prop drilling
2. Context API: 글로벌 상태 공유
개념과 기본 사용법
React 내장 기능으로 컴포넌트 트리 전체에 데이터를 공유합니다. 별도 라이브러리 설치가 필요 없습니다.
import { createContext, useContext, useState } from 'react';
// Context 생성
const AuthContext = createContext(null);
// Provider: 상태를 하위 트리에 공급
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const login = async (credentials) => {
setIsLoading(true);
const userData = await loginAPI(credentials);
setUser(userData);
setIsLoading(false);
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Custom Hook: 사용 편의성 향상
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth는 AuthProvider 내부에서 사용해야 합니다');
}
return context;
}
// 사용하는 곳에서 prop drilling 없이 바로 접근
function UserMenu() {
const { user, logout } = useAuth();
if (!user) return null;
return (
<div>
<span>{user.name}</span>
<button onClick={logout}>로그아웃</button>
</div>
);
}
function App() {
return (
<AuthProvider>
<Router>
<UserMenu /> {/* props 전달 없이 바로 사용 */}
</Router>
</AuthProvider>
);
}
Context의 리렌더링 문제
Context 값이 변경되면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다.
// 문제: theme이 변경되어도 user 관련 컴포넌트가 리렌더링됨
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('dark');
// theme이 바뀌면 user를 사용하는 컴포넌트도 리렌더링됨
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}
// 해결: Context 분리
function OptimizedProviders({ children }) {
return (
<AuthProvider> {/* user 관련 */}
<ThemeProvider> {/* theme 관련 */}
{children}
</ThemeProvider>
</AuthProvider>
);
}
3. Redux: 예측 가능한 중앙 집중 상태 관리
개념
단방향 데이터 흐름과 불변성을 기반으로 한 상태 컨테이너입니다. Action → Reducer → Store → View의 흐름을 따릅니다.
Redux Toolkit (RTK) 사용법
npm install @reduxjs/toolkit react-redux
// store/counterSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// 비동기 액션 생성
export const fetchUsers = createAsyncThunk(
'users/fetchAll',
async () => {
const response = await fetch('/api/users');
return response.json();
}
);
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0, status: 'idle' },
reducers: {
increment: (state) => { state.value += 1; }, // Immer로 불변성 자동 처리
decrement: (state) => { state.value -= 1; },
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => { state.status = 'loading'; })
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = 'succeeded';
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state) => { state.status = 'failed'; });
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// 컴포넌트에서 사용
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from './store/counterSlice';
function Counter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<p>카운트: {count}</p>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
</div>
);
}
4. Zustand: 간결하고 유연한 상태 관리
개념
보일러플레이트 없이 간단한 API로 전역 상태를 관리합니다. Redux의 복잡성 없이 유사한 기능을 제공합니다.
npm install zustand
// store/useCounterStore.js
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
const useCounterStore = create(
devtools(
persist(
(set, get) => ({
count: 0,
user: null,
history: [],
increment: () => set(
(state) => ({
count: state.count + 1,
history: [...state.history, state.count]
}),
false,
'increment' // Redux DevTools에 표시될 액션명
),
decrement: () => set(
(state) => ({ count: state.count - 1 }),
false,
'decrement'
),
reset: () => set({ count: 0, history: [] }, false, 'reset'),
setUser: (user) => set({ user }),
// 비동기 액션
fetchAndSetUser: async (id) => {
const user = await fetch(`/api/users/${id}`).then(r => r.json());
set({ user });
},
}),
{ name: 'counter-storage' } // localStorage에 저장
)
)
);
export default useCounterStore;
// 컴포넌트에서 사용 - 선택적 구독
function Counter() {
// 필요한 상태만 구독 (최적화)
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<div>
<p>{count}</p>
<button onClick={increment}>+1</button>
</div>
);
}
function UserInfo() {
// user만 구독 → count가 변경되어도 리렌더링 없음
const user = useCounterStore((state) => state.user);
return <div>{user?.name}</div>;
}
Zustand vs Redux 보일러플레이트 비교
// Zustand: 간결한 스토어 정의
const useTodoStore = create((set) => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [...state.todos, { id: Date.now(), text, done: false }]
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
})),
}));
// Redux Toolkit: 더 많은 구조 필요 (Slice + Store + Provider)
// 하지만 더 명확한 패턴과 강력한 DevTools 제공
5. Jotai / Recoil: 원자(Atom) 기반 상태 관리
Jotai 개념
Facebook의 Recoil에서 영감을 받은 원자(atom) 단위의 상태 관리 라이브러리입니다.
npm install jotai
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// 원자 정의 (단순 값)
const countAtom = atom(0);
const nameAtom = atom('');
// 파생 원자 (다른 원자를 기반으로 계산)
const doubledCountAtom = atom((get) => get(countAtom) * 2);
// 비동기 원자
const userAtom = atom(async (get) => {
const response = await fetch('/api/user');
return response.json();
});
// 쓰기 가능한 파생 원자
const uppercaseAtom = atom(
(get) => get(nameAtom).toUpperCase(),
(get, set, newValue) => set(nameAtom, newValue.toLowerCase())
);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubled = useAtomValue(doubledCountAtom);
return (
<div>
<p>카운트: {count}, 두 배: {doubled}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
function NameInput() {
// count와 무관하게 name atom만 구독
const setName = useSetAtom(nameAtom); // 읽기 없이 쓰기만 → 리렌더링 없음
return <input onChange={e => setName(e.target.value)} />;
}
6. TanStack Query: 서버 상태 관리
서버 상태(API 데이터)는 별도 라이브러리로 관리하는 것이 효율적입니다.
npm install @tanstack/react-query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserList() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5분 동안 캐시 유효
});
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUser) => fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); // 캐시 무효화
},
});
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>오류: {error.message}</div>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
라이브러리 선택 가이드
규모별 추천 조합:
소규모 개인 프로젝트: useState + Context API (+ TanStack Query for server state)
중규모 팀 프로젝트: Zustand (+ TanStack Query)
대규모 엔터프라이즈: Redux Toolkit (+ TanStack Query)
세밀한 최적화 필요: Jotai / Recoil (+ TanStack Query)
종합 비교표
| 항목 | useState | Context API | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|---|---|
| 범위 | 로컬 | 글로벌 | 글로벌 | 글로벌 | 글로벌 |
| 번들 크기 | 0KB (내장) | 0KB (내장) | ~15KB | ~3KB | ~3KB |
| 보일러플레이트 | 없음 | 적음 | 보통 | 거의 없음 | 없음 |
| DevTools | X | X | 매우 강력 | 지원 | 지원 |
| 리렌더링 최적화 | 수동 | 어려움 | 선택적 구독 | 선택적 구독 | 원자 단위 |
| 미들웨어 | X | X | 풍부 | 있음 | 있음 |
| 학습 곡선 | 낮음 | 낮음 | 높음 | 낮음 | 낮음 |
| 적합 규모 | 소 | 소~중 | 중~대 | 소~대 | 소~중 |
| 서버 상태 | X | X | 가능 (RTK Query) | X | X |
핵심 정리
- 상태 관리 도구는 "무조건 하나를 고르는 것"이 아니라 상태의 성격에 따라 조합해서 사용합니다.
- 로컬 UI 상태는
useState, 글로벌 상태는 규모에 따라 Context/Zustand/Redux, 서버 상태는 TanStack Query를 활용하는 것이 현대적인 접근법입니다. - Redux는 대규모 팀에서 코드 일관성과 DevTools의 강점이 있고, Zustand는 빠른 개발과 간결함이 장점입니다.
- Context API는 업데이트 빈도가 낮은 글로벌 상태(테마, 언어, 인증)에 적합하고, 자주 변경되는 상태에는 전문 라이브러리를 사용하는 것이 좋습니다.