[TIL] React Context API와 Redux

지현·2026년 6월 22일
post-thumbnail

오늘은 Context API와 Redux를 배웠다. 둘 다 컴포넌트 간 데이터 공유 문제를 해결하지만 목적이 다르다.
Context API는 props drilling을 피하기 위한 값 전달 메커니즘이고, Redux는 앱 전체 상태를 체계적으로 관리하는 상태 관리 라이브러리다.


Props Drilling 문제

컴포넌트 트리가 깊어지면 중간 컴포넌트들이 해당 데이터를 실제로 사용하지 않더라도 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 API 핵심 구조

Context는 크게 세 단계로 나뉜다.

단계역할
createContextContext 객체 생성
Provider하위 컴포넌트에 값 공급
useContext / ConsumerProvider의 값을 소비

1단계 — createContext로 Context 생성

// 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)

"무엇을 보여줄지"와 "무엇을 바꿀 수 있는지"를 분리하면 컴포넌트 역할이 명확해진다.


2단계 — Provider로 상태 공급

// 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;

3단계 — App에서 Provider로 감싸기

// 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가 없다면 SelectColorsColorBox가 color를 공유하려면 App에 useState를 두고 props로 각각 내려줘야 한다.


Context 값 소비하기 — 두 가지 방법

방법 1: useContext Hook (권장 ✅)

// 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>
  );
};

방법 2: Consumer — render props 패턴 (구버전)

// 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>
  );
};

두 방법 비교

ConsumeruseContext
방식render props (함수를 자식으로 전달)Hook
코드중첩 depth 깊음, 장황한 줄, 깔끔
사용 가능 컴포넌트클래스 + 함수형함수형 전용
권장 여부레거시 코드 / 클래스 컴포넌트✅ 현재 권장 방식

전체 파일 구조 정리

src/
├── contexts/
│   └── Colors.jsx       ← createContext + Provider + Consumer 정의
├── components/
│   ├── ColorBox.jsx     ← useContext로 state 소비 (읽기)
│   └── SelectColors.jsx ← Consumer로 actions 소비 (쓰기)
└── App.jsx              ← Provider로 트리 감싸기

정리

  • Props Drilling 문제 → Context로 해결. 중간 컴포넌트 없이 필요한 곳에서 바로 꺼내 씀
  • createContext(기본값) → 기본값은 Provider 없을 때만 적용. 타입 힌트 용도
  • state / actions 분리 → 읽기 데이터와 변경 함수를 나눠서 역할을 명확하게
  • Provider → 최상위에서 감싸면 하위 모든 컴포넌트에서 Context 접근 가능
  • useContext vs Consumer → 함수형 컴포넌트라면 useContext, 클래스 컴포넌트라면 Consumer

Context는 "전역 상태가 필요한데 Redux는 무겁다" 싶을 때 딱 좋다. 단, 자주 바뀌는 값에 쓰면 불필요한 리렌더링이 생길 수 있으니 주의!


Redux

Redux란?

앱 전체의 상태(state)를 하나의 저장소(store) 에서 관리하는 상태 관리 라이브러리다.

Redux의 3가지 원칙

  1. 스토어는 하나 — 앱 전체의 상태를 단일 스토어에서 관리
  2. 상태는 읽기 전용 — 오직 액션(Action)을 통해서만 상태 변경 가능
  3. 변화는 순수 함수(리듀서)로만 — 리듀서는 이전 state + action을 받아 새 state를 반환

데이터 흐름 (단방향)

사용자 이벤트 → dispatch(action) → Reducer → 새 state → 화면 업데이트

Context API vs Redux

Context APIRedux
성격값 전달 메커니즘상태 관리 라이브러리
설치React 내장별도 라이브러리 필요
목적Props Drilling 해결체계적인 전역 상태 관리
상태 관리useState / useReducer로 따로 관리Reducer + Store에서 통합 관리
상태 변경 로직컴포넌트/파일에 분산Reducer 한 곳에 집중
디버깅추적 어려움Redux DevTools로 흐름 추적 가능
Time Travel불가과거 state로 되돌아가 재현 가능
적합 규모중소 규모대규모

Redux 핵심 개념

Action — 무슨 일이 일어났는가

상태를 어떻게 바꿀지 설명하는 객체. type 필드는 필수다.

// 액션 타입 상수 — 오타 방지 + 자동완성을 위해 상수로 관리
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

// 액션 생성자 — dispatch에 넘길 액션 객체를 만들어주는 함수
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });

Reducer — 새 state를 반환하는 순수 함수

현재 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,
);

스토어 설정

combineReducers — 여러 리듀서를 하나로 합치기

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;

configureStore + Provider로 앱 감싸기

// 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 컴포넌트 vs 컨테이너 컴포넌트 분리 원칙

Redux를 쓸 때는 두 가지 역할을 분리하는 게 관례다.

UI 컴포넌트컨테이너 컴포넌트
역할화면 렌더링Redux 연결
Redux 의존❌ import 안 함✅ useSelector, useDispatch 사용
재사용성높음낮음
예시Counter.jsxCounterContainer.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} />
  );
};

immer로 불변성 쉽게 관리하기

배열/객체가 중첩된 복잡한 state를 다룰 때, 불변성을 직접 지키면 코드가 길고 복잡해진다. immerproduce를 쓰면 마치 직접 수정하는 것처럼 코드를 작성해도 내부적으로 불변성을 지켜준다.

// 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;

useActions 커스텀 훅 — dispatch 연결 자동화

액션 생성자가 많아지면 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로 앱 감싸기

정리

  • Action → 상태 변경을 설명하는 객체. type 필드 필수
  • Reducer(state, action) => newState. 반드시 새 객체 반환 (불변성)
  • Store → 앱 전체에 하나. configureStore로 생성, Provider로 공급
  • combineReducers → 여러 리듀서를 하나로 합쳐 스토어에 전달
  • useSelector → 스토어에서 필요한 state만 선택
  • useDispatch → 액션을 스토어에 전달하는 dispatch 함수
  • handleActions → switch/case 없이 리듀서를 깔끔하게 작성
  • immer → 복잡한 중첩 state도 직접 수정하듯 불변성 유지
  • bindActionCreators → 액션 생성자와 dispatch를 자동으로 묶기
  • UI / 컨테이너 분리 → UI 컴포넌트는 Redux를 몰라야 재사용성이 높아짐

Redux는 처음엔 개념이 많아서 복잡해 보이지만, "액션으로만 상태를 바꾼다"는 원칙 덕분에 코드가 커져도 흐름을 추적하기 쉽다. DevTools의 Time Travel이 생각보다 꽤 강력하다.

0개의 댓글