State Management
상태 관리 구현 패턴입니다.
개요
State Management 패턴은 애플리케이션의 상태를 효율적으로 관리하고, 컴포넌트 간 데이터를 공유하며, 예측 가능한 상태 업데이트를 보장하는 검증된 구현 방법입니다. Context API, Zustand, 로컬 vs 전역 상태, 상태 정규화 등 프로덕션 환경에서 필요한 모든 상태 관리 시나리오를 다룹니다.
사용 사례:
- 전역 사용자 인증 상태
- 장바구니 데이터
- UI 테마 설정
- 폼 상태 관리
- 복잡한 비즈니스 로직
사용하지 말아야 할 때:
- 단일 컴포넌트 로컬 상태
- 서버 상태 (React Query/SWR 사용)
- URL 상태 (라우터 사용)
기본 패턴
1. Context API 패턴
React Context를 사용한 전역 상태 관리 패턴입니다.
"use client";
import { createContext, useContext, useState, ReactNode } from "react";
interface User {
id: string;
name: string;
email: string;
}
interface AuthContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = (userData: User) => {
setUser(userData);
localStorage.setItem("user", JSON.stringify(userData));
};
const logout = () => {
setUser(null);
localStorage.removeItem("user");
};
return (
<AuthContext.Provider
value={{
user,
login,
logout,
isAuthenticated: !!user,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
// 사용 예시
export default function ProfilePage() {
const { user, logout } = useAuth();
return (
<div className="p-4">
<h1>프로필</h1>
<p>{user?.name}</p>
<button onClick={logout}>로그아웃</button>
</div>
);
}2. useReducer 패턴
복잡한 상태 로직을 위한 reducer 패턴입니다.
"use client";
import { useReducer } from "react";
import { Card, Button } from "@vortex/ui-foundation";
// 상태 타입
interface State {
count: number;
step: number;
history: number[];
}
// 액션 타입
type Action =
| { type: "INCREMENT" }
| { type: "DECREMENT" }
| { type: "SET_STEP"; payload: number }
| { type: "RESET" }
| { type: "UNDO" };
// 초기 상태
const initialState: State = {
count: 0,
step: 1,
history: [0],
};
// Reducer 함수
function counterReducer(state: State, action: Action): State {
switch (action.type) {
case "INCREMENT":
const newCountUp = state.count + state.step;
return {
...state,
count: newCountUp,
history: [...state.history, newCountUp],
};
case "DECREMENT":
const newCountDown = state.count - state.step;
return {
...state,
count: newCountDown,
history: [...state.history, newCountDown],
};
case "SET_STEP":
return {
...state,
step: action.payload,
};
case "RESET":
return initialState;
case "UNDO":
if (state.history.length <= 1) return state;
const newHistory = state.history.slice(0, -1);
return {
...state,
count: newHistory[newHistory.length - 1],
history: newHistory,
};
default:
return state;
}
}
export default function CounterWithReducer() {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<Card className="p-6 max-w-md">
<h2 className="text-2xl font-bold mb-4">카운터: {state.count}</h2>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
증가/감소 단위: {state.step}
</label>
<input
type="range"
min="1"
max="10"
value={state.step}
onChange={(e) =>
dispatch({ type: "SET_STEP", payload: Number(e.target.value) })
}
className="w-full"
/>
</div>
<div className="flex gap-2 mb-4">
<Button onClick={() => dispatch({ type: "DECREMENT" })}>
- {state.step}
</Button>
<Button onClick={() => dispatch({ type: "INCREMENT" })}>
+ {state.step}
</Button>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => dispatch({ type: "UNDO" })}>
실행 취소
</Button>
<Button variant="outline" onClick={() => dispatch({ type: "RESET" })}>
리셋
</Button>
</div>
<div className="mt-4 text-sm text-gray-600">
히스토리: {state.history.join(" → ")}
</div>
</Card>
);
}고급 패턴
3. Zustand 통합
Zustand를 사용한 간단한 전역 상태 관리 패턴입니다.
"use client";
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { Card, Button } from "@vortex/ui-foundation";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: number;
}
// Zustand Store 생성
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existingItem = state.items.find((i) => i.id === item.id);
if (existingItem) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return {
items: [...state.items, { ...item, quantity: 1 }],
};
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, quantity } : item
),
})),
clearCart: () => set({ items: [] }),
get total() {
return get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
}),
{
name: "cart-storage", // localStorage 키
storage: createJSONStorage(() => localStorage),
}
)
);
// 사용 예시
export default function ShoppingCart() {
const { items, addItem, removeItem, updateQuantity, clearCart, total } =
useCartStore();
const handleAddProduct = () => {
addItem({
id: Date.now().toString(),
name: "상품 " + (items.length + 1),
price: Math.floor(Math.random() * 100) + 10,
});
};
return (
<Card className="p-6 max-w-2xl">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">장바구니</h2>
<Button variant="outline" onClick={clearCart}>
전체 삭제
</Button>
</div>
<div className="space-y-4 mb-4">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border-b pb-4"
>
<div>
<h3 className="font-medium">{item.name}</h3>
<p className="text-sm text-gray-600">
₩{item.price.toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateQuantity(item.id, Number(e.target.value))
}
className="w-16 px-2 py-1 border rounded"
/>
<Button variant="destructive" onClick={() => removeItem(item.id)}>
삭제
</Button>
</div>
</div>
))}
{items.length === 0 && (
<p className="text-center text-gray-500 py-8">
장바구니가 비어있습니다
</p>
)}
</div>
<div className="border-t pt-4">
<div className="flex justify-between items-center mb-4">
<span className="text-lg font-bold">총액:</span>
<span className="text-2xl font-bold">₩{total.toLocaleString()}</span>
</div>
<div className="flex gap-2">
<Button onClick={handleAddProduct} className="flex-1">
상품 추가
</Button>
<Button variant="primary" className="flex-1">
결제하기
</Button>
</div>
</div>
</Card>
);
}4. 상태 정규화
복잡한 중첩 데이터를 flat하게 관리하는 패턴입니다.
"use client";
import { create } from "zustand";
// 정규화된 상태 구조
interface NormalizedState {
users: {
byId: Record<string, User>;
allIds: string[];
};
posts: {
byId: Record<string, Post>;
allIds: string[];
};
comments: {
byId: Record<string, Comment>;
allIds: string[];
};
}
interface User {
id: string;
name: string;
postIds: string[];
}
interface Post {
id: string;
title: string;
authorId: string;
commentIds: string[];
}
interface Comment {
id: string;
text: string;
authorId: string;
postId: string;
}
interface NormalizedStore extends NormalizedState {
addUser: (user: User) => void;
addPost: (post: Post) => void;
addComment: (comment: Comment) => void;
getUserPosts: (userId: string) => Post[];
getPostComments: (postId: string) => Comment[];
}
export const useNormalizedStore = create<NormalizedStore>((set, get) => ({
users: { byId: {}, allIds: [] },
posts: { byId: {}, allIds: [] },
comments: { byId: {}, allIds: [] },
addUser: (user) =>
set((state) => ({
users: {
byId: { ...state.users.byId, [user.id]: user },
allIds: [...state.users.allIds, user.id],
},
})),
addPost: (post) =>
set((state) => ({
posts: {
byId: { ...state.posts.byId, [post.id]: post },
allIds: [...state.posts.allIds, post.id],
},
})),
addComment: (comment) =>
set((state) => ({
comments: {
byId: { ...state.comments.byId, [comment.id]: comment },
allIds: [...state.comments.allIds, comment.id],
},
})),
getUserPosts: (userId) => {
const state = get();
const user = state.users.byId[userId];
if (!user) return [];
return user.postIds
.map((postId) => state.posts.byId[postId])
.filter(Boolean);
},
getPostComments: (postId) => {
const state = get();
const post = state.posts.byId[postId];
if (!post) return [];
return post.commentIds
.map((commentId) => state.comments.byId[commentId])
.filter(Boolean);
},
}));
// 사용 예시
export default function NormalizedExample() {
const { getUserPosts, getPostComments } = useNormalizedStore();
const userPosts = getUserPosts("user-1");
const postComments = getPostComments("post-1");
return (
<div className="p-4">
<h2 className="text-xl font-bold mb-4">사용자 게시물</h2>
{userPosts.map((post) => (
<div key={post.id} className="mb-4 p-4 border rounded">
<h3 className="font-bold">{post.title}</h3>
<div className="mt-2">
<h4 className="text-sm font-semibold">댓글:</h4>
{getPostComments(post.id).map((comment) => (
<p key={comment.id} className="text-sm text-gray-600">
{comment.text}
</p>
))}
</div>
</div>
))}
</div>
);
}5. Immer 통합
불변성 관리를 쉽게 하는 Immer 패턴입니다.
"use client";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
updateTodoText: (id: string, text: string) => void;
deleteTodo: (id: string) => void;
}
// Immer로 불변성 자동 관리
export const useTodoStore = create<TodoStore>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({
id: Date.now().toString(),
text,
completed: false,
});
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}),
updateTodoText: (id, text) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.text = text;
}
}),
deleteTodo: (id) =>
set((state) => {
state.todos = state.todos.filter((t) => t.id !== id);
}),
}))
);
// 사용 예시
export default function TodoList() {
const { todos, addTodo, toggleTodo, deleteTodo } = useTodoStore();
const [input, setInput] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim()) {
addTodo(input);
setInput("");
}
};
return (
<div className="p-4 max-w-md">
<form onSubmit={handleSubmit} className="mb-4">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full px-4 py-2 border rounded"
placeholder="할 일 추가..."
/>
</form>
<div className="space-y-2">
{todos.map((todo) => (
<div
key={todo.id}
className="flex items-center gap-2 p-2 border rounded"
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span className={todo.completed ? "line-through" : ""}>
{todo.text}
</span>
<button
onClick={() => deleteTodo(todo.id)}
className="ml-auto text-red-600"
>
삭제
</button>
</div>
))}
</div>
</div>
);
}Best Practices
✅ 권장 사항
-
상태 위치 결정
- 로컬 우선 (단일 컴포넌트)
- Props drilling 3단계 이상 → Context
- 여러 곳에서 사용 → 전역 상태
-
성능 최적화
- Context 분리 (자주 변경되는 값 분리)
- Selector 사용 (필요한 값만 구독)
- memo/useMemo로 리렌더링 방지
-
불변성 유지
- Immer 사용 권장
- Spread 연산자 활용
- 직접 mutation 금지
-
타입 안전성
- TypeScript 타입 정의
- 액션 타입 정의
- Store 타입 검증
-
개발자 도구
- Redux DevTools 통합
- 상태 변경 로깅
- 시간 여행 디버깅
⚠️ 피해야 할 것
-
과도한 전역 상태
- 모든 상태를 전역으로
- 서버 상태를 전역 상태로
- URL 상태를 전역 상태로
-
성능 문제
- 거대한 단일 Context
- 불필요한 리렌더링
- 깊은 중첩 상태
-
복잡성 증가
- 과도한 추상화
- 불필요한 미들웨어
- 복잡한 selector
성능 최적화
Context 분리
// ❌ 나쁜 예: 하나의 거대한 Context
<AppContext.Provider value={{ user, theme, cart, notifications }}>
// ✅ 좋은 예: 변경 빈도별 분리
<AuthContext.Provider value={{ user }}>
<ThemeContext.Provider value={{ theme }}>
<CartContext.Provider value={{ cart }}>Selector 사용
// Zustand selector로 필요한 값만 구독
const userName = useStore((state) => state.user.name);
const userEmail = useStore((state) => state.user.email);
// 여러 값 선택 시
const { name, email } = useStore((state) => ({
name: state.user.name,
email: state.user.email,
}));Foundation 예제
범용 테마 상태 관리
Foundation 컴포넌트로 구현한 테마 상태 관리입니다.
import { create } from "zustand";
const useThemeStore = create((set) => ({
theme: "light",
toggleTheme: () =>
set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
}));
export default function ThemeToggle() {
const { theme, toggleTheme } = useThemeStore();
return <button onClick={toggleTheme}>{theme} 모드</button>;
}iCignal 예제
Analytics 필터 상태
iCignal Blue 브랜드를 적용한 필터 상태 관리입니다.
import "@vortex/ui-icignal/theme";
import { create } from "zustand";
const useFilterStore = create((set) => ({
dateRange: "week",
metric: "visitors",
setDateRange: (range) => set({ dateRange: range }),
setMetric: (metric) => set({ metric }),
}));
export default function AnalyticsFilters() {
const { dateRange, setDateRange } = useFilterStore();
return (
<select value={dateRange} onChange={(e) => setDateRange(e.target.value)}>
<option value="week">주간</option>
<option value="month">월간</option>
</select>
);
}Cals 예제
예약 필터 상태
Cals Pink 브랜드를 적용한 예약 필터 상태 관리입니다.
import "@vortex/ui-cals/theme";
import { create } from "zustand";
const useBookingStore = create((set) => ({
status: "all",
date: new Date(),
setStatus: (status) => set({ status }),
setDate: (date) => set({ date }),
}));
export default function BookingFilters() {
const { status, setStatus } = useBookingStore();
return (
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="all">전체</option>
<option value="confirmed">확정</option>
</select>
);
}CodeSandbox
CodeSandbox 예제는 곧 제공될 예정입니다.
로컬에서 실행하기
-
프로젝트 생성
npx @vortex/cli init my-state-project --template next-app cd my-state-project -
Zustand 설치
pnpm add zustand immer -
컴포넌트 추가
npx @vortex/cli add card button --package foundation -
코드 복사 및 실행
pnpm dev
관련 패턴
- Data Fetching - 서버 상태 관리
- Authentication - 인증 상태 관리
- Performance Optimization - 상태 최적화
Last updated on