공부블로그

18장. 리덕스 미들웨어를 통한 비동기 작업 관리 본문

리액트/리액트를 다루는 기술

18장. 리덕스 미들웨어를 통한 비동기 작업 관리

떠어영 2021. 2. 11. 11:37

리액트 웹 애플리케이션에서 API 서버를 연동할 때는 API 요청에 대한 상태도 잘 관리해야 한다. '미들웨어'를 사용하여 비동기 작업을 처리하는 방법을 알아보자.

 

1. 미들웨어란?

리덕스 미들웨어는 액션을 디스패치했을 때 리듀서에서 이를 처리하기에 앞서 사전에 저장된 작업들을 실행한다. 따라서 액션과 리듀서 사이의 중간자라고 할 수 있다.

 

1.1 미들웨어 만들기

 

액션이 디스패치될 때마다 액션의 정보와 액션이 디스패치되기 전후의 상태를 콘솔에 보여주는 로깅 미들웨어를 작성해보자.

src디렉터리에 lib디렉터리를 생성하고 그 안에 loggerMiddleware. js 파일을 생성한다.

 

next 를 호출하면 그다음 처리해야 할 미들웨어에게 액션을 넘겨주고 미들웨어가 없다면 리듀서에게 액션을 넘겨준다

const loggerMiddleware = (store) => (next) => (action) => {
  //미들웨어 기본구조

  console.group(action && action.type); //액션 타입으로 log를 그룹화함
  console.log("이전 상태", store.getState());
  console.log("액션", action); //액션 정보
  next(action); //다음 미들웨어 혹은 리듀서에게 전달
  console.log("다음상태", store.getState()); //업데이트된 상태
  console.groupEnd(); //그룹 끝
};
export default loggerMiddleware;

 

src/ index. js ( redux-logger사용 )

//리덕스 적용하기
import React from "react";
import ReactDOM from "react-dom";
import { createStore, applyMiddleware } from "redux";
import { Provider } from "react-redux";
import "./index.css";
import App from "./App";
import rootReducer from "./modules";
//import loggerMiddleware from "./lib/loggerMiddleware";
import { createLogger } from "redux-logger";

const logger = createLogger(); //리덕스 logger 사용하기
//리덕스 미들웨어를 스토어를 생성하는 과정에서 적용
const store = createStore(rootReducer, applyMiddleware(logger)); //루트 리듀서를 이용하여 스토어 생성

ReactDOM.render(
  //Provider 컴포넌트로 App컴포넌트를 감싸서 리덕스 적용
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

미들웨어에서는 여러 종류의 작업을 처리할 수 있다. 

조건에 따라 액션을 무시할 수도, 액션 정보를 가로채서 변경한 후 리듀서에 전달해 줄 수도, 새로운 액션을 여러번 디스패치할 수도 있다.

 

2. 비동기 작업을 처리하는 미들웨어 사용

오픈 소스 커뮤니티에 공개된 미들웨어를 사용하여 리덕스를 사용하고 있는 프로젝트에서 비동기 작업을 더욱 효율적으로 관리한다.

- redux-thunk 미들웨어: 가장 많이 이용, 객체가 아닌 함수 형태의 액션을 디스패치할 수 있게 해줌.

- redux-saga 미들웨어: 특정 액션이 디스패치되었을 때 정해진 로직에 따라 다른 액션을 디스패치시키는 규칙을 작성

 

2. 1  redux-thunk

 

thunk는 특정 작업을 나중에 할 수 있도록 미루기 위해 함수 형태로 감싼 것을 의미한다. 

 

1. index. js 에서 스토어를 만들 때 redux-thunk를 적용한다.

...
import ReduxThunk from "redux-thunk";

const logger = createLogger(); //리덕스 logger 사용하기

//리덕스 미들웨어를 스토어를 생성하는 과정에서 적용, 스토어 만들 때 redux-thunk를 적용
const store = createStore(rootReducer, applyMiddleware(logger, ReduxThunk)); 
...

2. counter. js 리덕스 모듈에서 Thunk생성 함수를 만든다.

//redux-thunk는 액션 생성 함수에서 일반 액션 객체를 반환하는 대신에 함수를 반환한다.
export const increaseAsync = () => (dispatch) => {
  //1초 뒤에 increase 함수를 디스패치
  setTimeout(() => {
    dispatch(increase());
  }, 1000);
};
export const decreaseAsync = () => (dispatch) => {
  setTimeout(() => {
    dispatch(decrease());
  }, 1000);
};

3. CounterContainer. js 에서 호출하는 액션 생성함수를 변경해준다.

const CounterContainer = ({
  number,
  increaseAsync,
  decreaseAsync,
}) => {
  return (
    <Counter
      number={number}
      onIncrease={increaseAsync}
      onDecrease={decreaseAsync}
    />
  );
};
export default connect(
  (state) => ({
    // 리덕스 스토어안에 있는 상태
    number: state.counter,
  }),
  {
    increaseAsync, //액션을 디스패치하는 함수들
    decreaseAsync,
  }
)(CounterContainer);

 

4. 웹 요청 비동기 작업 처리

API를 모두 함수화해주고 export를 사용하여 내보내준다.

lib /api. js

import axios from "axios";

export const getPost = (id) =>
  axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`);
export const getUsers = (id) =>
  axios.get(`https://jsonplaceholder.typicode.com/users`);

그리고 이 API를 사용하여 새로운 리듀서( sample )를 만든다. 

modules/ sample. js

import { handleActions } from "redux-actions";
import * as api from "../lib/api";

//액션 타입 선언, 한 요청당 세개를 만든다.
const GET_POST = "sample/GET_POST";
const GET_POST_SUCCESS = "sample/GET_POST_SUCCESS";
const GET_POST_FAILURE = "sample/GET_POST_FAILURE";

const GET_USERS = "sample/GET_USERS";
const GET_USERS_SUCCESS = "sample/GET_USERS_SUCCESS";
const GET_USERS_FAILURE = "sample/GET_USERS_FAILURE";

//thunk함수 생성
//thunk함수 내부에서는 시작할 때, 성공했을 때, 실패했을 때 다른 액션을 디스패치
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,
      error: true,
    }); //에러 발생
    throw e; //에러를 조회할 수 있게 해줌
  }
};

export const getUsers = () => async (dispatch) => {
  dispatch({ type: GET_USERS }); //요청을 시작한 것을 알림
  try {
    const response = await api.getUsers();
    dispatch({
      type: GET_USERS_SUCCESS,
      payload: response.data,
    }); //요청 성공
  } catch (e) {
    dispatch({
      type: GET_USERS_FAILURE,
      payload: e,
      error: true,
    }); //에러 발생
    throw e; //에러를 조회할 수 있게 해줌
  }
};

//초기상태 선언
//요청의 로딩 중 상태는 loading이라는 객체에서 관리한다.
const initialState = {
  loading: {
    GET_POST: false,
    GET_USERS: false,
  },
  post: null,
  users: null,
};

const sample = handleActions(
  {
    [GET_POST]: (state) => ({
      ...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, //요청 완료
      },
    }),
    [GET_USERS]: (state) => ({
      ...state,
      loading: {
        ...state.loading,
        GET_USERS: true, //요청 시작
      },
    }),
    [GET_USERS_SUCCESS]: (state, action) => ({
      ...state,
      loading: {
        ...state.loading,
        GET_USERS: false, //요청 완료
      },
      users: action.payload,
    }),
    [GET_USERS_FAILURE]: (state, action) => ({
      ...state,
      loading: {
        ...state.loading,
        GET_USERS: false, //요청 완료
      },
    }),
  },
  initialState
);
export default sample;

새로운 리듀서는 루트 리듀서에 포함시킨다.

 

데이터를 렌더링할 프레젠테이셔널 컴포넌트를 작성한다. 

components/ Sample. js

import React from "react";

const Sample = ({ post, users, loadingPost, loadingUsers }) => {
  return (
    <div>
      <section>
        <h1>포스트</h1>
        {loadingPost && "로딩중..."}
        {!loadingPost &&
          post && ( //post객체가 유효할 때만 보여줌
            <div>
              <h3>{post.title}</h3>
              <h3>{post.body}</h3>
            </div>
          )}
      </section>
      <hr />
      <section>
        <h1>사용자 목록</h1>
        {loadingUsers && "로딩중..."}
        {!loadingUsers && users && (
          <ul>
            {users.map((user) => (
              <li key={user.id}>
                {user.username} ({user.email})
              </li>
            ))}
          </ul>
        )}
      </section>
    </div>
  );
};

export default Sample;

컨테이너 컴포넌트를 만든다.

containers/ SampleContainer.js

import React from "react";
import { connect } from "react-redux";
import Sample from "../components/Sample";
import { getPost, getUsers } from "../modules/sample";

const { useEffect } = React;
const SampleContainer = ({
  getPost,
  getUsers,
  post,
  users,
  loadingPost,
  loadingUsers,
}) => {
  // 클래스 형태 컴포넌트였더라면, componentDidMount
  useEffect(() => {
    getPost(1);
    getUsers(1);
  }, [getPost, getUsers]);
  return (
    <Sample
      post={post}
      users={users}
      loadingPost={loadingPost}
      loadingUsers={loadingUsers}
    />
  );
};

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

App. js에서 렌더링한다.

 

 

5. 리팩토링

API를 요청할 때마다 thunk함수를 작성하는 것과 로딩 상태를 리듀서에서 관히라는 작업은 귀찮으므로 반복되는 로직을 따로 분리한다.

lib/ createRequestThunk. js 

import { startLoading, finishLoading } from '../modules/loading';

export default function createRequestThunk(type, request) {
  // 성공 및 실패 액션 타입을 정의합니다.
  const SUCCESS = `${type}_SUCCESS`;
  const FAILURE = `${type}_FAILURE`;
  return params => async dispatch => {
    dispatch({ type }); // 시작됨
    dispatch(startLoading(type));
    try {
      const response = await request(params);
      dispatch({
        type: SUCCESS,
        payload: response.data
      }); // 성공
      dispatch(finishLoading(type));
    } catch (e) {
      dispatch({
        type: FAILURE,
        payload: e,
        error: true
      }); // 에러 발생
      dispatch(startLoading(type));
      throw e;
    }
  };
}

// 사용법: createRequestThunk('GET_USERS',api.getUsers);

이렇게 만든 유틸 함수는 API요청을 해주는 thunk함수를 한 줄로 생성할 수 있게 해준다. 액션 타입과 API를 요청하는 함수를 파라미터로 넣어준다.

modules/ sample. js

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

 

로딩 상태를 관리하는 작업을 개선하기 위해 리듀서 내부에서 각 요청에 관련된 액션이 디스패치될 때마다 로딩 상태를 변경해 주던 것을 로딩 상태만 관리하는 리덕스 모듈을 따로 생성하여 처리한다.

modules/ loading. js

import { createAction, handleActions } from 'redux-actions';

const START_LOADING = 'loading/START_LOADING';
const FINISH_LOADING = 'loading/FINISH_LOADING';

/*
 요청을 위한 액션 타입을 payload 로 설정합니다 (예: "sample/GET_POST")
*/

export const startLoading = createAction(
  START_LOADING,
  requestType => requestType
);

export const finishLoading = createAction(
  FINISH_LOADING,
  requestType => requestType
);

const initialState = {};

const loading = handleActions(
  {
    [START_LOADING]: (state, action) => ({
      ...state,
      [action.payload]: true
    }),
    [FINISH_LOADING]: (state, action) => ({
      ...state,
      [action.payload]: false
    })
  },
  initialState
);

export default loading;

만든 loading 리듀서를 루트 리듀서에 포함시킨다.

 

createRequestThunk에서 loading 리덕스 모듈에서 만든 액션 생성함수를 사용한다.

import { startLoading, finishLoading } from "../modules/loading";
...
dispatch(startLoading(type));
try{
...dispatch(finishLoading(type));
}catch(e){
...dispatch(startLoading(type));
}

SampleContainer에서 로딩상태를 조회할 수 있다.

export default connect(
  ({ sample, loading }) => ({
    post: sample.post,
    users: sample.users,
    loadingPost: loading["sample/GET_POST"],
    loadingUsers: loading["sample/GET_USERS"],
  }),
  {
    getPost,
    getUsers,
  }

최종적으로 불필요한 코드를 지우면 sample 리듀서의 코드가 깔끔해진다.

...
const initialState = {
  /*loading: {
    GET_POST: false,
    GET_USERS: false,
  },*/
  post: null,
  users: null,
};

const sample = handleActions(
  {
    [GET_POST_SUCCESS]: (state, action) => ({
      ...state,
      post: action.payload,
    }),
    [GET_USERS_SUCCESS]: (state, action) => ({
      ...state,
      users: action.payload,
    }),
  },
  initialState
);

 

2. 2 redux-saga

 

좀 더 까다로운 상황에서 유용

  • 기존 요청을 취소 처리해야 할 때
  • 특저 액션이 발생했을 때 다른 액션을 발생시키거나, API요청 등 리덕스와 관계없는 코드를 실행할 때
  • 웹 소켓을 사용할 때
  • API요청 실패 시 재요청해야 할 때

제너레이터 함수 사용: 함수를 작성할 때 함수를 특정 구간에 멈춰 놓을 수도 있고, 원할 때 다시 돌아가게 할 수도 있다. function* 키워드 사용, next( )가 호출되면 다음 yield가 있는 곳까지 호출하고 다시 함수가 멈춘다.