오늘은 Context API와 Redux를 배웠다. 둘 다 컴포넌트 간 데이터 공유 문제를 해결하지만 목적이 다르다.
Context API는 props drilling을 피하기 위한 값 전달 메커니즘이고, Redux는 앱 전체 상태를 체계적으로 관리하는 상태 관리 라이브러리다.
컴포넌트 트리가 깊어지면 중간 컴포넌트들이 해당 데이터를 실제로 사용하지 않더라도 props를 계속 전달해야 하는 Props Drilling 문제가 생긴다.
// ❌ Props Drilling
function App() {
const [color, setColor] = useState('black');
return <Parent color={color} setColor={setColor} />;
}
function Parent({ color, setColor }) {
// Parent는 color를 쓰지도 않는데 받아서 넘겨야 함
return <Child color={color} setColor={setColor} />;
}
function Child({ color, setColor }) {
return <div style={{ background: color }} onClick={() => setColor('red')} />;
}
Context를 쓰면 중간 단계 없이 필요한 컴포넌트가 직접 값을 꺼내 쓸 수 있다.
Context API는 상태 관리를 해주는 게 아니다. 상태는 여전히
useState로 따로 관리하고, Context는 그 값을 props 없이 트리 전체에 전달해주는 통로 역할이다.
Context는 크게 세 단계로 나뉜다.
| 단계 | 역할 |
|---|---|
createContext | Context 객체 생성 |
Provider | 하위 컴포넌트에 값 공급 |
useContext / Consumer | Provider의 값을 소비 |
// contexts/Colors.jsx
import { createContext, useState } from 'react';
// createContext(기본값)
// 기본값은 Provider 없이 useContext를 사용할 때만 적용됨
// 실제로는 타입 힌트 / 자동완성 용도로 쓰는 경우가 많음
const ColorContext = createContext({
state: { color: 'black', subColor: 'red' },
actions: {
setColor: () => {},
setSubColor: () => {},
},
});
state / actions 분리 패턴
state: 읽기 전용 데이터 (color, subColor)actions: 상태를 변경하는 함수 (setColor, setSubColor)"무엇을 보여줄지"와 "무엇을 바꿀 수 있는지"를 분리하면 컴포넌트 역할이 명확해진다.
// contexts/Colors.jsx (이어서)
const ColorProvider = ({ children }) => {
const [color, setColor] = useState('black');
const [subColor, setSubColor] = useState('red');
// createContext의 기본값 구조와 동일하게 맞춰야
// Consumer / useContext에서 일관성 있게 사용 가능
const value = {
state: { color, subColor },
actions: { setColor, setSubColor },
};
return (
<ColorContext.Provider value={value}>
{children}
</ColorContext.Provider>
);
};
// Consumer는 ColorContext에 내장된 컴포넌트를 꺼내 쓰는 것
const { Consumer: ColorConsumer } = ColorContext;
export { ColorProvider, ColorConsumer };
export default ColorContext;
// App.jsx
import { ColorProvider } from './contexts/Colors';
import { SelectColors } from './components/SelectColors';
import { ColorBox } from './components/ColorBox';
function App() {
return (
// ColorProvider로 감싸면 내부의 모든 컴포넌트에서 Context 값에 접근 가능
<ColorProvider>
<div>
<SelectColors />
<ColorBox />
</div>
</ColorProvider>
);
}
Provider가 없다면 SelectColors와 ColorBox가 color를 공유하려면 App에 useState를 두고 props로 각각 내려줘야 한다.
// components/ColorBox.jsx
import { useContext } from 'react';
import ColorContext from '../contexts/Colors';
export const ColorBox = () => {
// useContext 한 줄로 Context 값을 꺼내 일반 변수처럼 사용
// ColorBox는 색상을 보여주기만 하므로 state만 필요
const { state } = useContext(ColorContext);
return (
<div>
{/* 왼쪽 클릭으로 선택한 색상 */}
<div style={{ width: '64px', height: '64px', background: state.color }} />
{/* 오른쪽 클릭으로 선택한 색상 */}
<div style={{ width: '32px', height: '32px', background: state.subColor }} />
</div>
);
};
// components/SelectColors.jsx
import { ColorConsumer } from '../contexts/Colors';
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'];
export const SelectColors = () => {
return (
<div>
<h2>색상을 선택하세요. 왼쪽 클릭 혹은 오른쪽 클릭으로</h2>
{/* render props 패턴: 자식으로 함수를 전달, 함수의 인자로 Context 값이 들어옴 */}
<ColorConsumer>
{({ actions }) => (
<div style={{ display: 'flex' }}>
{colors.map((color) => (
<div
key={color}
style={{ background: color, width: '24px', height: '24px', cursor: 'pointer' }}
// 왼쪽 클릭: 큰 사각형 색상 변경
onClick={() => actions.setColor(color)}
// 오른쪽 클릭: 작은 사각형 색상 변경
onContextMenu={(e) => {
e.preventDefault(); // 브라우저 기본 우클릭 메뉴 차단
actions.setSubColor(color);
}}
/>
))}
</div>
)}
</ColorConsumer>
</div>
);
};
| Consumer | useContext | |
|---|---|---|
| 방식 | render props (함수를 자식으로 전달) | Hook |
| 코드 | 중첩 depth 깊음, 장황 | 한 줄, 깔끔 |
| 사용 가능 컴포넌트 | 클래스 + 함수형 | 함수형 전용 |
| 권장 여부 | 레거시 코드 / 클래스 컴포넌트 | ✅ 현재 권장 방식 |
src/
├── contexts/
│ └── Colors.jsx ← createContext + Provider + Consumer 정의
├── components/
│ ├── ColorBox.jsx ← useContext로 state 소비 (읽기)
│ └── SelectColors.jsx ← Consumer로 actions 소비 (쓰기)
└── App.jsx ← Provider로 트리 감싸기
Context는 "전역 상태가 필요한데 Redux는 무겁다" 싶을 때 딱 좋다. 단, 자주 바뀌는 값에 쓰면 불필요한 리렌더링이 생길 수 있으니 주의!
앱 전체의 상태(state)를 하나의 저장소(store) 에서 관리하는 상태 관리 라이브러리다.
사용자 이벤트 → dispatch(action) → Reducer → 새 state → 화면 업데이트
| Context API | Redux | |
|---|---|---|
| 성격 | 값 전달 메커니즘 | 상태 관리 라이브러리 |
| 설치 | React 내장 | 별도 라이브러리 필요 |
| 목적 | Props Drilling 해결 | 체계적인 전역 상태 관리 |
| 상태 관리 | useState / useReducer로 따로 관리 | Reducer + Store에서 통합 관리 |
| 상태 변경 로직 | 컴포넌트/파일에 분산 | Reducer 한 곳에 집중 |
| 디버깅 | 추적 어려움 | Redux DevTools로 흐름 추적 가능 |
| Time Travel | 불가 | 과거 state로 되돌아가 재현 가능 |
| 적합 규모 | 중소 규모 | 대규모 |
상태를 어떻게 바꿀지 설명하는 객체. type 필드는 필수다.
// 액션 타입 상수 — 오타 방지 + 자동완성을 위해 상수로 관리
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
// 액션 생성자 — dispatch에 넘길 액션 객체를 만들어주는 함수
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
현재 state와 action을 받아 새로운 state를 반환한다. 직접 state를 수정하면 안 되고, 반드시 새 객체를 반환해야 한다 (불변성 유지).
// 기본 switch/case 패턴
function counter(state = initialState, action) {
switch (action.type) {
case INCREASE:
return { number: state.number + 1 };
case DECREASE:
return { number: state.number - 1 };
default:
return state; // 해당 없는 액션은 state 그대로 반환
}
}
handleActions를 쓰면 switch/case 없이 더 깔끔하게 작성할 수 있다.
import { createAction, handleActions } from 'redux-actions';
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
// handleActions(핸들러맵, initialState)
const counter = handleActions(
{
[INCREASE]: (state, action) => ({ number: state.number + 1 }),
[DECREASE]: (state, action) => ({ number: state.number - 1 }),
},
initialState,
);
Redux 스토어는 리듀서를 하나만 받는다. 기능이 많아지면 리듀서도 여러 개로 나뉘는데, combineReducers로 하나로 합쳐준다.
// modules/index.jsx
import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';
// 객체 키 이름이 state의 슬라이스 이름이 됨
// state.counter, state.todos 로 접근 가능
const rootReducer = combineReducers({
counter,
todos,
});
export default rootReducer;
// main.jsx
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import rootReducer from './modules/index';
const store = configureStore({ reducer: rootReducer });
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
);
Provider로 감싸면 하위 모든 컴포넌트에서 useSelector / useDispatch로 스토어에 접근할 수 있다.
Redux DevTools
@redux-devtools/extension을 연결하고 크롬 확장 "Redux DevTools"를 설치하면,
액션이 dispatch될 때마다 어떤 액션이 발생했고 state가 어떻게 바뀌었는지 실시간으로 확인할 수 있다.
"Time Travel Debugging"으로 과거 state로 되돌아가 버그를 재현할 수도 있다.
Redux를 쓸 때는 두 가지 역할을 분리하는 게 관례다.
| UI 컴포넌트 | 컨테이너 컴포넌트 | |
|---|---|---|
| 역할 | 화면 렌더링 | Redux 연결 |
| Redux 의존 | ❌ import 안 함 | ✅ useSelector, useDispatch 사용 |
| 재사용성 | 높음 | 낮음 |
| 예시 | Counter.jsx | CounterContainer.jsx |
// components/Counter.jsx — UI 컴포넌트 (Redux 모름)
export const Counter = ({ number, onIncrease, onDecrease }) => {
return (
<div>
<h1>{number}</h1>
<button onClick={onIncrease}>1 더하기</button>
<button onClick={onDecrease}>1 빼기</button>
</div>
);
};
// containers/CounterContainer.jsx — 컨테이너 (Redux 연결 담당)
import { useSelector, useDispatch } from 'react-redux';
import { useCallback } from 'react';
import { increase, decrease } from '../modules/counter';
import { Counter } from '../components/Counter';
export const CounterContainer = () => {
// useSelector: 스토어의 state에서 필요한 값만 선택
const number = useSelector((state) => state.counter.number);
// useDispatch: 액션을 스토어에 전달하는 dispatch 함수 반환
const dispatch = useDispatch();
const onIncrease = useCallback(() => dispatch(increase()), [dispatch]);
const onDecrease = useCallback(() => dispatch(decrease()), [dispatch]);
return (
<Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease} />
);
};
배열/객체가 중첩된 복잡한 state를 다룰 때, 불변성을 직접 지키면 코드가 길고 복잡해진다. immer의 produce를 쓰면 마치 직접 수정하는 것처럼 코드를 작성해도 내부적으로 불변성을 지켜준다.
// modules/todos.jsx
import { createAction, handleActions } from 'redux-actions';
import { produce } from 'immer';
const CHANGE_INPUT = 'todos/CHANGE_INPUT';
const INSERT = 'todos/INSERT';
const TOGGLE = 'todos/TOGGLE';
const REMOVE = 'todos/REMOVE';
export const changeInput = createAction(CHANGE_INPUT, (input) => input);
export const insert = createAction(INSERT, (text) => ({ id: id++, text, done: false }));
export const toggle = createAction(TOGGLE, (id) => id);
export const remove = createAction(REMOVE, (id) => id);
let id = 3;
const initialState = {
input: '',
todos: [
{ id: 1, text: '리덕스 기초 배우기', done: false },
{ id: 2, text: '리액트와 리덕스 사용하기', done: true },
],
};
const todos = handleActions(
{
[CHANGE_INPUT]: (state, { payload: input }) =>
produce(state, (draft) => { draft.input = input; }),
[INSERT]: (state, { payload: todo }) =>
produce(state, (draft) => { draft.todos.push(todo); }),
[TOGGLE]: (state, { payload: id }) =>
produce(state, (draft) => {
const todo = draft.todos.find((t) => t.id === id);
todo.done = !todo.done;
}),
[REMOVE]: (state, { payload: id }) =>
produce(state, (draft) => {
const index = draft.todos.findIndex((t) => t.id === id);
draft.todos.splice(index, 1);
}),
},
initialState,
);
export default todos;
액션 생성자가 많아지면 useCallback을 여러 번 반복해야 한다. bindActionCreators와 커스텀 훅으로 한 번에 묶을 수 있다.
// lib/useActions.js
import { bindActionCreators } from 'redux';
import { useDispatch } from 'react-redux';
import { useMemo } from 'react';
// bindActionCreators: 액션 생성자를 dispatch와 묶어주는 redux 내장 함수
// 호출 시 자동으로 dispatch(actionCreator(...))가 실행되는 함수가 만들어짐
export default function useActions(actions, deps) {
const dispatch = useDispatch();
return useMemo(
() => {
if (Array.isArray(actions)) {
return actions.map((a) => bindActionCreators(a, dispatch));
}
return bindActionCreators(actions, dispatch);
},
deps ? [dispatch, ...deps] : deps,
);
}
// containers/TodosContainer.jsx
import { useSelector } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todos';
import useActions from '../lib/useActions';
import { Todos } from '../components/Todos';
export const TodosContainer = () => {
const { input, todos } = useSelector(({ todos }) => ({
input: todos.input,
todos: todos.todos,
}));
// useActions 덕분에 useCallback 4번 쓸 필요 없이 한 줄로 해결
const [onChangeInput, onInsert, onToggle, onRemove] = useActions(
[changeInput, insert, toggle, remove],
[],
);
return (
<Todos
input={input}
todos={todos}
onChangeInput={onChangeInput}
onInsert={onInsert}
onToggle={onToggle}
onRemove={onRemove}
/>
);
};
src/
├── modules/
│ ├── index.jsx ← combineReducers로 루트 리듀서 생성
│ ├── counter.jsx ← 카운터 액션 타입 / 액션 생성자 / 리듀서
│ └── todos.jsx ← 할 일 목록 액션 타입 / 액션 생성자 / 리듀서
├── containers/
│ ├── CounterContainer.jsx ← Redux 연결 (useSelector, useDispatch)
│ └── TodosContainer.jsx ← Redux 연결 + useActions 커스텀 훅
├── components/
│ ├── Counter.jsx ← UI 컴포넌트 (Redux 모름)
│ └── Todos.jsx ← UI 컴포넌트 (Redux 모름)
└── main.jsx ← configureStore + Provider로 앱 감싸기
type 필드 필수(state, action) => newState. 반드시 새 객체 반환 (불변성)configureStore로 생성, Provider로 공급Redux는 처음엔 개념이 많아서 복잡해 보이지만, "액션으로만 상태를 바꾼다"는 원칙 덕분에 코드가 커져도 흐름을 추적하기 쉽다. DevTools의 Time Travel이 생각보다 꽤 강력하다.