[말로 풀어쓴 React] 리덕스 미들웨어를 통한 비동기 작업 관리 2

DongGu·2021년 2월 26일
0

목차

18.1 작업 환경 준비

18.2 미들웨어란?

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

  • 18.3.1.4 '웹 요청' 비동기 작업 처리하기
    이번에는 thunk의 속성을 활용하여 웹 요청 비동기 작업을 처리하는 방법에 대해 알아볼 것이다. API를 호출할 때는 주로 Promise 기반 웹클라이언트인 axios를 사용한다. 해당 라이브러리를 설치한다. yarn add axios

JSONPlaceholder에서 제공되는 가짜 API를 사용할 것이다. API를 모두 함수화할 것이다. 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이라는 리듀서를 생성할 것이다. 주석을 읽으면서 다음 코드를 작성하자.

setTimeout으로 시간 지연을 줬던 것처럼 '초기 액션'을 디스패치해온 다음, 그 '액션'의 TYPE에 따라 함수를 달리 적용한다.

getPost, getUsers 모두 총 두 번의 디스패치가 일어나는데, 최종적으로 전달되는 액션은 마지막 액션인지..?

// 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.getUses();
    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;

코드에서 반복되는 로직이 꽤 있다. 우선 컨테이너 컴포넌트를 사용하여 데이터 요청을 성공적으로 처리하고, 나중에 반복되는 로직을 따로 분리하여 재사용하는 형태로 코드를 리팩토링할 것이다. 리듀서를 다 작성했으면 해당 리듀서를 루트 리듀서에 포함시킨다.

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

const rootReducer = combineReducers({
  counter,
  sample
});

export default rootReducer;

데이터를 렌더링할 프레젠테이셔널 컴포넌트를 작성할 것이다. 이에 앞서 API를 통해 전달받은 데이터 형식이 어떤 구조인지 확인해야 한다.

// post
{
  "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"
}
// users
[
    {
        "id": 1,
        "name": "Leanne Graham",
        "username": "Bret",
        "email": "Sincere@april.biz",
        "address": 
        {
            "street": "Kulas Light",
            "suite": "Apt. 556",
            "city": "Gwenborough",
            "zipcode": "92998-3874",
            "geo": 
                {
                    "lat": "-37.3159",
                    "lng": "81.1496"
                }
        },
        "phone": "1-770-736-8031 x56442",
        "website": "hildegard.org",
        "company": 
        {
            "name": "Romaguera-Crona",
            "catchPhrase": "Multi-layered client-server neural-net",
            "bs": "harness real-time e-markets"
         }
    },
    (...)
]

이번에 만들 컴포넌트는 post의 경우 title과 body만 보여주고, user의 경우 username과 email만 보여줄 것이다.

// components/Sample.js
import React from 'react';
const Sample = ({loadingPost, loadingUsers, post, users}) => {
  return (
    <div>
    	<section>
    	    <h1>포스트</h1>
            {loadingPost && '로딩 중...'}
            {!loadingPost && 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>
    );
};

데이터를 불러와서 렌더링해줄 떄는 유효성 검사를 해주는 것이 중요하다. 예를 들어 post &&를 사용하면 post 객체가 유효할 때만 그 내부의 post.title 혹은 post.body 값을 보여준다. 만약 데이터가 없는 상태라면 post.title을 조회하려고 할 때 자바스크립트 오류가 발생하니 반드시 유효성 검사를 해야 한다.

users도 마찬가지로 데이터가 배열 형태로 들어올 것을 기대하고 map함수를 사용하고 있다. 하지만 유효성 검사를 하지 않으면 null 값에 대해 map 함수를 호출하고, 결국 map 함수가 존재하지 않아 오류가 발생한다.

이제 컨테이너 컴포넌트를 만들 것이다.

// 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.lost,
    users: sample.users,
    loadingPost: sample.loading.GET_POST,
    loadingUsers: sample.loading.GET_USERS,
  }),
  {
    getPost,
    getUsers,
  }
)(SampleContainer);

그 다음 App 컴포넌트에서 CounterContainer 대신 SampleContainer를 렌더링한다.

// App.js
import React from 'react';
import SampleContainer from './container/SampleContainer';

const App = () => {
  return (
    <div>
      <SampleContainer/>
    </div>
  );
};
export default App;
  • 18.3.15 리팩토링
    API를 요청할 때마다 17줄 정도 되는 thunk 함수를 작성하는 것과 로딩 상태를 리듀서에서 관리하는 작업은 귀찮을 뿐 아니라 코드도 길어지게 만든다. 반복되는 로직을 따로 분리하여 코드의 양을 줄일 수 있다.
// lib/createRequestThunk.js
export default function 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,
        error: true
      });
      throw e;
    }
  };
}
// 사용법: createRequestThunk('GET_USERS', api_getUsers);

이번에 만들 유틸 함수는 API 요청을 해주는 thunk 함수를 한 줄로 생성할 수 있게 해준다. 액션 타입과 API를 요청하는 함수를 파라미터로 넣어주면 나머지 작업을 대신 처리해준다. 이 함수를 사용하여 기존 thunk 함수의 코드를 대체해보자.

아래는 기존의 함수다. 유사한 형태가 6번씩 반복되는 것을 볼 수 있다. POST, USERS인지 2가지 경우, 일반/성공/실패라는 3가지 경우로 총 6번이 반복된다. 위에서 만든 createRequestThunk는 TYPE과 함수를 인자로 받는다.

// 기존 thunk 함수
// 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.getUses();
    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;

새롭게 정의한 createReuqestThunk를 이용해 thunk 함수를 대체해보자.

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

(...)

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_LAOADING";
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;

다음은 요청이 시작될 때 디스패치할 액션이다.

{
  type: 'loading/START_LOADING',
  payload: 'sample/GET_POST'
}

위 액션이 디스패치되면 loading 리듀서가 관리하고 있는 상태에서 sample/GET_POST 값을 true로 설정해준다. 만약 기존 상태에 sample/GET_POST 필드가 존재하지 않으면 새로 값을 설정해준다.

그리고 요청이 끝나면 다음 액션을 디스패치해야 한다. 그러면 기존에 true로 설정했던 값을 다시 false로 전환해준다. 리듀서를 다 작성했으면 루트 리듀서에 포함시킨다.

// modules/index.js
import {combineReducers} from 'redux';
import counter from './counter';
import sample from './sample';
import loading from './loading';

const rootReducer = combineReducers({
  counter,
  sample,
  loading,
});
export default rootReducer;

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

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

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

loading 모듈을 적용하기 전의 코드는 아래와 같다. loading 모듈을 만듦으로써 loading 관련 코드를 재사용하여 더 깔끔히 표현했다.

const sample = handleActions(
  {
    [GET_POST]: state => ({
      ...state,
      loading: {
        ...state.loading,
        GET_POST: true // 요청 시작
      }
    }),
  }

그러면 SampleContainer에서 로딩 상태를 다음과 같이 조회할 수 있다.

// 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,
  loadingPot,
  loadingUsers
}) => {
  useEffect(() => {
    getPost(1);
    getUSers(1);
  }, [getPost, getUsers]);
  return (
    <Sample post={post} users={users} loadingPost={loadingPost} loadingUsers={loadingUsers} />
  );
};

export default connect(
  ({sample, loading}) => ({
    post: sample.post,
    users: sample.users,
    loadingPost: loading['sample/GET_POST'],
    loaidngUsers: loading['sample/GET_POST']
  }),
  {
    getPost,
    getUsers
  }
  )(SampleContainer);

이제 sample 리듀서에서 불필요한 코드를 지우자

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

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

export default sample;

이제 sample 리듀서에서는 로딩 중에 대한 상태를 관리할 필요 없다. 성공했을 떄의 케이스만 잘 관리해주면 된다. 추가로 실패했을 때의 케이스를 관리하고 싶다면 _FAILURE가 붙은 액션을 리듀서에서 처리해주면 된다. 혹은 컨테이너 컴포넌트에서 try/catch 구문을 사용해서 에러값을 조회할 수 있다.

// SampleCounter.js -useEffect
useEffect(()=> {
  // useEffect에 파라미터로 넣는 함수는 async로 할 수 없기 때문에
  // 그 내부에서 async 함수를 선언하고 호출해준다.
  const fn = async () => {
    try{
      await getPost(1);
      await getUsers(1);
    } catch(e){
      console.log(e); // 에러 조회
    }
  };
  fn();
}, [getPost, getUsers];
profile
코딩하는 신방과생

0개의 댓글