[saga+toolkit] redux-saga와 redux-toolkit으로 비동기 처리 모듈화하기

Seung-a·2021년 4월 30일
10
post-thumbnail

💬 들어가며

지금까지 redux에 api 호출에 관한 데이터를 저장할 때는 FSA 형식으로 redux-thunk를 사용하여 다음과 같은 방식으로 관리했다.

예를 들어 posts의 정보를 state.posts에 저장하려면

1. state.posts는 FSA 형식의 객체 형식으로 정의한다.

state.posts = {
  data: null,
  loading: false,
  error: false
}

2. api를 호출하는 action을 dispatch 한다.

  1. 요청 -> post/getPosts
  2. 요청 성공 시 -> post/getPostsSuccess
  3. 요청 실패 시 -> posts/getPostsError

이렇게 관리하면 action을 통해 현재 api 호출 상태를 쉽게 알아볼 수 있고, state를 조회할 때도 편리하다.
나는 위와 같은 과정을 강의로 정리하며 익히며 유용하게 사용 중이다.

그런데 최근 redux-sagaredux-toolkit을 사용할 일이 생겼고, 위와 동일한 기능을 수행하면서 효율적으로 코드를 작성하고 싶었다.

공부해보며 쉽게 작성한 부분도 있었고, 몇 번을 썼다 지운 코드도 있었다. 그 중 현재 나에게 가장 편한 방법으로 모듈화한 과정을 잊지 않게 적어두려 한다.

👩‍💻 createPromiseSaga

이 부분은 강의를 참고하여 작성했습니다.

위에서 기술한 2번 방식으로 action을 하나씩 dispatch 하려면 번거로울 것이다. 그래서 type과 promise를 반환하는 function을 인자로 받아 2번 작업을 한번에 수행하는 saga를 반환하는 함수를 작성했다.

/* 호출 방법 예시 */
const getPostsSaga = createPromiseSaga("posts/getPosts", PostsAPI.readPosts);`
/* src/lib/asyncUtils.js 중 일부 */

import { call, put } from "redux-saga/effects";

export const createActionString = (type) => {
  return { success: `${type}Success`, error: `${type}Error` };
}; // 1. return success action, error action string

export const createPromiseSaga = (type, promiseCreator) => {
  const { success, error } = createActionString(type); // 1.

  return function* (action) {
    try {
      const response = yield call(promiseCreator, action.payload); // 2.
      yield put({ 
        type: success,
        payload: response
      }); // 3.
    } catch (err) {
      yield put({
        type: error,
        payload: err.message,
        error: true
      }); // 4.
    }
  };
};

...
  1. createActionString(type)으로 type에 해당하는 action이 성공 또는 실패 했을 때 action string을 반환한다.
const { success, error } = createActionString('post/getPosts');
// success = 'post/getPostsSuccess';
// error = 'post/getPostsError';
  1. action.payload 값을 사용하여 promiseCreator function을 실행하고, 그 결과를 response에 저장하고 post/getPosts action을 dispatch 한다.
  2. 요청 성공 시 action.payload에 결과를 저장하고 post/getPostsSuccess action을 dispatch 한다.
  3. 요청 성공 시 action.payload에 에러 메시지를 저장, error: true로 변경하고 post/getPostsSuccess action을 dispatch 한다.

이렇게 createPromiseSaga를 통해 api 호출 상태에 따라 다른 action을 dispatch 하는 과정을 모듈화할 수 있다.

👩‍💻 redux-toolkit으로 반복되는 action, reducer 처리

1. reducerUtils

먼저 FSA 형식의 객체를 return 해주는 util 함수를 작성한다.

/* src/utils/asyncUtils.js 중 일부 */

export const reducerUtils = {
  init: () => ({
    data: null,
    loading: false,
    error: false
  }),

  loading: (prevData = null) => ({
    data: prevData,
    loading: true,
    error: false
  }),

  success: (data = null) => ({
    data: data,
    loading: false,
    error: false
  }),

  error: (error) => ({
    data: error,
    loading: false,
    error: true
  })
};

위처럼 작성하면 reducer에서 state에 FSA 형식의 객체를 대입할 때 일일히 작성하지 않아도 된다.

const initState = {
  posts: reducerUtils.init() // 초기화
}

2. handleAsyncAction

위처럼 FSA 반환 과정을 모듈화해도 state마다 reducerUtils를 참조하는 과정이 번거롭다고 생각돼서 action을 인자로 받아 type에 따라 reducerUtils를 호출하는 함수를 만들었다.

/* src/lib/asyncUtils.js 일부 */

export const handleAsyncAction = ({ type, payload = {} }, prevData = null) => { 
  // success or error
  if (type.includes("Success")) return reducerUtils.success(payload); // 1.
  if (type.includes("Error")) return reducerUtils.error(payload); // 2.
  // loading
  return reducerUtils.loading(prevData); // 3.
};
  1. 요청 성공 action이면 -> reducerUtils.success
  2. 요청 실패 action이면 -> reducerUtils.error
  3. 요청 action이면 -> reducerUtils.loading

3. reducer (extraReducer 사용)

지금까지 작성한 유틸 함수를 이용하면 일일히 action과 reducer를 일일히 작성하지 않고 동적으로 처리할 수 있다.

state.post.postsposts/getPosts 라는 action으로 api 호출 데이터를 받는다고 가정한다.

/* src/reducer/post.js */

import { createSlice } from "@reduxjs/toolkit";
import { takeEvery } from "redux-saga/effects";

import { createPromiseSaga, reducerUtils, handleAsyncAction } from "../lib/asyncUtils";
import PostsAPI from "../api/posts";

/* action */
const prefix = "posts";

/* reducer */
const initState = {
  posts: reducerUtils.init() // 0. state 초기화
};

export const posts = createSlice({
  name: prefix,
  initialState: initState,
  
  reducers: {
    getPosts: (state, action) => {}, // 1. saga 실행 action 생성 (api 호출)
  },
  
  extraReducers: (builder) => {
    builder.addMatcher( // 2. 모든 action을 인자로 받는 callback
      (action) => {
        return action.type.includes(prefix); // 3. posts action일 경우
      },
      (state, action) => {
        state.posts = handleAsyncAction(action); // 4. action type에 따라 맞는 FSA 객체 return
      }
    );
  }
});

export const { getPosts } = posts.actions;

/* saga */
const getPostsSaga = createPromiseSaga(getPosts, PostsAPI.readPosts); // 5. api 상태에 따라 적합한 action을 dispatch하는 saga 생성

export function* postSaga() {
  yield takeEvery(getPosts, getPostsSaga);
}

export default posts;
  1. extraReducers에서 builder.addMatcher의 첫 번째 callback으로 posts 관련 action만 다룰 수 있도록 제한한다.
  2. builder.addMatcher의 두 번째 cabllback으로 원하는 작업을 수행한다. handleAsyncAction을 통해 action type에 따라 적합한 FSA 객체를 return하여 state.posts에 저장한다.

👩‍💻 state가 여러개 존재할 때 분기 처리

댓글을 확인하고 추가한 내용 입니다. - 2021. 06. 23

여러 방식이 있겠지만 현재 코드에서 모듈화할 수 있는 방법을 생각해보았다.

다음과 같이 reducer에 여러개의 비동기 관련 state가 존재하고, 특정 state에 값을 저장해야하는 상황을 대응할 것이다.

const prefix = "posts";

const initState = {
  posts: reducerUtils.init(),
  post: reducerUtils.init()
};

export const posts = createSlice({
  name: prefix,
  initialState: initState,
  ...
});

ex) posts reducer에 state.posts, state.post 존재

1. 해당 state명을 payload에 포함

// 1. saga 실행 action
dispatch(getPosts({ stateType: "posts" })); // posts/getPosts action
dispatch(getPost({ id, stateType: "post" })); // posts/getPost action

// 2. saga -> success, error action
const getPostsSaga = createPromiseSaga(getPosts, PostsAPI.readPosts, "posts");
const getPostSaga = createPromiseSaga(getPost, PostAPI.readPost, "post");

stateType이라는 값으로 저장하고자 하는 state명을 전달한다.

2. state명 유무에 따라 createPromiseSaga를 분기처리

export const createPromiseSaga = (type, promiseCreator, stateType = null) => { // 0.
  const { success, error } = createActionString(type);

  return function* (action) {
    try {
      const response = yield call(promiseCreator, action.payload);
      const payload = stateType ? { ...response, stateType } : response; // 1.

      yield put({
        payload,
        type: success
      });
    } catch (err) {
      const payload = stateType ? { msg: err.message, stateType } : { msg: err.message }; // 2.

      yield put({
        type: error,
        error: true,
        payload
      });
    }
  };
};
  1. stateType이 없는 reducer도 대응 가능하도록 default를 null로 설정한다.
  2. stateType이 존재하는 요청 성공일 경우 payload에 stateType을 포함한다.
  3. stateType이 존재하는 요청 실패일 경우 payload에 stateType을 포함한다.

3. handleAsyncAction - stateType 제거

export const handleAsyncAction = ({ type, payload = {} }, prevData = null) => {
  if (payload.hasOwnProperty("stateType")) delete payload.stateType; // 변경 사항
  
  // success or error
  if (type.includes("Success")) return reducerUtils.success(payload);
  if (type.includes("Error")) return reducerUtils.error(payload);
  // loading
  return reducerUtils.loading(prevData);
};

stateType은 대입할 state만 판단하고, 직접 state에 저장하진 않을 것이므로 존재한다면 제거하고 return 한다.

3. extraReducer 변경

  extraReducers: (builder) => {
    builder.addMatcher(
      (action) => {
        return action.type.includes(prefix);
      },
      (state, action) => {
        state[action.payload.stateType] = handleAsyncAction(action); // 변경 사항
      }
    );
  }

stateType를 통해 저장할 state를 지정한 후 handleAsyncAction로 제거하여 저장한다.

위의 방식대로 코드를 수정하면 단일 state일 때( = stateType을 전달하지 않을 때), 여러개일 때 대응할 수 있는 것을 확인했다.

단, 기술하지 않았지만 state명 변경 가능성을 고려하여 reducer가 있는 위치에 state를 상수로 정의하고 사용하였다!

/* src/reducer/post.js 중 일부 */

const prefix = "posts";
export const [postsState, postState] = [prefix, "post"]; // 변경 사항

const initState = {
  posts: reducerUtils.init(),
  post: reducerUtils.init()
};

export const posts = createSlice({
  name: prefix,
  initialState: initState,
  ...
});
  
...

📚 마치며

공통적인 case를 다루는 부분을 builder.addMatcher로 처리했는데, 다른 reducer까지 비교해야 하므로 성능적으로 최선인 지는 모르겠다.
계속 개발하면서 개선점을 찾고 효율적인 방법을 찾아나갈 것이다. 🙆‍♀️

🔗 참고

📝 수정 내역

  1. state가 여러개 존재할 때 분기 처리 항목 추가 - 2021. 06. 23
  2. createSlice로 action 생성 (기존: createAction 사용) - 2021. 06. 23
  3. handleAsyncReducer 로직 수정 - 2021. 06. 23

피드백은 언제나 환영합니다 ❤

10개의 댓글

comment-user-thumbnail
2021년 5월 24일

정말 유익한 포스팅 감사드립니다 !! 덕분에 많이 보고 배워가요.
추가로 참고하신 강의를 알고싶은데 지금은 404 페이지가 떠서요 어떤 강의 인지 알고싶습니다 !

1개의 답글
comment-user-thumbnail
2021년 6월 22일

안녕하세요. 글 잘봤습니다! 글을 보고 궁금증이 생겨서 댓글을 남기게 되었습니다.

(state, action) => {
state.posts = handleAsyncAction(action);
}

만약 비동기 관련 State( posts, users 등 )가 여러개 존재할 때 위 코드에서 분기 처리를 어떻게 하시나요? 해당 코드에서는 posts만 처리하는 로직같아서요!

1개의 답글
comment-user-thumbnail
2021년 8월 21일

좋은 글 잘봤습니다 :)
근데 궁금한것이 있습니다.
둘다 비동기를 처리하는데 있어 유용한 미들웨어인건 확실하고,
툴킷 사용전에는 저도 saga를 많이 이용했거든요.
근데 툴킷 자체에서도 지원하는 미들웨어가 thunk이고 그만 큼 thunk랑 비동기처리하는건 코드면에서도 더 갈끔하고 좋다고 느겼는데요 굳이 saga를 사용한 이유가 있을까요?

답글 달기
comment-user-thumbnail
2024년 3월 26일

님의 글 잘 봤습니다. 대단하십니다. 포스팅에 사용하신 마크다운은 어떻게 배울 수 있나요? 아이콘을 넣어서 작성한 것이 신기하였습니다.

답글 달기
comment-user-thumbnail
2024년 3월 26일

님의 글 잘 봤습니다. 대단하십니다. 포스팅에 사용하신 마크다운은 어떻게 배울 수 있나요? 아이콘을 넣어서 작성한 것이 신기하였습니다.

답글 달기