Redux Middleware, Redux-Thunk 살펴보기

39175 단어 reduxredux

Redux Middleware, Redux-Thunk 살펴보기

1. Middleware란?

미들웨어는 리덕스를 사용자 정의 기능으로 확장하여 사용할 수 있는 도구이다. 액션을 디스패치했을 때 이를 리듀서에 전달하기 전에 미들웨어에서 먼저 처리하고 리듀서로 넘겨주는 역할을 하고, 비동기 API 호출을 포함하여 다양한 용도로 사용할 수 있다.(API 호출, Redux Action에 따른 Log 확인 등)

액션 ⇒ 미들웨어 ⇒ 리듀서 ⇒ 스토어 형태로 전달되는 것이다.


1-1) 미들웨어의 구조

다른 개발자가 만들어놓은 미들웨어가 많아서 실제로 사용할 때는 미들웨어를 만들어서 사용한다기보단 만들어진 미들웨어를 가져다가 사용한다. 그런데, 미들웨어가 어떻게 생성되는지를 알면 좀 더 이해하기 좋을 것 같다. 미들웨어는 어떻게 생겼을까?

const middleware = store => next => action => {
	/* ... */
}

1) store: 리덕스 스토어 인스턴스

2) next: 함수인데, next(action) 형태로 액션을 호출하면 다음 처리해야 할 미들웨어에게 액션을 넘겨주고 만약 없다면 리듀서에게 액션을 넘겨준다.

3) action: 디스패치된 액션

이 구조를 처음 봤을 때 이해하기가 조금 어려울 순 있지만 결국 미들웨어는 action 파라미터를 가진 함수를 반환하는 next 파라미터를 가진 함수를 반환하는 store 파라미터를 가진 함수이다.


2. 미들웨어로 비동기 요청 처리하기 → Thunk

Thunk는 특정 작업을 나중에 할 수 있도록 미루기 위해 함수 형태로 감싼 것을 의미한다. 일반적으로 함수를 호출하면 호출한 순간 바로 실행되는데, 함수 형태로 한번 감싸게 되면 호출하더라도 함수를 반환하게 돼서 바로 작업하지 않고 연산을 미룰 수 있다.

function add(a, b) {
	return a + b;
}
add(1, 2); // 호출하는 순간 바로 연산된다.

function thunkAdd(a, b) {
	const thunk = () => add(a, b);
	return thunk;
}
const func = thunkAdd(1, 2); // 함수를 호출하면 thunk 함수를 반환하게 되므로 연산은 이루어지지 않는다.

func(); // 이 때 반환된 thunk를 호출하므로 연산이 이루어진다.

리덕스를 사용하는 프로젝트에서 비동기 작업을 처리할 때 가장 기본적으로 사용하는 미들웨어로 redux-thunk가 있다. 해당 미들웨어를 사용하면 thunk 함수를 만들어서 디스패치할 수 있게 되고, 미들웨어가 그 함수를 전달받아 dispatch, getState 등으로 상호작용할 수 있다.


3. redux-thunk 사용해보기


3-1) Sample 컴포넌트 만들기

  • /src/components/Sample.js
const Sample = ({ loadingPost, post }) => {
  return (
    <section>
      <h1>포스트</h1>
      {loadingPost && '로딩 중...'}
      {!loadingPost && post && (
        <div>
          <h3>{post.title}</h3>
          <h3>{post.body}</h3>
        </div>
      )}
    </section>
  );
};

export default Sample;

이 컴포넌트는 loading 상태(boolean)와 post({ title: string, body: string })을 Container로부터 Props로 전달받는다. 필요한 post 데이터는 JSONPlaceholder에서 GET /posts API를 사용한다.


3-2) api 코드 작성하기

  • /src/lib/api.js
import axios from 'axios';

const API_END_POINT = 'https://jsonplaceholder.typicode.com';

export const getPost = (id) => axios.get(`${API_END_POINT}/posts/${id}`);

비동기 요청은 axios를 사용하고, 요청 주소는 API_END_POINT로 따로 빼서 작성했다. 이렇게 하면 나중에 API 요청 주소가 변경되어도 해당 변수만 수정하면 되기 때문에 유지 보수 측면에서 더 좋다고 생각한다.

Data의 형태는 다음과 같다.

// 요청 id: 1
{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}

3-3) sample Reducer 만들기

  • src/modules/sample.js
import * as api from 'src/lib/api';
import { handleActions } from 'redux-actions';

const GET_POST = 'sample/GET_POST'; // 시작
const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS'; // 성공
const GET_POST_FAILURE = 'sample/GET_POST_FAILURE'; // 실패

export const getPost = (id) => async (dispatch) => {
  dispatch({ type: GET_POST });
  try {
    const response = await api.getPost(id);

    dispatch({
      type: GET_POST_SUCCESS,
      payload: response.data,
    });
  } catch (e) {
    dispatch({
      type: GET_POST_FAILURE,
      payload: e,
    });
  }
};

const initialState = {
  loading: {
    GET_POST: false,
  },
  post: null,
};

const sample = handleActions(
  {
    [GET_POST]: (state, action) => ({
      ...state,
      loading: {
        ...state.loading,
        GET_POST: true,
      },
    }),
    [GET_POST_SUCCESS]: (state, action) => ({
      ...state,
      loading: {
        ...state.loading,
        GET_POST: false,
      },
      post: action.payload,
    }),
    [GET_POST_FAILURE]: (state, action) => ({
      ...state,
      loading: {
        ...state.loading,
        GET_POST: false,
      },
    }),
  },
  initialState,
);

export default sample;
  • GET_POST: 액션 타입, 최초 호출 | 호출 성공 | 호출 실패 3가지 분기에 따라 dispatch할 예정이므로 3가지 상수를 작성해 줬다.
  • getPost: dispatch를 반환하는 thunk 함수. 해당 함수 내에서 try, catch 문으로 비동기 요청을 보내고, 성공했을 때 | 실패했을 때 dispatch에서 다른 액션을 호출한다.
  • sample: 리듀서. 각각의 액션 타입에 따라 상태를 관리한다.

3-4) rootReducer 생성하기

  • /src/modules/index.js
import { combineReducers } from 'redux';
import sample from './sample';

const rootReducer = combineReducers({
  sample,
});

export default rootReducer;

지금은 하나의 리듀서밖에 없지만, 다른 모듈로 리듀서가 작성될 수 있으므로 미리 rootReducer를 생성해 놓으면 나중에 확장하기 용이할 것이다.


3-5) store 만들기

  • /src/index.js
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { applyMiddleware, createStore } from 'redux';
import App from './App';
import rootReducer from './modules';
import ReduxThunk from 'redux-thunk';

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

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root'),
);

여기서 applyMiddleware, ReduxThunk 모듈을 import해서 적용해 주고 있다.

  • applyMiddleware: 미들웨어 API를 따르는 함수들을 파라미터로 넘겨줄 수 있고, 각각의 미들웨어는 store, dispatch, getState 함수를 명명된 인수로 받아서 함수를 반환한다.
  • ReduxThunk: thunk 함수를 만들어서 디스패치할 수 있게 해준다.

3-6) SampleContainer 컴포넌트 만들기

  • /src/containers/SampleContainer.js
import { useEffect } from 'react';
import { connect } from 'react-redux';
import Sample from 'src/components/Sample';
import { getPost } from 'src/modules/sample';

const SampleContainer = ({
  loadingPost,
  post,
  getPost,
}) => {
  useEffect(() => {
    getPost(1);
  }, [getPost]);

  return (
    <Sample
      post={post}
      loadingPost={loadingPost}
    />
  );
};

export default connect(
  ({ sample }) => ({
    post: sample.post,
    loadingPost: sample.loading.GET_POST,
  }),
  {
    getPost,
  },
)(SampleContainer);

해당 컴포넌트가 렌더링 되면 useEffect에 의해 getPost 요청을 보내고, getPost는 정의한 dispatch대로 비동기 요청을 수행한다. 여기까지 작성하고 App.js에 SampleContainer 컴포넌트를 렌더링 시키면 다음과 같이 정상적으로 동작하는 것을 확인할 수 있다.


3-7) Thunk 생성 함수로 리팩토링하기

여기까지만 해도 사실 정상적으로 동작하긴 한다. 그러나 새로운 API 요청이 생겨서 추가해야 할 때 getPost처럼 똑같이 액션 타입 3개, dispatch, try | catch 문을 작성해 줘야 한다. API가 한개 추가되면 그냥 작성해 줘도 문제는 없겠지만 만약 생성해야 하는 게 10개, 100개가 된다면? 똑같은 코드를 치는 것이라서 매우 번거로운 작업이 될 것이다. 이를 따로 util 함수로 빼서 사용해 보자.


  • /src/lib/createRequestThunk.js
const createRequestThunk = (type, request) => {
  const SUCCESS = `${type}_SUCCESS`;
  const FAILURE = `${type}_FAILURE`;

  return (params) => async (dispatch) => {
    dispatch({ type });
    try {
      const response = await request(params);
      dispatch({
        type: SUCCESS,
        payload: response.data,
      });
    } catch (e) {
      dispatch({
        type: FAILURE,
        payload: e,
      });
    }
  };
};

export default createRequestThunk;

위에서 작성한 getPost와 유사하다. 액션 타입과 API 요청을 파라미터로 넘겨주면 아까 정의한 getPost처럼 thunk 함수를 반환하고 있다. 이를 getPost에 적용하면 다음과 같다.

  • /src/modules/sample.js
import * as api from 'src/lib/api';
import { handleActions } from 'redux-actions';
import createRequestThunk from 'src/lib/createRequestThunk';

/* ... */

export const getPost = createRequestThunk(GET_POST, api.getPost);

엄청나게 간단해졌다. 다음에 똑같은 로직으로 API를 추가하더라도 위처럼 함수를 호출해 주기만 하면 된다. 홈페이지를 확인해 보면 마찬가지로 잘 동작한다.


※ Middleware에서 dispatch 파라미터를 잘못 넘겼을 때 발생한 에러

export default function createRequestThunk(type, request) {
  const SUCCESS = `${type}_SUCCESS`;
  const FAILURE = `${type}_FAILURE`;

  return (params) => async (dispatch) => {
    dispatch(type); // → 이 부분이 잘못됨..
		/* Thunk 비동기 로직 */
  };
}

Thunk함수를 매번 사용할 때마다 길게 작성하기보다 하나의 함수로 작성하고 import하면 사용하는 곳에서의 중복 코드를 많이 줄일 수 있다. 그래서 작성을 완료한 후 import하여 사용해 봤는데 아래와 같은 에러가 떴다..


항상 그런 것은 아니지만 이번 에러는 글의 내용에서 살짝 유추가 되어 꽤 금방 해결했다. 에러의 내용은 호출할 action은 객체 형태여야 하는데 문자열 형태로 들어왔다고 한다. 위에 작성한 코드(dispatch(type))에서 보이듯이 type은 sample/GET_REQUEST 같은 문자열 형태로 집어넣으려고 헀는데 이를 그대로 호출하니 에러가 발생했다. 이를 그대로 넣어주면 안 되고, **{ type } 처럼 객체 형태로 호출**해야 한다. 문자열뿐만 아니라 다른 형태에서도 발생할 수 있는 에러라고 생각되지만 다행히 에러 내용이 구체적이어서 발견할 수 있을 것 같다.

4. 정리

redux를 처음 학습할 때 redux/toolkit 위주로 봤었고, 이후 사용할 때 configureStore, createSlice, useSelector, useDispatch 등으로 엄청 편하게 사용했었다. 그때는 어려운 게 없다고 생각했었는데 지금 생각해 보면 connect 함수도 잘 몰랐고, 오늘 작성한 미들웨어, thunk 부분에 대해서도 완전히 새로운 것을 배우는 느낌이었다. 그래도 기본이 되는 부분을 한 번 짚고 넘어가는 것 같아 다행이고, 조금씩 응용하면서 실제로 사용할 때 아직 모자란 부분이 많겠지만 조금이나마 이해하고 사용할 수 있을 것 같다.

참고

좋은 웹페이지 즐겨찾기