[helper] Redux + Redux-saga
urTweet

[helper] Redux + Redux-saga

반응형

Preview

redux는 단순히 전역 상태 관리를 위한 도구였다. 하지만 우리는 data fetch handling을 통해서 정보 저장을 많이 하기 때문에 redux-saga(middleware)와 redux 결합을 많이 이용하고 있다. (redux-toolkit에서는 crateAsyncthunk가 있다)

여기서 api 추가할 때마다, action과 fetch 상태를 만들어줘야 하는 중복 패턴이 발생하게 되어 이것을 쉽게 해결하고자 helper 함수를 제작하였다.

helper/type.d.ts

전체 코드

helper에서 공통으로 사용하는 type 관리

코드 설명

import { ActionCreatorWithPreparedPayload } from '@reduxjs/toolkit';

import { FetchStatusActionPayload } from '@modules/fetchStatus/@types';

export interface RequestCommonMeta extends SubPartial<FetchStatusActionPayload['data'], 'actionList'> {
  keyName?: string;
  isLoadMore?: boolean;
}

export type ActionMetaPayload<P, M> = ActionCreatorWithPreparedPayload<[payload: P, meta?: M], P, string>;

export type PromiseAction<R, S, F, M> = {
  payload: R;
  meta?: M;
  resolve?: (value: S) => void;
  reject?: (value: CustomAxiosError<F>) => void;
};

export type FetchAction<R, S, F, M> = {
  TYPE: string;
  request: ActionMetaPayload<R, M>;
  success: ActionMetaPayload<S, M>;
  failure: ActionMetaPayload<F, M>;
};
  • RequstCommonMeta: api call에서 공통으로 사용되는 meta 정보
  • ActionMetaPayload: payload, meta를 사용하는 action type
  • PromiseAction: createRequestAsyncThunk에서 만들어지는 action 객체 타입
  • FetchAction: helper/createFetchAction.ts에서 리턴한 R, S, F, M의 제너릭 타입을 다른 함수에서도 제너릭 유추에 활용하기 위함

helper/createRequestAsyncThunk.ts

전체 코드

action은 순차적 방식이다. (ContextApi 같이 batch 방식이 아님)

api 요청 수행(fetch)은 redux-saga(middleware) 영역에서 이루어지기 때문에, 해당 요청의 결과는 변화 감지 훅스 useSelector를 통해 확인(fetchStatus[actionType].status)하고, callback 함수를 호출한다. 이렇게 되면 fetch 함수, useSelector를 통해 success, faile 감지 후 callback을 호출하는 함수 2개의 함수를 따로 관리가 되어 불편해진다.

 

여기서 promise async-await처럼 비동기 함수를 기다리게 하기 위해, dispatch을 promise로 감싸게 해주는 helper 함수를 만들었다.

코드 설명

import { Dispatch } from '@reduxjs/toolkit';

import { ActionMetaPayload } from './type';

export const createRequestAsyncThunk = <R, S, M>(action: ActionMetaPayload<R, M | undefined>) => {
  return (payload: R, meta?: M) => (dispatch: Dispatch) => {
    return new Promise<S>((resolve, reject) => {
      dispatch({ ...action(payload, meta), resolve, reject });
    });
  };
};

redux-thunk를 사용하여 미들웨어에서 disptatch를 전달해, reducer에게 Promise의 resolve, reject를 전달하는 helper 함수를 만들었다.

resolve, reject를 부가적으로 값을 던지게 하여, 해당 saga, thunk영역에서 결과 값을 해당 호출 인자로 넣게 되면, 자연스럽게 handle~ 함수 안에서 부가 작업을 이루어질 수 있게 된다.

resolve, reject를 호출 안 하게 된다면, 메모리 부족 현상처럼 성능에 영향이 미치지 않을까?

이슈 확인 => 결론은 성능에서 상관없다.

helper/createFetchAction.ts

전체 코드

api 요청 타입 이름과 액션 생성자를 리턴하는 helper 함수

코드 설명

import { createAction } from '@reduxjs/toolkit';

import { createRequestAsyncThunk } from './createRequestAsyncThunk';
import { RequestCommonMeta } from './type';

export const createFetchAction = <
  R,
  S,
  F extends ErrorCommonRes = ErrorCommonRes,
  M extends RequestCommonMeta = RequestCommonMeta,
>(
  type: string,
) => {
  const REQUEST = `${type}/request`;
  const SUCCESS = `${type}/success`;
  const FAILURE = `${type}/failure`;

  const action = {
    TYPE: type,
    request: createAction(REQUEST, (payload: R, meta?: M) => ({ payload, meta })),
    success: createAction(SUCCESS, (payload: S, meta?: M) => ({ payload, meta })),
    failure: createAction(FAILURE, (payload: F, meta?: M) => ({ payload, meta })),
  };
  const asyncThunk = createRequestAsyncThunk<R, S, M>(action.request);
  return { ...action, asyncThunk };
};
  • requset, success, failure: action 생성자 함수
  • asyncTunk: promise 안에 dispatch 하여 async-await을 할 수 있는 action 생성자 함수
  • F 타입: ErrorCommonRes가 포함되도록 설정, default 또한 ErrorCommonRes
  • M 타입: RequestCommonMeta가 포함되도록 설정, default 또한 RequestCommonMeta

helper/createFetchSaga.ts

전체 코드

api 요청은 [INIT] api request [LOADING] => [SUCCESS | FAIL] 순으로 작업이 이루어진다. (불변 흐름)

위 fetch 요청 과정은 항상 필요한 플로우가 되면서, 반복 코드가 눈에 띄게 되어, 해당 과정을 하나의 제너레이터 사가 함수를 리턴해주는 helper 함수를 만들었다.

코드 설명

import { AxiosResponse } from 'axios';
import { call, put } from 'redux-saga/effects';

import { CustomAxiosError } from '@typings/type';
import isCustomAxiosError from '@utils/isCustomAxiosError';

import { fetchStatusAction } from '../fetchStatus';
import { FetchAction, PromiseAction, RequestCommonMeta } from './type';

export const createFetchSaga = <R, S, F, M extends RequestCommonMeta>(
  fetchAction: FetchAction<R, S, F, M>,
  requestCall: (query: R | never) => Promise<AxiosResponse<S>>,
  successCall?: (data: S) => void,
  failureCall?: (error: CustomAxiosError<F>) => void,
) => {
  return function* (action: PromiseAction<R, S, F, M>) {
    const actionList = action.meta?.actionList;
    try {
      yield put(fetchStatusAction.requestFetchStatus({ type: fetchAction.TYPE, actionList }));
      const { data }: AxiosResponse<S> = yield call(requestCall, action.payload);
      yield put(fetchAction.success(data, action.meta));
      yield put(fetchStatusAction.successFetchStatus({ type: fetchAction.TYPE, response: data, actionList }));
      if (successCall) {
        yield call(successCall, data);
      }
      if (action.resolve) {
        action.resolve(data);
      }
    } catch (error) {
      if (isCustomAxiosError(error)) {
        const { data }: AxiosResponse<F> = error.response;
        yield put(fetchAction.failure(data, action.meta));
        yield put(fetchStatusAction.failureFetchStatus({ type: fetchAction.TYPE, response: data, actionList }));
        if (failureCall) {
          yield call(failureCall, error);
        }
        if (action.reject) {
          action.reject(error);
        }
      }
    }
  };
};

매개변수인 requestCall은 일반 함수 혹은 제너레이터 함수 상관없이 받도록 하였다.

  • request과정에서 request 이전(INIT), 이후(SUCCESS | FAIL) 상황에 따른 콜백을 고려해야 한다면, [successCall | failureCall] 제너레이러터를 만들어서 사용한다. ([case 1])
  • request 요청이 끝나고 난 후에 처리를 하게 될경우 ([case 2])

M의 제너릭 타입은 RequestCommonMeta 타입을 포함시키도록 했다.

actionList를 통해 단일 요청 fetch 상태를 관리할 수 있다. (해당 이슈에서 확인)
action의 [resolve | reject] 값은 helper/createRequestAsyncThunk.ts 에서 만들어준 인자 값이다.

 

error처리

  • fetchAction.failure: 요청한 액션에서는 F 타입을 가진 값을 전달한다.
  • fetchStatusAction.failureFetchStatus: 마찬가지로 해당 F 타입을 가진 값을 전달한다.
  • action.reject: 해당 error의 전체 데이터 값을 전달한다.
    • side-effect를 할 때, F 타입 외의 데이터가 필요할 수 있다고 판단

[case 1]. createRequestSaga의 requestCall 작업([action.resolve | action.reject] 전)

에러가 나오게 된다면 자동으로 createRequestSaga의 catch에 잡히게 된다.

function* watchCreatePost() {
  yield debounce(
    300,
    postAction.fetchCreatePost.request,
         								       // 요청 작업 수행 ↓  // successCall ↓ 
    createFetchSaga(postAction.fetchCreatePost, requestCreatePost, function* (data) {
      yield put(userAction.addPostToMe(data.resData.id));
    }),
  );
}

[case 2]. createRequestSaga 작업 완료 후의 작업([action.resolve | action.reject] 후)

const removePostSaga = createRequestSaga(postAction.fetchRemovePost, requestRemovePost);

function* watchRemovePost() {
  yield takeLatest(postAction.fetchRemovePost, function* (action) {
    yield call(removePostSaga, action);
    const rootState: RootState = yield select();
    const { status, data } = rootState.FETCH_STATUS[postAction.removePost.TYPE];
    if (status === 'SUCCESS') {
      yield put(removePostToMe(data.PostId));
    }
  });
}

한계점

saga의 call함수 리턴 타입은 항상 지정해줘야 하는 번거로움은 여전히 해소를 못하고 있다... 해당 이슈

code view

github

 

GitHub - yjkwon07/urTweet: React 라이브러리를 사용하면서 유지보수 및 생산성을 높이기 위해, 폴더, 데

React 라이브러리를 사용하면서 유지보수 및 생산성을 높이기 위해, 폴더, 데이터 구조화 modules를 정의 - GitHub - yjkwon07/urTweet: React 라이브러리를 사용하면서 유지보수 및 생산성을 높이기 위해, 폴

github.com

반응형