redux-thunk, redux-saga 이용하기

blackbell·2020년 10월 26일
2

외주 프로젝트를 진행하다보니 컴포넌트 내부에 있었으면 하는 로직들이 saga쪽에 있게 되는 문제가 발생하였습니다.
완벽하게 문제를 해결하지는 못하였지만, 만들어놓은 helper함수와 같이 사용할 수 있는 방법을 찾았고 모두 동의하였습니다.
(+프로젝트에서 redux를 어떻게 사용하고 있는지(일부))

간단한 Todo앱 예시 (backend는 Nestjs로 DB없이 만듬)
(⛔️말보다는 코드로만 설명이 되어있는 글입니다⛔️)

redux-thunk

이용하고 있는 방법을 설명하기 전에 먼저 redux-thunk에 대해 알아보겠습니다.

const thunk = store => next => action =>
  typeof action === 'function'
    ? action(store.dispatch, store.getState)
    : next(action)

redux-thunk는 다음과 같이 생긴 간단한 미들웨어입니다.
thunk의 예시를 보면 다음과 같이 생겼습니다.
(redux-thunk github에 있는 예시이며, counter가 홀수일 때 increment 해주는 thunk입니다.)

const incrementIfOdd = () => (dispatch, getState) => {
    const { counter } = getState();

    if (counter % 2 === 0) return;

    dispatch(increment());
};

redux-saga

redux-saga를 어떻게 이용하고 있을까요?
createRequestAction, createRequestSaga 2개의 helper 함수를 살펴보고 가겠습니다.
비동기와 관련된 액션을 만들어주는 createRequestAction이 있습니다.
액션과 saga를 받아 다시 saga를 만들어주는 createRequestSaga가 있습니다.

function createRequestAction(type) {
  const REQUEST = `${type}/REQUEST`;
  const SUCCESS = `${type}/SUCCESS`;
  const FAILURE = `${type}/FAILURE`;

  return {
    type,
    REQUEST,
    SUCCESS,
    FAILURE,
    request: createAction(REQUEST, (payload, meta) => ({ payload, meta })),
    success: createAction(SUCCESS, (payload, meta) => ({ payload, meta })),
    failure: createAction(FAILURE, (payload, meta) => ({ payload, meta })),
  };
}

// actions는 위에서 createRequestAction으로 만들어진 값을 의미합니다.
// request, success, failure와 같이 action들이 여러 개 있기에😅
function createRequestSaga(actions, saga) {
  return function* (action) {
    try {
      const result = yield call(saga, action);
      yield put(actions.success(result, action.meta));
    } catch (e) {
      yield put(actions.failure(e, action.meta));
    }
  };
}

코드만 살펴보면 이해가 가지 않을 수 있습니다. 바로 위의 예제 코드를 살펴보겠습니다.
todos를 불러오는 비동기처리입니다.
1. createRequestAction helper 함수를 통해 action을 만들어줍니다.
2. redux-toolkit의 createReducer 함수를 통해 reducer를 만들어줍니다.
3. getTodos.REQUEST type일 때 createRequestSaga를 통해 만든 saga가 실행됩니다. (즉, TodoList에서 dispatch(getTodos.request()) 와 같이 실행되었을때 )

export const getTodos = createRequestAction('todos/GET_TODOS');

// createReducer ( redux-toolkit )
const todoReducer = createReducer(initialState, (builder) =>
  builder
    .addCase(getTodos.SUCCESS, (state, action) => {
      state.todos = action.payload;
    })
);

function* todoSaga() {
  yield all([
    takeEvery(
      getTodos.REQUEST,
      createRequestSaga(getTodos, function* () {
        const response = yield call(api.getTodos);
        return response.data;
      })
    )
  ])
}

// TodoList Component
function TodoList() {
  const dispatch = useDispatch();
  const todos = useSelector((state) => state.todo.todos);
  const loading = useSelector((state) => state.pending[getTodos.type]);
  useEffect(() => {
    // todos 한번 호출
    (!todos || !todos.length) && dispatch(getTodos.request());
  }, []);

  return (
    <>
      <h2>TodoList</h2>
      ...
    </>
  );
}

이렇게 사용하다보면 부족한 느낌이 듭니다. 우리는 해당 api가 호출 되었을때 loading, error의 상태도 알고 싶습니다.
먼저 pendingReducer를 살펴보겠습니다. addDefaultCase 함수를 통해 모든 액션이 아래의 함수를 실행시킬 것입니다.
createRequestAction helper를 통해 todos/GET_TODOS/REQUEST, todos/GET_TODOS/SUCCESS, todos/GET_TODOS/FAILURE action들을 만들었습니다. pendingReducer들에서는 다음 action들에 따라 pending의 상태를 actionName을 키로 하는 object에 저장하게 됩니다.
errorReducer도 pendingReducer와 비슷합니다.
(실제 사용하는 코드와 다르게 간략하게 나타내어 버그가 있을 수 있으나 의미하는 바는 같습니다)

// pending.js
export const pendingReducer = createReducer({}, (builder) =>
  builder.addDefaultCase((state, action) => {
    const { type } = action;
    // actionName -> 'todos/GET_TODOS'
    const actionName = type.split('/').slice(0, -1).join('/'); 
    if (actionName) {
      if (type.endsWith('/REQUEST')) {
        state[actionName] = true;
      } else if (type.endsWith('/SUCCESS') || type.endsWith('/FAILURE')) {
        state[actionName] = false;
      }
    }
  })
);

// error.js
export const errorReducer = createReducer({}, (builder) =>
  builder.addDefaultCase((state, action) => {
    const { type } = action;
    const actionName = type.split('/').slice(0, -1).join('/');
    if (actionName) {
      if (type.endsWith('/REQUEST')) {
        state[actionName] = false;
      } else if (type.endsWith('/FAILURE')) {
        state[actionName] = true;
      }
    }
  })
);

위의 TodoList Component코드를 다시 보게 되면 어떻게 사용하는지 알 수 있습니다.

function TodoList() {
  const dispatch = useDispatch();
  const todos = useSelector((state) => state.todo.todos);
  // action.type을 키로 같음
  const loading = useSelector((state) => state.pending[getTodos.type]);
  useEffect(() => {
    (!todos || !todos.length) && dispatch(getTodos.request());
  }, []);

  return (
    <>
      <h2>TodoList</h2>
      {loading ? (<Loader />) : (...) }
    </>
  );
}

자, 이제 기초설명(?)이 끝났습니다.
이렇게 만족하면서 유용하게 사용하였습니다.
하지만, 현실에서는 Sign in Dialog가 있다고 해보겠습니다. Sign in이 loading되었을 때 Spinner가 돌다가 실패하였을 경우 error toast를 띄워주거나, Sign in이 성공하였을 경우 성공하였다는 toast와 함께 Dialog를 닫아주는 일련의 과정들이 필요합니다.
이런 부분을 전부 redux-saga로 옮겨서 getContext 함수나 위에서 payload와 함께 meta값을 넘겨주어서 처리할 수 있었습니다.
하지만 사실 이런 부분들은 컴포넌트에 있으면 훨씬 로직을 이해하기 쉽습니다. 이런 부분까지 redux-saga에 있다면 실제 컴포넌트에서는 단순히 dispatch하는 것만 보이고 그 컴포넌트에서 일어나는 로직을 이해하기 위해서는 saga를 봐야합니다.

이를 해결하기 위해 일부 간단한 로직에 대해서만 redux-thunk를 도입하기로 하였습니
다.

목표는 2가지가 있었습니다.
1. 기존의 helper(createRequestSaga...)와 redux의 로직을 그대로 이용할 것.
2. dispatch를 await하여 기존에 컴포넌트에서 간단히 로직을 처리할 수 있게 할 것.

이를 위해서 createRequestThunk 함수를 만들었습니다.
createRequestThunk는 Promise로 한번 감싸주는 역할을 하게 됩니다.
그리고 그에 맞춰 createRequestSaga의 로직도 수정이 됩니다.
createRequestThunk에서 같이 넘겨준 resolve, reject를 처리하는 부분의 로직만 추가되게 됩니다.

export function createRequestThunk(actions) {
  return (payload, meta) => (dispatch) => {
    return new Promise((resolve, reject) => {
      dispatch({ ...actions.request(payload, meta), resolve, reject });
    });
  };
}

export function createRequestSaga(actions, saga) {
  return function* (action) {
    try {
      const result = yield call(saga, action);
      yield put(actions.success(result, action.meta));
      action.resolve && action.resolve(result);
    } catch (e) {
      yield put(actions.failure(e, action.meta));
      action.reject && action.reject(e);
    }
  };
}

다시 예시코드로 빠르게 이해해보겠습니다.
아래 코드는 todo를 create할 때의 코드입니다.
기존에 만들었던 actions를 createRequestThunk로 감싸주시만 하면 됩니다.
dispatch를 await 할 수 있으며 그 뒤에 toast를 띄워주는 등의 로직을 처리할 수 있습니다. 이로써 위의 1, 2번째 목표가 해결되는 걸 알 수 있습니다.

export const createTodo = createRequestAction('todos/CREATE_TODO');
export const createTodoThunk = createRequestThunk(createTodo);


// Todo.js
const onSubmit = useCallback(
    async (e) => {
      e.preventDefault();
      try {
        await dispatch(createTodoThunk({ title, content }));
        AppToaster.show({ message: 'todo added.', timeout: 1500 });
      } catch {
        AppToaster.show({ message: 'error: todo added.', timeout: 1500 });
      } finally {
        setTitle('');
        setContent('');
      }
    },
    [content, dispatch, title]
  );

이렇게 하여 간단한 비동기 로직은 Thunk로 처리하게 되었습니다.
그리고 복잡한 비동기 로직은 역시 redux-saga에서 효율적으로 처리하였습니다.
thunk를 많이 사용하면 테스트가 어렵지만 redux-saga는 테스트도 쉽게 작성할 수 있었기 때문에 redux-saga를 주로 이용하지만 간단한 로직에서는 thunk을 이용하여 처리하였습니다.

비동기처리를 위해 redux-saga를 도입했으면서 redux-thunk까지 난잡하게 왜
쓸까 그래도 통일성이 있는게 코드를 파악하기 좋은데...라고 하신다면 그 생각에도 동의합니다.
saga를 이용하면서 이런 고민을 하셨던 분의 의견이나 또 다른 방법이 있다면 알려주시면 감사하겠습니다.💪

profile
알고 싶은게 많은 프론트엔드 개발자입니다.

0개의 댓글