Redux 상태 관리와 React 통합 전략

1. React와 Redux 결합하기

1.1 고차 컴포넌트를 활용한 공통 로직 추출

Redux 사용 시 반복되는 패턴을 고차 컴포넌트(HOC)로 추상화하여 코드 중복을 줄일 수 있습니다. 이는 connect 함수가 대표적인 예시입니다.

1.2 react-redux 라이브러리 활용

Redux는 React, Angular, Vue, jQuery, 순수 자바스크립트 등 다양한 환경에서 사용할 수 있습니다. 하지만 React와의 조합이 특히 강력한 이유는 두 라이브러리 모두 상태(state)를 기반으로 UI를 표현하기 때문입니다. React는 컴포넌트의 state를 통해 화면을 그리고, Redux는 전역 상태를 관리하며 변경 사항을 구독자에게 알려줍니다.

앞서 connect와 Provider 같은 헬퍼를 직접 구현했지만, 실제 프로젝트에서는 react-redux 패키지를 사용하는 것이 더 안정적이고 효율적입니다.

1.3 react-redux 적용 절차

설치:

npm install react-redux
# 또는
yarn add react-redux

Provider로 앱 감싸기:

import { Provider } from 'react-redux';
import store from './store';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

connect를 통한 데이터 매핑:

import { connect } from 'react-redux';
import { addAction, subtractAction } from '../store/actions';

const mapState = (state) => ({
  count: state.counter
});

const mapDispatch = (dispatch) => ({
  increment: (val) => dispatch(addAction(val)),
  decrement: (val) => dispatch(subtractAction(val))
});

export default connect(mapState, mapDispatch)(CounterComponent);

완성된 컴포넌트 예시:

import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { addAction, subtractAction } from '../../store/actions';

class Profile extends PureComponent {
  handleChange(value, isIncrease) {
    isIncrease ? this.props.increment(value) : this.props.decrement(value);
  }

  render() {
    const { count } = this.props;
    return (
      <div>
        <h2>현재 카운트: {count}</h2>
        <button onClick={() => this.handleChange(3, true)}>+3</button>
        <button onClick={() => this.handleChange(10, true)}>+10</button>
        <button onClick={() => this.handleChange(3, false)}>-3</button>
        <button onClick={() => this.handleChange(10, false)}>-10</button>
      </div>
    );
  }
}

const mapStateToProps = (state) => ({
  count: state.counter
});

const mapDispatchToProps = (dispatch) => ({
  increment: (num) => dispatch(addAction(num)),
  decrement: (num) => dispatch(subtractAction(num))
});

export default connect(mapStateToProps, mapDispatchToProps)(Profile);

2. Redux 비동기 작업 처리

2.1 컴포넌트 내부에서의 비동기

앞선 예제에서 counter 데이터는 로컬에서 정의되었기 때문에 동기 액션으로 즉시 상태를 갱신할 수 있었습니다. 하지만 실제 애플리케이션에서는 서버로부터 데이터를 받아와 Redux 상태에 저장해야 하는 경우가 많습니다. 전통적인 방법은 클래스형 컴포넌트의 componentDidMount 생명주기에서 네트워크 요청을 보내고, 응답을 받아 dispatch하는 것입니다.

Category 컴포넌트에서 데이터 요청:

import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { setBanners, setRecommends } from '../../store/actions';
import axios from 'axios';

class Category extends PureComponent {
  componentDidMount() {
    axios.get('https://api.example.com/data').then(res => {
      this.props.updateBanners(res.data.banners);
      this.props.updateRecommends(res.data.recommends);
    });
  }

  render() {
    return <h2>카테고리 페이지</h2>;
  }
}

const mapDispatch = (dispatch) => ({
  updateBanners: (data) => dispatch(setBanners(data)),
  updateRecommends: (data) => dispatch(setRecommends(data))
});

export default connect(null, mapDispatch)(Category);

About 컴포넌트에서 데이터 표시:

import React, { PureComponent } from 'react';
import { connect } from 'react-redux';

class About extends PureComponent {
  render() {
    const { count, banners, recommends } = this.props;
    return (
      <div>
        <h2>카운트: {count}</h2>
        <button onClick={() => this.props.increment(5)}>+5</button>
        <button onClick={() => this.props.decrement(5)}>-5</button>
        <h2>배너 데이터</h2>
        <ul>
          {banners.map(item => (
            <li key={item.id}>{item.title}</li>
          ))}
        </ul>
        <h2>추천 데이터</h2>
        <ul>
          {recommends.map(item => (
            <li key={item.id}>{item.title}</li>
          ))}
        </ul>
      </div>
    );
  }
}

const mapState = (state) => ({
  count: state.counter,
  banners: state.banners,
  recommends: state.recommends
});

export default connect(mapState, null)(About);

2.2 Redux 미들웨어를 통한 비동기

위 접근 방식의 단점은 네트워크 로직이 컴포넌트 생명주기에 강하게 결합된다는 점입니다. 서버 데이터도 상태 관리의 일부이므로 Redux에서 직접 처리하는 것이 더 나은 구조입니다. 이를 위해 Redux 미들웨어(Middleware)가 필요합니다. Express나 Koa를 사용해본 개발자라면 미들웨어 개념에 익숙할 것입니다. 이러한 프레임워크에서 미들웨어는 요청과 응답 사이에 로깅, 압축, 쿠키 파싱 등의 기능을 추가합니다.

2.3 Redux 미들웨어 이해

Redux 미들웨어는 dispatch된 action이 reducer에 도달하기 전에 추가 로직을 실행할 수 있게 해줍니다. 주요 용도는 로깅, 비동기 API 호출, 디버깅 기능 확장 등입니다. 비동기 네트워크 요청을 위해 공식적으로 권장되는 미들웨어는 redux-thunk입니다.

redux-thunk의 핵심 아이디어는 다음과 같습니다:

  • 기본적으로 dispatch는 JavaScript 객체(action)를 받습니다.
  • redux-thunk는 dispatch가 함수를 받을 수 있도록 확장합니다.
  • 이 함수는 dispatch와 getState를 인자로 받아 호출됩니다.
  • dispatch: 나중에 다른 action을 다시 dispatch할 때 사용됩니다.
  • getState: 현재 상태에 의존해야 하는 작업을 위해 이전 상태를 조회할 수 있습니다.

2.4 redux-thunk 적용 방법

설치:

npm install redux-thunk

스토어 생성 시 미들웨어 적용:

import { createStore, applyMiddleware } from 'redux';
import { thunk } from 'redux-thunk';
import rootReducer from './reducers';

const store = createStore(rootReducer, applyMiddleware(thunk));

export default store;

함수를 반환하는 액션 생성자:

import axios from 'axios';
import { setBanners, setRecommends } from './actions';

export const fetchHomeData = () => {
  return (dispatch, getState) => {
    axios.get('https://api.example.com/home').then(res => {
      const { banners, recommends } = res.data;
      dispatch(setBanners(banners));
      dispatch(setRecommends(recommends));
    });
  };
};

3. Redux DevTools 설정

Redux DevTools는 상태 변경을 추적하고 디버깅하는 강력한 도구입니다. 각 액션이 상태를 어떻게 변경했는지, 변경 전후의 상태를 시각적으로 확인할 수 있습니다.

설치 단계:

  1. 크롬 확장 프로그램에서 "Redux DevTools" 설치
  2. Redux에 devtools 미들웨어 통합
import { createStore, applyMiddleware, compose } from 'redux';
import { thunk } from 'redux-thunk';
import rootReducer from './reducers';

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ trace: true }) || compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));

export default store;

4. Reducer 모듈화와 combineReducers

4.1 Reducer 분할의 필요성

단일 reducer에 모든 상태를 관리하면 프로젝트가 커질수록 코드가 비대해지고 유지보수가 어려워집니다. 예를 들어 counter, home, cart, category 등 각 도메인별로 reducer를 분리하는 것이 좋습니다.

4.2 Counter 리듀서 분할 예시

constants.js:

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';

actions.js:

import * as types from './constants';

export const incrementAction = (num) => ({
  type: types.INCREMENT,
  payload: num
});

export const decrementAction = (num) => ({
  type: types.DECREMENT,
  payload: num
});

reducer.js:

import * as types from './constants';

const initialState = { count: 0 };

export default function counterReducer(state = initialState, action) {
  switch (action.type) {
    case types.INCREMENT:
      return { ...state, count: state.count + action.payload };
    case types.DECREMENT:
      return { ...state, count: state.count - action.payload };
    default:
      return state;
  }
}

index.js (통합 내보내기):

export { default } from './reducer';
export * from './actions';

4.3 combineReducers로 리듀서 합치기

import { combineReducers } from 'redux';
import counterReducer from './counter';
import homeReducer from './home';
import userReducer from './user';

const rootReducer = combineReducers({
  counter: counterReducer,
  home: homeReducer,
  user: userReducer
});

export default rootReducer;

combineReducers는 내부적으로 각 리듀서를 호출하여 새로운 상태 객체를 만듭니다. 변경 사항이 없으면 이전 상태를 그대로 반환하여 불필요한 리렌더링을 방지합니다. 이 함수의 내부 동작은 Redux 소스 코드에서 확인할 수 있습니다.

4.4 최종 스토어 구성

import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import { thunk } from 'redux-thunk';
import counterReducer from './counter';
import homeReducer from './home';
import userReducer from './user';

const rootReducer = combineReducers({
  counter: counterReducer,
  home: homeReducer,
  user: userReducer
});

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ trace: true }) || compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));

export default store;

태그: redux react-redux redux-thunk redux-devtools combineReducers

6월 8일 01:44에 게시됨