Skip to Content
PatternsState Management

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

✅ 권장 사항

  1. 상태 위치 결정

    • 로컬 우선 (단일 컴포넌트)
    • Props drilling 3단계 이상 → Context
    • 여러 곳에서 사용 → 전역 상태
  2. 성능 최적화

    • Context 분리 (자주 변경되는 값 분리)
    • Selector 사용 (필요한 값만 구독)
    • memo/useMemo로 리렌더링 방지
  3. 불변성 유지

    • Immer 사용 권장
    • Spread 연산자 활용
    • 직접 mutation 금지
  4. 타입 안전성

    • TypeScript 타입 정의
    • 액션 타입 정의
    • Store 타입 검증
  5. 개발자 도구

    • Redux DevTools 통합
    • 상태 변경 로깅
    • 시간 여행 디버깅

⚠️ 피해야 할 것

  1. 과도한 전역 상태

    • 모든 상태를 전역으로
    • 서버 상태를 전역 상태로
    • URL 상태를 전역 상태로
  2. 성능 문제

    • 거대한 단일 Context
    • 불필요한 리렌더링
    • 깊은 중첩 상태
  3. 복잡성 증가

    • 과도한 추상화
    • 불필요한 미들웨어
    • 복잡한 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 예제는 곧 제공될 예정입니다.

로컬에서 실행하기

  1. 프로젝트 생성

    npx @vortex/cli init my-state-project --template next-app cd my-state-project
  2. Zustand 설치

    pnpm add zustand immer
  3. 컴포넌트 추가

    npx @vortex/cli add card button --package foundation
  4. 코드 복사 및 실행

    pnpm dev

관련 패턴

Last updated on