ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Redux saga] 리덕스 사가에 대해서 (Redux toolkit과 같이 사용해보기)
    Frontend 2020. 4. 12. 12:17

     

     

    요즘 회사에서 예전 레거시 코드를 리팩토링하는 과정에서 Redux/toolkitredux-saga를 처음으로 도입해보았다.

    프로젝트 규모가 그리 크지 않아서 과연 복잡한 초기 세팅과 러닝 커브를 모두 극복할만큼

    redux의 효과가 클 것인가에 대해서 계속 논의했고, 더 큰 프로젝트를 하기 전에

    지금 규모의 프로젝트에서 한 번 도입을 해보고 괜찮다고 생각이 되면 앞으로도 계속 사용하자는 결론이 나왔다.

     

    그리하여 도입한 redux-toolkit과 redux-saga는 생각보다 더더욱 만족스러운 결과를 만들어주었다.

    일단 redux-saga를 도입하고 나서 좋아진 것으로는 아래와 같은 것들이 있다.

     

    1. 테스트 코드 쓰기 매우 편해졌다.
    2. 비동기 로직 흐름을 이해하기가 쉬워졌다.
    3. prop drilling이 줄었다.

     

    그래서 오늘은 redux-saga에 대해 간단히 정리해보려고 한다.

     

    redux-saga란

    redux-saga는 어플리케이션의 사이드 이펙트(데이터 fetch와 같은 비동기 로직이나 브라우저 캐시에 접근하는 것과 같은 순수하지 않은 것들)를 더 효과적으로 관리하려고 만들어졌다. 즉, 효과적으로 실행하고, 쉽게 테스트하고, 쉽게 에러 핸들링을 하자!는 목적으로 만들어졌다.

     

    그래서 saga는 어플리케이션에서 오로지 사이드 이펙트에만 반응하도록 만들어진 별도 쓰레드와 같다고 할 수 있다. redux-saga는 redux 미들웨어로, 보통의 리덕스 액션으로 시작되고, 중단되며, 취소될 수 있다. 또한 redux 어플리케이션의 모든 상태 값에 접근할 수 있고, redux 액션들을 dispatch할 수도 있다. 

     

    redux-saga는 비동기 플로우를 쉽게 읽고, 쓰고, 테스트할 수 있도록 ES6의 Generator라는 개념을 사용한다. 이 Generator를 차용한 덕분에 비동기 코드가 마치 스탠다드한 동기 코드처럼 보여진다. (마치 async/await과 같지만 더 멋진 점들이 많다.) redux-thunk와 다르게 콜백 지옥에 빠질 일도 없고, 비동기 로직을 쉽게 테스트할 수 있으며, 액션들을 순수한 상태로 둘 수 있다.

     

     

    Example

    그럼 이 비동기 로직을 어떻게 처리하는지 예제 코드를 통해서 살펴보려고 한다.

    먼저 https://github.com/wtjs/what-the-splash repo의 코드를 clone받아서 saga를 연습하는데 사용하였다.

    이 repo는 무료 이미지를 가져올 수 있는 unsplash의 API로 이미지들 배열을 fetch해서 화면에 뿌려주는 매우 간단한 리액트 페이지이다.

     

    import React, { Component } from 'react';
    
    import './styles.css';
    
    const key = '5f96323678d05ff0c4eb264ef184556868e303b32a2db88ecbf15746e6f25e02';
    
    class ImageGrid extends Component {
      state = {
        images: [],
      };
    
      componentDidMount() {
        fetch(`https://api.unsplash.com/photos/?client_id=${key}&per_page=28`)
          .then(res => res.json())
          .then(images => {
            this.setState({
              images,
            });
          });
      }
    
      render() {
        const { images } = this.state;
        return (
          <div className="content">
            <section className="grid">
              {images.map(image => (
                <div
                  key={image.id}
                  className={`item item-${Math.ceil(
                    image.height / image.width,
                  )}`}
                >
                  <img
                    src={image.urls.small}
                    alt={image.user.username}
                  />
              </div>
              ))}
            </section>
          </div>
        );
      }
    }
    
    export default ImageGrid;

     

    즉, 위와 같이 componentDidMount에서 fetch 함수를 사용하여 이미지를 가져오고 이미지들을 화면에 뿌려주는 것이 전부이다.

    일단 위 repo가 꽤 오래전에 만들어진 repo여서 class component를 사용하고 있는데,

    전부 functional component로 바꾸고 redux-toolkit과 saga를 이용해서 바꿔보려고 한다.

     

    src/api/

    src/components/

    src/features/

        ImageGrid/

            index.js

            slice.js

            saga.js

    src/store/

    src/App.js

    src/index.js

     

    폴더 디렉토리 구조는 위와 같이 변경할 예정이다.

     

    예전에는 actions, reducers, constants 등등 각 기능 별로 폴더를 나누는 것을 권장했다면,

    redux-toolkit에서 actions와 reducer등을 한 방에 만들어주는 redux-toolkit의 createSlice가 나오면서

     

    features/ 폴더에 각 화면 별로 폴더를 나누고 그 폴더 안에

    관련 컴포넌트 파일, actions와 reducer가 들어있는 slice.js 파일, 그리고 saga.js 파일을 한꺼번에 묶는 것이 권장되고 있다.

     

    (이러한 패턴을 ducks 패턴이라고 한다.)

     

     

    src/features/ImageGrid/slice.js

    import { createSelector, createSlice } from '@reduxjs/toolkit';
    
    const initialState = {
      isLoading: false,
      images: [],
      error: null
    };
    
    const reducers = {
      load: (state) => {
        state.isLoading = true;
      },
      loadSuccess: (state, { payload: images }) => {
        state.isLoading = false;
        state.images = images;
      },
      loadFail: (state, { payload: error }) => {
        state.isLoading = false;
        state.error = error;
      }
    }
    
    const name = 'UNSPLASH';
    const slice = createSlice({
      name, initialState, reducers
    });
    
    const selectAllState = createSelector(
      state => state.isLoading,
      state => state.images,
      state => state.error,
      (isLoading, images, error) => {
        return { isLoading, images, error };
      }
    );
    
    export const unsplashSelector = {
      all: state => selectAllState(state[UNSPLASH])
    };
    
    export const UNSPLASH = slice.name;
    export const unsplashReducer = slice.reducer;
    export const unsplashAction = slice.actions;
    

     

    slice.js 파일을 보면, UNSPLASH라는 이름으로 createSlice를 이용해 slice를 생성하고,

    그 slice 객체에 자동으로 생성된 name과 reducer, actions 등을 export해주고 있다.

     

     

    src/features/ImageGrid/saga.js

    import { call, put, takeLatest } from 'redux-saga/effects';
    import { getSplashImage } from '../../api';
    import { unsplashAction } from './slice';
    
    function* handleImageLoad() {
      const { loadSuccess, loadFail } = unsplashAction;
      try {
        const images = yield call(getSplashImage);
    
        yield put(loadSuccess(images));
      } catch (err) {
        yield put(loadFail(err));
      }
    }
    
    export function* watchUnsplash() {
      const { load } = unsplashAction;
    
      yield takeLatest(load, handleImageLoad);
    }
    

     

    이제 제일 중요한 saga 로직을 살펴보자.

     

    먼저 saga.js에는 보통 2개의 generator 함수가 선언되는데,

    하나는 보통 watch~라는 이름으로 생성되는 함수이다.

     

    watchUnsplash함수에서는 takeLatest라는 함수 안에 load 액션 함수를 인자로 넣고,

    handleImageLoad 함수를 두 번째 인자로 넣는다.

     

    그러면 이제 load 액션이 dispatch되는 순간 saga에서 그 액션을 take한 후,

    handleImageLoad generator 함수를 실행시킨다.

     

     

    그럼 handleImageLoad 함수를 살펴보자.

     

    이 함수는 getSplashImage라는 api 함수를 call이라는 saga effect 함수에 넣은 후 yield 시키고 있다.

    그러면 saga 미들웨어에서 알아서 getSplashImage라는 비동기 액션을 실행시킨 후 그 결과물을 images라는 변수에 담아준다.

     

    그 후 put이라는 effect 함수에 loadSuccess 액션 객체를 넣고 있는데,

    이 때 위에서 fetch 결과물로 받은 images 배열을 인자로 넣어주고 있다.

    이 인자는 action 객체의 payload 속성으로 들어가며 위 slice.js에서 생성된 reducer에 의해 새로운 상태값으로 업데이트된다.

     

    try catch문을 사용하여 만약에 에러가 catch된다면

    loadFail이라는 액션 객체를 생성하여 put 이펙트 함수에 넣어주면 된다.

     

    너무나도 간단하게 비동기 로직이 정리되며, 마치 비동기가 아닌 동기 코드처럼 보여진다(!)

     

     

    이제 이렇게 생성된 saga와 slice들을 연결을 해주어야 동작하게 된다.

     

     

    src/store/index.js

    import { combineReducers } from '@reduxjs/toolkit';
    import { configureStore } from '@reduxjs/toolkit';
    import createSagaMiddleware from 'redux-saga';
    
    import { all } from 'redux-saga/effects';
    import { UNSPLASH, unsplashReducer } from '../features/ImageGrid/slice';
    import { watchUnsplash } from '../features/ImageGrid/saga';
    
    export const rootReducer = combineReducers({
      [UNSPLASH]: unsplashReducer,
    });
    
    const sagaMiddleware = createSagaMiddleware();
    function* rootSaga() {
      yield all([
        watchUnsplash(),
      ])
    }
    
    const createStore = () => {
      const store = configureStore({
        reducer: rootReducer,
        devTools: true,
        middleware: [sagaMiddleware]
      });
    
      sagaMiddleware.run(rootSaga);
    
      return store;
    }
    
    export default createStore;
    

     

    위와 같이 store를 생성하는 함수 createStore를 만들어주었다.

    이 함수는 redux/toolkit에서 제공해주는 configureStore라는 함수를 이용하여 store를 생성한다.

    그리고 sagaMiddleware.run() 을 통해 saga 미들웨어를 실행시킨다.

     

    이렇게 만든 createStore 함수를 이용해 App.js에서 store를 생성한 후, Provider 컴포넌트에 prop으로 내려줌으로써 모든 설정이 끝난다.

     

     

    src/App.js

    import React, { Component } from 'react';
    import { Provider } from 'react-redux';
    import createStore from './store';
    
    import Header from './components/Header';
    import ImageGrid from './features/ImageGrid';
    
    const store = createStore();
    
    class App extends Component {
      render() {
        return (
          <Provider store={store}>
            <div>
              <Header />
              <ImageGrid />
            </div>
          </Provider>
        );
      }
    }
    
    export default App;
    

     

     

    src/features/ImageGrid/index.js

    import React, { useEffect } from 'react';
    import { useSelector } from 'react-redux';
    import { useDispatch } from 'react-redux';
    import Loader from '../../components/Loader';
    import ErrorView from '../../components/ErrorView';
    import { unsplashAction, unsplashSelector } from './slice';
    import './styles.css';
    
    const ImageGrid = () => {
      const dispatch = useDispatch();
      const { isLoading, images, error } = useSelector(unsplashSelector.all);
    
      useEffect(() => {
        const { load } = unsplashAction;
    
        dispatch(load());
      }, []);
    
      if (isLoading) {
        return <Loader />;
      }
    
      if (error) {
        return <ErrorView />;
      }
    
      return (
        <div className="content">
          <section className="grid">
            {images.map(image => (
              <div
                key={ image.id }
                className={`item item-${Math.ceil(
                  image.height / image.width,
                )}`}
              >
                <img
                  src={ image.urls.small }
                  alt={ image.user.username }
                />
            </div>
            ))}
          </section>
        </div>
      );
    };
    
    export default ImageGrid;
    

     

    이제 ImageGrid 컴포넌트에서 load 액션을 dispatch하면 

    사가 미들웨어의 handleImageLoad가 실행되어 비동기 fetch가 일어나고

    그 결과물이 업데이트되어 컴포넌트가 다시 리렌더가 된다.

     

    업데이트된 상태값들은 selector를 이용해 꺼내 쓴다.

     

     


     

    위 코드는 github에 올려두었다.

     

    practice-saga 브랜치에는 redux/toolkit을 사용하지 않고 

    액션, 리듀서 등을 일일이 따로 만들어준 후 redux-saga를 사용한 버전이 들어있다.

     

    redux-toolkit 브랜치에는 practice-saga에서 만든 결과물에 redux/toolkit을 도입해서 

    간단한 구조로 리팩토링한 코드가 들어있다.

     

     

    반응형

    COMMENT