개인공부/TIL(Today I Learned)

모달창 리팩토링하기 (with Redux-saga)

soon327 2021. 7. 15. 02:00

드.디.어
4주프로젝트의 모달창을 리팩토링했다. 😆
아직 모달창을 띄우는 모든 컴포넌트를 리팩토링한건 아니구, 리덕스에 redux-saga를 세팅하고 채팅창 나가기버튼과 회원탈퇴 컴포넌트에 적용해놨다.
아주 잘된다. 속이 시원하다.

리팩토링하면서 공부하고 배운 과정들을 기록해보겠다!!


🧐 리팩토링 왜 한거야?

리팩토링의 목적부터 먼저 말하면 아래와 같다.

모달창을 OPEN할 때 callback함수를 dispatch해서, 모달창의 예/아니오 버튼에 따라 다른 callback 함수를 실행시키자!!

사실, 프로젝트를 진행하면서도 위를 구현해야 했다. 그러나 당시에는 데드라인이 정해져있었기때문에 추가로 redux-middleware를 공부해서 적용할 만한 시간이 없었다.
따라서 방법이 없을까 궁리하다가 아래와 같이 구현했다.

변경전 코드 (회원탈퇴)

withdrawal.tsx

    dispatch(
      openModal({
        type: 'danger',
        text: '정말 탈퇴하시겠습니까? 😢',
        callbackName: 'kakaoWithdrawal',
        callbackData: { config, withdrawalURL },
      }),
    );

modal.tsx

 const okCallback = () => {
    if (callbackName) {
      // OK클릭시 실행할 콜백들 작성
      if (callbackName === 'kakaoWithdrawal') {
        kakaoWithdrawal();
      }

      ...
  }

  const kakaoWithdrawal = () => {
    const {config,withdrawalURL} = callbackData;
    ...
    (회원탈퇴를 위한 코드)
    ...
  }

미들웨어 없이는 action에 함수를 담아 보낼 수 없기때문에 callbackName이라는 string과 callbackData라는 callback함수를 실행시키는데 필요한 data를 객체로 담아 dispatch했다.
이러한 방법은 어찌어찌 기능구현은 됐지만 아래와 같은 문제점들이 있었다.

1. 무거워진 modal 컴포넌트: 콜백함수들이 모두 modal.tsx에 들어가면서 modal 컴포넌트가 굉장히 무거워졌다. 또한 콜백함수들이 콜백함수가 필요한 컴포넌트에 있는게 아니라 modal 컴포넌트에 모여서, 코드의 가독성 또한 해치게되었다.

2. 코드작성의 번거로움: dispatch할 때마다 openModal의 payload에 4~5가지의 값을 넣다보니 type을 설정하는 것도 굉장히 번거로웠고, modal component에 callbackName별로 분기를 계속 해줘야했다.

3. 한계: 계속 사용하다보니 기능적으로도 한계가 있었다. callbackData에 값을 명시적으로 넣을 수 없는, 예를 들어 상태와 관련된 함수들은 callback으로 넘길 수 없었다.

이러한 문제들로 모달창의 리팩토링은 반드시 필요했다!!!


😅 리팩토링 접근 과정

함수를 action에 담아 dispatch해야 했기때문에 redux-middleware를 사용해야 했다.
일단 해보면서 배우자는 생각으로 redux-toolkit에 내장되어있는 redux-thunk를 먼저 사용해서 해결해보려 했다.

redux-thunk와 redux-saga

redux-toolkitcreateAsyncThunk를 사용해서 dispatch된 함수를 실행시키고 이를 slice의 extraReducers에서 pending, fulfilled, rejected 상태에 따라 다른 로직을 실행시킬 수 있음을 배웠다.
서버에 요청을 하는 경우 이러한 thunk를 사용하면 Loading spinner를 띄우거나 에러핸들링을 하는데 굉장히 편리할 것 같다는 생각을 했다.

그러나 전달된 함수를 저장했다가 특정 action이 dispatch 되었을 때만 조건적으로 실행시키는 데에는 redux-thunk보다는 redux-saga가 적절하다는 것을 아래 내용으로 알 수 있었다.

thunk는 절대로 action에 응답을 줄수 없다.
반면 saga는 store를 구독하고 특정 작업이 디스패치될때 saga가 실행되도록 할 수 있다.

따라서 redux-saga를 사용하기 위해 간단히 generator 문법에 대해 공부한 뒤 적용해보기 시작했다.


😎 리팩토링 결과

reducer (redux-toolkit사용)

1) reducer/modalSlice.ts

export const modalSlice = createSlice({
  name: 'modal',
  initialState,
  reducers: {
    openModal: (state, action: PayloadAction<OpenPayload>) => {
      const newState = { ...action.payload, open: true };
      return newState;
    },

    closeModal: () => {
      return initialState;
    },

    // clickConfirm이 dispatch되면 openModal의 callback함수가 실행된다.
    clickConfirm: () => {
      return initialState;
    },
  },
});

export const { openModal, closeModal, clickConfirm } = modalSlice.actions;

export default modalSlice.reducer;

2) reducer/modalSaga.ts

import { takeLatest, race, take } from 'redux-saga/effects';
import { clickConfirm, openModal, closeModal } from './modalSlice';

export function* handleConfirm(action: ReturnType<typeof openModal>) {
  const { confirm } = yield race({ confirm: take(clickConfirm), cancle: take(closeModal) });

  if (confirm) {
    action.payload.onConfirm?.();
  } else {
    action.payload.onCancle?.();
  }
}

export default function* watchOpen() {
  yield takeLatest(openModal.type, handleConfirm);
}

3) reducer/index.ts

... (생략) ...

const sagaMiddleware = createSagaMiddleware();
function* rootSaga() {
  yield all([watchOpen()]);
}

const store = configureStore({
  reducer: rootReducer,
  middleware: [sagaMiddleware],
});

sagaMiddleware.run(rootSaga);

1) modalSlice.ts

  • clickConfirm action을 추가해줬다. closeModal action과 로직이 동일하지만 예/아니오에 따른 다른 함수가 실행되도록 action을 분리해줬다.

2) modalSaga.ts

  • handleConfirm함수는 clickConfirm,closeModal 액션이 실행됐을 때, 값을 confirm,cancle 변수에 할당하고 (cancle변수는 else로 처리하면돼서 구조분해할당에서 생략함) 이에따라 payload에서 받은 onConfirm 또는 onCancle 함수를 실행시키는 함수다.
  • watchOpen함수는 openModal action이 들어오면 위의 handleConfirm을 실행시킨다.
  • 위에서 쓰인 redux-saga의 effects들을 간략히 설명하면 아래와 같다.

    takeLatest: 가장 마지막에 실행된 액션에 대해서만 핸들러를 실행한다. 실행된 모든 액션에대해서 핸들러를 실행시키고 싶다면 takeEvery effects를 사용하면 된다.
    race: effects들을 마치 경주하듯이 동시에 실행시켜놓고 먼저 완료되는 effect가 있으면 다른 effects들을 종료시킨다.
    take: 매개변수로 전달된 액션이 올때까지 블락된 상태로 기다린다.

3) index.ts

  • redux-toolkit을 사용하고 있었기때문에 store에 미들웨어로 sagaMiddleware를 추가함으로써 간단히 세팅을 마쳤다.

Components

1) withdrawal.tsx

    dispatch(
      openModal({
        type: 'danger',
        text: '정말 탈퇴하시겠습니까? 😢',
        onConfirm: confirmCallback,
      }),
    );

2) modal.tsx

const okCallback = () => dispatch(clickConfirm());
const cancleCallback = () => dispatch(closeModal());

1) withdrawal.tsx

  • 이제 해당 컴포넌트에서 callback함수를 직접 payload로 담을 수 있다!!
    예/아니오 버튼을 눌렀을 때, onConfirm/onCancle함수가 실행되는 것이다.👍

2) madal.tsx

  • modal 컴포넌트의 이벤트핸들러가 위의 두줄이 끝이다..!
    기존에는 온갖 if문으로 callbackName에 따라 분기하고, callback을 직접 modal 컴포넌트 안에서 선언해줬어야 했는데 말이다.
    너무 이쁘다.😍

🤗 리팩토링 후기

뿌듯하다.
만약 "redux-middleware에 대해 공부합시다." 라는 생각으로 redux-middleware를 공부했다면 꽤나 지루한 시간이 되었을 지도 모르겠다.

그러나 내가 그 필요성을 느끼고 문제를 해결하기위해 부딪히면서 공부하는 과정은 정말 재밌는 것 같다.
이걸로 redux-thunkredux-saga에 대해서 충분히 공부했다고는 절대 말 못하겠지만, 이 둘을 처음 공부하는 시작으로는 꽤나 괜찮은 시작이었다는 생각이 들었다.
나머지 컴포넌트들의 모달창들도 하나하나 수정해나가야겠다. 끝!


🙏 참고