포스트

[이제와서 시작하는 React 마스터하기 #13] 상태 관리 라이브러리 - 전역 상태 관리하기

[이제와서 시작하는 React 마스터하기 #13] 상태 관리 라이브러리 - 전역 상태 관리하기

100일 챌린지 Day 13 - 앱이 커지면 상태 관리가 복잡해져요! 전역 상태 관리 라이브러리로 깔끔하게 정리해봐요. 📦

배울 내용

이번 시간에는 전역 상태를 관리하는 라이브러리들을 배워봐요.

  • Props Drilling 문제 이해하기
  • Zustand로 간단하게 상태 관리하기
  • Redux 기본 개념 이해하기
  • 언제 어떤 도구를 사용할지 결정하기

시작하기 전에

이번 내용을 더 잘 이해하려면 아래 내용을 먼저 보고 오세요:


일상 비유로 이해하기

상태 관리를 일상으로 비유하면 이렇습니다:

🏢 사무실 메모 공유하기

Props로 전달하기 = 메모를 손으로 전달

  • 사원 → 대리 → 과장 → 차장 → 부장 (5단계!)
  • 중간 사람들은 메모 내용에 관심 없는데 계속 전달해야 해요
  • 비효율적이에요

Context API = 사내 게시판

  • 누구나 게시판에 메모를 붙일 수 있어요
  • 필요한 사람만 확인해요
  • 간단하지만, 메모가 많아지면 복잡해져요

Zustand/Redux = 중앙 문서 관리 시스템

  • 모든 문서를 한 곳에 체계적으로 보관해요
  • 누구든 필요할 때 가져다 쓸 수 있어요
  • 규칙이 있어서 정리되어 있어요

1️⃣ 문제 상황: Props Drilling

Props Drilling이 뭔가요?

Props를 여러 단계에 걸쳐 전달하는 것을 말해요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 문제 상황
function App() {
  const [user, setUser] = useState({ name: '김철수', age: 25 });

  return (
    <div>
      <Header user={user} />
    </div>
  );
}

function Header({ user }) {
  // Header는 user를 사용하지 않지만 전달만 해요
  return (
    <header>
      <Navigation user={user} />
    </header>
  );
}

function Navigation({ user }) {
  // Navigation도 user를 사용하지 않지만 전달만 해요
  return (
    <nav>
      <UserMenu user={user} />
    </nav>
  );
}

function UserMenu({ user }) {
  // 여기서야 실제로 user를 사용해요!
  return <div>안녕하세요, {user.name}님!</div>;
}

문제점:

  • App → Header → Navigation → UserMenu (3단계 전달!)
  • Header와 Navigation은 user를 사용하지 않아요
  • user가 바뀌면 중간 컴포넌트들도 다시 렌더링돼요
  • 코드 수정이 어려워요

Context API로 해결하기

Day 7에서 배운 Context API를 사용할 수 있어요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { createContext, useContext, useState } from 'react';

const UserContext = createContext();

function App() {
  const [user, setUser] = useState({ name: '김철수', age: 25 });

  return (
    <UserContext.Provider value={user}>
      <Header />
    </UserContext.Provider>
  );
}

function Header() {
  // Props를 받지 않아도 돼요!
  return (
    <header>
      <Navigation />
    </header>
  );
}

function Navigation() {
  return (
    <nav>
      <UserMenu />
    </nav>
  );
}

function UserMenu() {
  const user = useContext(UserContext); // 직접 가져와요!
  return <div>안녕하세요, {user.name}님!</div>;
}

장점: Props를 여러 단계 전달하지 않아도 돼요!

단점:

  • Context가 많아지면 Provider가 너무 많아져요
  • Context 값이 바뀌면 모든 구독자가 리렌더링돼요
  • 디버깅이 어려워요

2️⃣ Zustand - 가장 간단한 상태 관리

Zustand란?

가장 간단하고 사용하기 쉬운 상태 관리 라이브러리예요!

설치하기

1
npm install zustand

기본 사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// stores/useUserStore.js
import { create } from 'zustand';

const useUserStore = create((set) => ({
  // 상태
  user: null,

  // 액션 (상태를 변경하는 함수)
  setUser: (user) => set({ user }),

  clearUser: () => set({ user: null })
}));

export default useUserStore;

컴포넌트에서 사용하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// App.js
import useUserStore from './stores/useUserStore';

function App() {
  const setUser = useUserStore((state) => state.setUser);

  const handleLogin = () => {
    setUser({ name: '김철수', age: 25 });
  };

  return (
    <div>
      <button onClick={handleLogin}>로그인</button>
      <Header />
    </div>
  );
}

function Header() {
  return (
    <header>
      <UserMenu />
    </header>
  );
}

function UserMenu() {
  const user = useUserStore((state) => state.user);
  const clearUser = useUserStore((state) => state.clearUser);

  if (!user) {
    return <div>로그인해주세요</div>;
  }

  return (
    <div>
      <p>안녕하세요, {user.name}님!</p>
      <button onClick={clearUser}>로그아웃</button>
    </div>
  );
}

완전 간단하죠? Props를 전혀 전달하지 않았어요!

실습: 쇼핑 카트 만들기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// stores/useCartStore.js
import { create } from 'zustand';

const useCartStore = create((set, get) => ({
  items: [],

  addItem: (product) => set((state) => ({
    items: [...state.items, { ...product, quantity: 1 }]
  })),

  removeItem: (productId) => set((state) => ({
    items: state.items.filter(item => item.id !== productId)
  })),

  increaseQuantity: (productId) => set((state) => ({
    items: state.items.map(item =>
      item.id === productId
        ? { ...item, quantity: item.quantity + 1 }
        : item
    )
  })),

  decreaseQuantity: (productId) => set((state) => ({
    items: state.items.map(item =>
      item.id === productId && item.quantity > 1
        ? { ...item, quantity: item.quantity - 1 }
        : item
    )
  })),

  // 계산된 값
  getTotalPrice: () => {
    const items = get().items;
    return items.reduce((total, item) => total + (item.price * item.quantity), 0);
  },

  getTotalItems: () => {
    const items = get().items;
    return items.reduce((total, item) => total + item.quantity, 0);
  },

  clearCart: () => set({ items: [] })
}));

export default useCartStore;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// components/ProductList.js
import useCartStore from '../stores/useCartStore';

function ProductList() {
  const addItem = useCartStore((state) => state.addItem);

  const products = [
    { id: 1, name: '노트북', price: 1000000 },
    { id: 2, name: '마우스', price: 50000 },
    { id: 3, name: '키보드', price: 100000 }
  ];

  return (
    <div>
      <h2>상품 목록</h2>
      {products.map(product => (
        <div key={product.id} style=>
          <h3>{product.name}</h3>
          <p>{product.price.toLocaleString()}</p>
          <button onClick={() => addItem(product)}>
            장바구니 추가
          </button>
        </div>
      ))}
    </div>
  );
}

export default ProductList;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// components/Cart.js
import useCartStore from '../stores/useCartStore';

function Cart() {
  const items = useCartStore((state) => state.items);
  const removeItem = useCartStore((state) => state.removeItem);
  const increaseQuantity = useCartStore((state) => state.increaseQuantity);
  const decreaseQuantity = useCartStore((state) => state.decreaseQuantity);
  const getTotalPrice = useCartStore((state) => state.getTotalPrice);
  const clearCart = useCartStore((state) => state.clearCart);

  const totalPrice = getTotalPrice();

  return (
    <div style=>
      <h2>장바구니</h2>

      {items.length === 0 ? (
        <p>장바구니가 비어있습니다</p>
      ) : (
        <>
          {items.map(item => (
            <div key={item.id} style=>
              <div>
                <h4>{item.name}</h4>
                <p>{item.price.toLocaleString()}</p>
              </div>

              <div style=>
                <button onClick={() => decreaseQuantity(item.id)}>-</button>
                <span>{item.quantity}</span>
                <button onClick={() => increaseQuantity(item.id)}>+</button>
                <button onClick={() => removeItem(item.id)}>삭제</button>
              </div>
            </div>
          ))}

          <div style=>
            <h3>총 금액: {totalPrice.toLocaleString()}</h3>
            <button
              onClick={clearCart}
              style=
            >
              장바구니 비우기
            </button>
          </div>
        </>
      )}
    </div>
  );
}

export default Cart;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// components/CartBadge.js
import useCartStore from '../stores/useCartStore';

function CartBadge() {
  const getTotalItems = useCartStore((state) => state.getTotalItems);
  const totalItems = getTotalItems();

  return (
    <div style=>
      🛒 장바구니: {totalItems}</div>
  );
}

export default CartBadge;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// App.js
import ProductList from './components/ProductList';
import Cart from './components/Cart';
import CartBadge from './components/CartBadge';

function App() {
  return (
    <div style=>
      <h1>쇼핑몰</h1>
      <CartBadge />

      <div style=>
        <ProductList />
        <Cart />
      </div>
    </div>
  );
}

export default App;

포인트:

  • ProductList, Cart, CartBadge 모두 같은 store를 사용해요
  • Props를 전혀 전달하지 않았어요
  • 한 곳에서 상품을 추가하면 모든 곳에 반영돼요!

3️⃣ Redux - 예측 가능한 상태 관리

Redux란?

가장 유명한 상태 관리 라이브러리예요. 규칙이 엄격해서 코드가 예측 가능해요.

특징:

  • 하나의 큰 Store (중앙 집중식)
  • Action으로 상태 변경 요청
  • Reducer가 실제로 상태를 변경
  • 시간 여행 디버깅 가능 (상태 변화 추적)

설치하기

1
npm install @reduxjs/toolkit react-redux

기본 사용법 (Redux Toolkit 사용)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// store/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0
  },
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    }
  }
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
1
2
3
4
5
6
7
8
9
// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer
  }
});
1
2
3
4
5
6
7
8
9
// main.jsx or index.js
import { Provider } from 'react-redux';
import { store } from './store';

root.render(
  <Provider store={store}>
    <App />
  </Provider>
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// components/Counter.js
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from '../store/counterSlice';

function Counter() {
  const count = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div style=>
      <h1>카운터: {count}</h1>
      <button onClick={() => dispatch(decrement())}>-</button>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(incrementByAmount(10))}>+10</button>
    </div>
  );
}

export default Counter;

Zustand와 비교

같은 기능을 Zustand로 구현하면:

1
2
3
4
5
6
7
8
9
10
11
// stores/useCounterStore.js
import { create } from 'zustand';

const useCounterStore = create((set) => ({
  value: 0,
  increment: () => set((state) => ({ value: state.value + 1 })),
  decrement: () => set((state) => ({ value: state.value - 1 })),
  incrementByAmount: (amount) => set((state) => ({ value: state.value + amount }))
}));

export default useCounterStore;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// components/Counter.js
import useCounterStore from '../stores/useCounterStore';

function Counter() {
  const { value, increment, decrement, incrementByAmount } = useCounterStore();

  return (
    <div style=>
      <h1>카운터: {value}</h1>
      <button onClick={decrement}>-</button>
      <button onClick={increment}>+</button>
      <button onClick={() => incrementByAmount(10)}>+10</button>
    </div>
  );
}

차이점:

  • Zustand가 훨씬 간단해요!
  • Redux는 Provider, dispatch, action 필요
  • Zustand는 그냥 hook처럼 사용

4️⃣ 비교: useState vs Context vs Zustand vs Redux

언제 무엇을 사용할까요?

도구 사용 시기 장점 단점
useState 한 컴포넌트 안에서만 사용 간단함 공유 불가
Props 부모-자식 1단계 명확함 깊은 전달 어려움
Context API 2-3개 전역 상태 내장 기능, 간단 많아지면 복잡
Zustand 여러 전역 상태 관리 매우 간단, 성능 좋음 큰 팀에선 규칙 부족
Redux 큰 프로젝트, 팀 협업 예측 가능, 디버깅 좋음 보일러플레이트 많음

실제 사용 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ✅ useState: 한 컴포넌트 안에서만
function SearchBox() {
  const [query, setQuery] = useState('');
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

// ✅ Props: 부모-자식 간단히 전달
function Parent() {
  const [name, setName] = useState('김철수');
  return <Child name={name} />;
}

// ✅ Context API: 테마, 언어 설정 등 2-3개
const ThemeContext = createContext();
function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Page />
    </ThemeContext.Provider>
  );
}

// ✅ Zustand: 사용자, 장바구니, 알림 등 여러 상태
const useUserStore = create((set) => ({
  user: null,
  setUser: (user) => set({ user })
}));

// ✅ Redux: 대규모 프로젝트, 복잡한 상태 로직
// 은행 앱, 관리자 대시보드, 복잡한 SaaS 등

5️⃣ 추천: 어떤 걸 선택할까요?

초보자라면

Zustand를 추천해요!

  • 배우기 쉬워요
  • 코드가 간단해요
  • 성능이 좋아요
  • 대부분의 프로젝트에 충분해요

이미 Redux를 쓰는 프로젝트라면

Redux를 계속 사용하세요

  • 팀이 익숙한 도구를 쓰는 게 좋아요
  • Redux도 충분히 좋은 도구예요

간단한 프로젝트라면

Context API만으로도 충분해요

  • 추가 라이브러리 필요 없어요
  • 상태가 2-3개면 Context로도 OK

흔한 실수와 해결 방법

실수 1: 모든 상태를 전역으로 만들기

1
2
3
4
5
6
7
// ❌ 이건 전역일 필요 없어요
const useFormStore = create((set) => ({
  name: '',
  email: '',
  setName: (name) => set({ name }),
  setEmail: (email) => set({ email })
}));

해결:

1
2
3
4
5
6
7
8
9
10
11
12
// ✅ 폼 상태는 컴포넌트 안에서 useState 사용
function SignupForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
    </form>
  );
}

전역 상태로 만들어야 하는 것:

  • 여러 페이지에서 사용하는 사용자 정보
  • 장바구니
  • 알림
  • 테마 설정

로컬 상태로 충분한 것:

  • 폼 입력 값
  • 모달 열림/닫힘
  • 탭 선택 상태

실수 2: Store를 너무 많이 만들기

1
2
3
4
5
6
7
// ❌ Store가 너무 많아요
const useUserStore = create(...);
const useThemeStore = create(...);
const useLanguageStore = create(...);
const useNotificationStore = create(...);
const useSettingsStore = create(...);
// ... 20개의 store

해결:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ✅ 관련된 것끼리 묶기
const useAppStore = create((set) => ({
  user: null,
  theme: 'light',
  language: 'ko',
  notifications: [],

  setUser: (user) => set({ user }),
  setTheme: (theme) => set({ theme }),
  setLanguage: (language) => set({ language }),
  addNotification: (notification) => set((state) => ({
    notifications: [...state.notifications, notification]
  }))
}));

실수 3: 상태를 직접 수정하기

1
2
3
// ❌ Zustand에서 상태를 직접 수정
const items = useCartStore((state) => state.items);
items.push(newItem); // 안 돼요!

해결:

1
2
3
// ✅ 액션 함수 사용
const addItem = useCartStore((state) => state.addItem);
addItem(newItem);

정리

오늘 배운 내용

  • Props Drilling 문제를 이해했어요
  • Zustand로 간단하게 상태 관리를 할 수 있어요
  • Redux의 기본 개념을 이해했어요
  • 언제 어떤 도구를 사용할지 알아요

핵심 정리

상태 관리 선택 기준:

  1. 작은 프로젝트: Context API
  2. 중간 프로젝트: Zustand (추천!)
  3. 큰 프로젝트: Redux

Zustand 패턴:

1
2
3
4
5
6
7
8
9
10
const useStore = create((set, get) => ({
  // 상태
  value: 0,

  // 액션
  setValue: (value) => set({ value }),

  // 계산된 값
  getDoubleValue: () => get().value * 2
}));

다음 단계

  • Zustand middleware 사용하기 (persist, devtools)
  • Redux DevTools로 디버깅하기
  • 비동기 작업 처리하기

숙제

필수 과제

  1. 카운터 앱 (Zustand 사용)
    • 증가/감소 버튼
    • 리셋 버튼
    • 여러 컴포넌트에서 count 표시
  2. Todo 앱 (Zustand 사용)
    • 할 일 추가/삭제
    • 완료 토글
    • 전체/완료/미완료 필터

선택 과제

  1. 테마 전환 앱
    • Light/Dark 모드
    • 여러 페이지에서 테마 적용
    • localStorage에 저장
  2. 간단한 쇼핑몰
    • 상품 목록
    • 장바구니 추가/삭제
    • 총 금액 계산

“상태 관리는 도구가 아니라 전략이에요! 🎯”

프로젝트 크기에 맞는 도구를 선택하세요. 작은 프로젝트에 Redux는 과해요!


React 완벽 가이드 시리즈

  1. React란 무엇인가? 시작하기 전 알아야 할 모든 것
  2. useState와 useEffect - 상태와 생명주기 기초
  3. 컴포넌트 이해하기 - 레고 블록으로 만드는 웹
  4. Props - 컴포넌트끼리 대화하기
  5. useEffect - 생명주기 이해하기
  6. 조건부/리스트 렌더링 - 똑똑하게 보여주기
  7. Context API - Props 지옥 탈출하기
  8. Custom Hooks - 나만의 Hook 만들기
  9. React Router - 페이지 이동하기
  10. 폼 처리 - 사용자 입력 받기
  11. 성능 최적화 - 똑똑하게 렌더링하기
  12. 에러 처리와 로딩 화면 - 사용자 경험 챙기기
  13. 상태 관리 라이브러리 - 전역 상태 관리하기 ← 현재 글
  14. 서버 상태 관리 - React Query/TanStack Query 활용
  15. React와 TypeScript - 타입 안전한 React 개발
  16. Next.js 입문 - SSR/SSG와 풀스택 React
  17. React 테스팅 전략 - Jest, React Testing Library, E2E
  18. 고급 패턴과 베스트 프랙티스 - HOC, Render Props, Compound Components
  19. 실전 프로젝트 - Todo 앱에서 쇼핑몰까지
  20. React 배포와 DevOps - 빌드 최적화와 CI/CD

관련 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.