• 리액트 리덕스 툴킷 - 리덕스 사가 (React + Redux Toolkit + Redux Saga + TypeScript) - with Next.js

    2021. 3. 8.

    by. kimyang-Sun

    리덕스 (Redux)

     

    리덕스 툴킷으로 리덕스 사가를 이용해보려고 합니다.

    리액트 프로젝트를 하며 리덕스 툴킷을 이용하면 좋은 점은 액션 함수와 액션 타입들을 한번에 모아주는 createSlice가 있습니다.

    이렇게 createSlice를 사용하며 필요한 기능을 구현할때 서버에서 데이터를 받아와야하는 경우에 Redux Thunk나 Redux Saga를 이용합니다.

     

    리덕스 툴킷에 Redux Thunk는 createAsyncThunk라는 툴킷의 자체 기능이 있지만 Saga는 현재 시점에서 아직까지

    그런 기능은 없어서 평소에 사용하는 Redux Saga를 그대로 적용시켜 사용해야 합니다.

     

    이전 글에서 createSlice의 사용방법을 적었었습니다. 해당 링크는 Saga를 사용하지 않은 기본 상태관리 글입니다.

    kimyang-sun.tistory.com/entry/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A6%AC%EB%8D%95%EC%8A%A4-%ED%88%B4%ED%82%B7%EC%9C%BC%EB%A1%9C-%EC%83%81%ED%83%9C%EA%B4%80%EB%A6%AC-React-Redux-Toolkit

     

    리액트 리덕스 툴킷으로 상태관리하기 (React + Redux Toolkit + TypeScript)

    리액트 프로젝트를 진행하며 상태관리시에 프로젝트 규모가 작으면 Context API를 이용하고 그 외에 경우에는 Redux 또는 Mobx를 많이 사용하는것 같습니다. 리덕스는 액션 타입, 액션 실행함수, 리듀

    kimyang-sun.tistory.com

     

    이제 시작해보겠습니다.

     

    // store/modules/user.ts
    
    import { createSlice, PayloadAction } from '@reduxjs/toolkit';
    
    // 초기 상태 타입
    export type UserState = {
      userLoading: boolean;
      userData: any;
      error: any;
    };
    
    // 액션 Payload 타입
    export type LoginPayload = {
      userId: string;
      password: string;
    };
    
    // 초기 상태
    const initialState: UserState = {
      userLoading: false,
      userData: null,
      error: null,
    };
    
    // 리듀서 슬라이스
    const userSlice = createSlice({
      name: 'user',
      initialState,
      reducers: {
        // Login
        loginRequest(
          state: UserState,
          _action: PayloadAction<LoginRequestPayload>
        ) {
          state.userLoading = true;
          state.error = null;
        },
    
        loginSuccess(state: UserState, action: PayloadAction<UserDataPayload>) {
          state.userLoading = false;
          state.userData = action.payload;
        },
    
        loginFailure(state: UserState, action: PayloadAction<{ error: any }>) {
          state.userLoading = false;
          state.error = action.payload;
        },
        
        
        // Logout
        logoutRequest(state: UserState) {
          state.userLoading = true;
          state.error = null;
        },
    
        logoutSuccess(state: UserState) {
          state.userLoading = false;
          state.userData = null;
        },
    
        logoutFailure(state: UserState, action: PayloadAction<{ error: any }>) {
          state.userLoading = false;
          state.error = action.payload;
        },
      },
    });
    
    // 리듀서 & 액션 리턴
    const { reducer, actions } = userSlice;
    export const {
        loginRequest,
        loginSuccess,
        loginFailure,
        logoutRequest,
        logoutSuccess,
        logoutFailure
        } = actions;
    export default reducer;

     

    Saga를 사용하기 위해 코드를 변환시켰습니다.

    이전 글의 코드에서 isLoggedIn을 없앴습니다. 이유는 isLoggedIn을 넣는것보다 그냥 userData가 존재하면

    그 자체로 로그인이 되어있는 상태라는걸 알 수 있기에 그냥 없애줬습니다.

    이전 코드는 비동기로 작업할 필요가 없었기에 loginAction과 logoutAction 두개만 존재했는데 이제는 비동기 작업을 위한

    Request, Success, Failure 를 각각 만들어줬습니다.

     

    // store/modules/userHook.ts
    
    import { useCallback } from 'react';
    import { useDispatch, useSelector } from 'react-redux';
    import { RootState } from '.';
    import { loginAction, LoginPayload, logoutAction } from './user';
    
    // 커스텀 훅
    export default function useUser() {
      const { userLoading } = useSelector((state: RootState) => state.user);
      const dispatch = useDispatch();
      
      const login = useCallback((data: LoginPayload) => {
        dispatch(loginRequest(data));
      }, []);
      
      const logout = useCallback(() => {
        dispatch(logoutRequest());
      }, []);
    
      return { userLoading, login, logout };
    }

     

    커스텀 훅 userHook에서 로그인, 로그아웃을 각각 Request 함수로 바꿔줍니다.

     

    이제 Saga 디렉토리를 따로 만들어 그 안에 index.ts와 userSaga.ts를 만들어주겠습니다.

     

    // saga/userSaga.ts
    
    import { PayloadAction } from '@reduxjs/toolkit';
    import axios from 'axios';
    import { call, delay, put, takeLatest } from 'redux-saga/effects';
    import {
      LoginRequestPayload,
      loginRequest,
      loginFailure,
      loginSuccess,
      logoutFailure,
      logoutRequest,
      logoutSuccess,
    } from 'store/modules/user';
    
    // API 요청
    function loginAPI(data: LoginRequestPayload) {
      // axios를 이용하여 데이터를 불러옵니다. (예시)
      return axios.post('api/login', data);
    }
    
    function logoutAPI(data: LoginRequestPayload) {
      // logout도 예시로 작성해주었습니다.
      return axios.post('api/login', data);
    
    }
    
    // Saga 실행 함수
    // 여기서는 밑에 loginRequest의 액션이 인자로 들어옵니다.
    function* login(action: PayloadAction<LoginRequestPayload>) {
      try {
        // fork는 비동기 call은 동기
        // fork를 쓰면 불러온것들을 result에 넣어줘야 하는데 바로 다음코드가 실행됨
        const result = yield call(loginAPI, action.payload);
        //요청 성공시
        yield put(loginSuccess(result));
      } catch (e) {
        // 요청 실패시
        yield put(loginFailure(e.response.data));
      }
    }
    
    function* logout() {
      try {
        const result = yield call(logoutAPI);
        yield put(logoutSuccess());
      } catch (e) {
        yield put(logoutFailure(e.response.data));
      }
    }
    
    // Watch 함수
    export function* watchLogin() {
      yield takeLatest(loginRequest.type, login);
      // loginRequest에서의 type이 실행되면 login함수가 실행되는데
      // loginRequest의 action이 있으면 그 액션이 login함수의 인자로 들어갑니다.
    }
    
    export function* watchLogout() {
      yield takeLatest(logoutRequest.type, logout);
    }

     

    Saga는 Generator함수를 이용합니다 (function 뒤에 *)

    yield를 이용하여 비동기작업을 해줄 수 있습니다.

    작동원리는 위의 코드에서 주석으로 적어놓았습니다.

    watch 함수에서는 takeLatest를 사용해주었는데 이와 유사한 다양한 기능들이 많습니다.

     

    take = 이벤트 리스너같은 역할, 치명적인 단점, 일회용

    한번 실행시키면 한번밖에 받지않아서 그 다음에 다시 실행시키면 이벤트가 사라져서 안됩니다.

    해결하기 위해서는 while (true) {yield take()} 로 감싸주면 되는데 직관적이지 않아서 보통 takeEvery를 씁니다.

    takeEvery와의 차이점은 while take는 동기적으로 동작하고 takeEvery는 비동기로 동작합니다.

     

    takeEvery = 들어오는 이벤트를 실행시킵니다. 여기서도 단점이 있는데 가끔 한번에 두번이 클릭되면

    그 두번을 모두 실행시켜버립니다.

     

    takeLastest = 마지막으로 들어온 이벤트를 실행시킵니다. 이미 완료된 거 제외하고

    한번에 여러개가 들어오거나 하면 완료되지 않은 이전의 것들을 없애고 마지막을 실행시킵니다.

     

    takeleading = 처음으로 들어온 이벤트를 실행시킵니다.

    한번에 여러개가 들어왔을때 처음것이 아직 완료되기 전이면 이후의 것들은 없앱니다.

     

    그러나 takeLastest, takeLeading은 프론트서버에서만 그렇게 적용되고 백엔드서버에서는

    여러번의 요청에 대해 모두 실행되어 여러번 저장됩니다.

    그래서 백엔드에서도 검사를 해주어야 합니다.

    하지만 보통은 이걸 사용하고 서버쪽에서 검증를 하는 방법을 많이 씁니다.

     

    throttle = 마지막 인자로 시간을 넣어주면 그 시간동안에는 

    여러번의 요청이 들어와도 무조건 한번만 실행됩니다. 이건 백엔드서버에서도 한번만 요청됩니다.

     

    debouncing = 연이어 호출되는 함수들 중 특정 시간동안 마지막 함수(또는 제일 처음)만 호출되도록 합니다.

     

    이제 index.ts를 작성하겠습니다.

     

    // saga/index.ts
    
    import { all, fork } from 'redux-saga/effects';
    import {
      watchLogin,
      watchLogout,
    } from './userSaga';
    
    // rootSaga를 만들어줘서 store에 추가해주어야 합니다.
    export default function* rootSaga() {
      yield all([
        fork(watchLogin),
        fork(watchLogout),
      ]);
    }

     

    이제 작성된 rootSaga를 configureStore.ts에 적용시키겠습니다.

     

    import { configureStore } from '@reduxjs/toolkit';
    import { createWrapper } from 'next-redux-wrapper';
    import rootReducer from 'store/modules';
    import createSagaMiddleware, { Task } from 'redux-saga';
    import { Store } from 'redux';
    import rootSaga from 'sagas';
    
    // Next Redux Toolkit Saga를 사용할때는
    // confugureStore에서 강제로 sagaTask를 만들어주기 위함
    interface SagaStore extends Store {
      sagaTask?: Task;
    }
    
    const store = () => {
      const devMode = process.env.NODE_ENV === 'development'; // 개발모드
      const sagaMiddleware = createSagaMiddleware();
      const store = configureStore({
        reducer: rootReducer,
        middleware: [sagaMiddleware],
        devTools: devMode,
      });
      
      // Next Redux Toolkit 에서 saga를 사용해야할 때
      (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga);
      
      return store;
    };
    
    const wrapper = createWrapper(store, {
      // 이 부분이 true면 디버그때 자세한 설명이 나옵니다. (개발할때는 true로)
      debug: process.env.NODE_ENV === 'development',
    });
    
    export default wrapper;

     

    sagaMiddleware를 불러와서 추가해주시면 되는데 Next.js를 이용하게되면 sagaTask를 지정해주어야 합니다.

     

    export default wrapper.withRedux(withReduxSaga(App));

     

    해당 테스트는 Next.js를 사용했기 때문에 _app.tsx 파일에서 withReduxSaga를 하나 더 추가해서 App을 감싸주었습니다.

    (추후에 없애줄 수도 있습니다.)

     

    const { userLoading, login, logout } = useUser();

     

    이제 똑같이 이런식으로 커스텀훅으로 불러와서 사용할곳에서 사용해주시면 됩니다.

    댓글