Redux 미들웨어 적용하기 (Thunk, Saga)

엄현태·2020년 3월 11일
5

React-redux

목록 보기
2/2
post-thumbnail

지난 포스팅에서 React 프로젝트에 Redux 를 적용해보았습니다.

> React 프로젝트에 Redux 적용하기

이번 포스팅에서는 Redux 에서 비동기 처리를 위해 많이 쓰이는 미들웨어 중 Thunk, Saga를 둘 다 적용해 보고 이를 비교해보도록 하겠습니다.

이에 대하여 비교해놓은 글은 정말 많습니다.
대쵸적으로 몇개 가져와 보면...

요정도 글이 도움이 될것 같네요. 그럼 저는 직접 적용해보는 포스팅으로 진행해보겠습니다.

Redux-thunk

의존 모듈을 설치해줍니다.

$ npm i redux-thunk

src/App.js

import React from 'react';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import ReduxThunk from 'redux-thunk';

import './App.css';

// Redux & Redux-Thunk test
import CountComponent from './components/reduxThunkTest/Count';
import CountFCComponent from './components/reduxThunkTest/CountFC';

import reducers from './reducers';

const store = createStore(reducers, applyMiddleware(ReduxThunk));

function App() {
  return (
    <Provider store={store}>
      <div className="App">
        <CountComponent />
        <CountFCComponent />
      </div>
    </Provider>
  );
}

export default App;

정말 간단하죠? ReduxThunk 를 applyMiddleware 에 넣어주고 createStore 를 해주면 됩니다.

마지막으로 action을 수정해주어야하는데
redux-thunk 는 다음과 같은 형식을 가지는 action creator 가 필요합니다.

const thunkActionCreator = (parameters) => (dispatch, getState) => {
	// Code
}

actions/count.js

// Action types
export const INCREASE_COUNT = 'count/INCREASE_COUNT';
export const DECREASE_COUNT = 'count/DECREASE_COUNT';

export const SET_COUNT = 'count/SET_COUNT';

// Action creators
export const increaseCount = () => {
  return {
    type: INCREASE_COUNT,
  }
};

export const decreaseCount = () => {
  return {
    type: DECREASE_COUNT,
  }
};

export const setCount = () => {
  return {
    type: SET_COUNT,
  }
};

// Action creators for thunk
export const increaseCountAsyncThunk = () => (dispatch, getState) => {
  setTimeout(() => {
    dispatch(increaseCount());
  }, 1000);
};

export const decreaseCountAsyncThunk = () => (dispatch, getState) => {
  setTimeout(() => {
    dispatch(decreaseCount());
  }, 1000);
};

비동기적으로 코드를 작성하였는데 컴포넌트 단에서 increaseCountAsyncThunk, decreaseCountAsyncThunk 를 dispatch 하면 timer 가 시작되고 1초 후에 increaseCount 또는 decreaseCount 를 dispatch 해줍니다. 따라서 이 안에서 여러가지 비동기 처리를 통하여 기다렸다가 필요한 action을 dispatch 해 줄 수 있게 되죠. 이러한 점 때문에 많이들 비동기를 처리하기 위해 사용하게 됩니다.
조금 더 직관적으로 코드를 구성해보겠습니다.

export const requestGetData = (url) => async (dispatch, getState) => {
    dispatch({
    	type: ACTION_REQUEST
    });
    try {
    	const res = await axios.get(url);
        dispatch({
            type: ACTION_SUCCESS,
        })
    } catch (error) {
    	dispatch({
            type: ACTION_FAILURE
        })
    }
};

이런식으로 컴포넌트 단에서 requestGetData 를 dispatch 하면 ACTION_REQUEST를 dispatch 시켜주고 통신 응답이 오면 결과에 따라 ACTION_SUCCESS 또는 ACTIO_FAILURE 를 해주어 통신의 한 플로우를 완성 시킬 수 있습니다.

컴포넌트를 수정해보겠습니다.
components/Count.jsx

import React from 'react';
import { connect } from 'react-redux';

import * as actions from '../../actions/count';

class Count extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      localCount: 0,
    }
  }

  componentDidMount() {
    console.log('Component did mount.');
  }

  increaseCount = () => {
    this.setState((prevState) => {
      return {
        localCount: prevState.localCount + 1,
      }
    });
  };

  decreaseCount = () => {
    this.setState((prevState) => {
      return {
        localCount: prevState.localCount - 1,
      }
    });
  };

  render () {
    const { localCount } = this.state;
    const { storeCount,
            increaseStoreCount, decreaseStoreCount,
            increaseCountAsyncThunk, decreaseCountAsyncThunk,
    } = this.props;

    return (
      <>
        {'Class component'}
        <div>
          <div>
            {`localCount: ${localCount}`}
          </div>
          <div>
            {`storeCount: ${storeCount}`}
          </div>
        </div>
        <div onClick={() => {
          this.increaseCount();
          increaseStoreCount();
        }}>
          {'+'}
        </div>
        <div onClick={() => {
          this.decreaseCount();
          decreaseStoreCount();
        }}>
          {'-'}
        </div>
        <div onClick={increaseCountAsyncThunk}>
          {'Increase count async thunk'}
        </div>
        <div onClick={decreaseCountAsyncThunk}>
          {'Decrease count async thunk'}
        </div>
      </>
    )
  }
}

const mapStateToProps = (state) => ({
  storeCount: state.count.count,
});

const mapDispatchToProps = (dispatch) => ({
  increaseStoreCount: () => dispatch(actions.increaseCount()),
  decreaseStoreCount: () => dispatch(actions.decreaseCount()),
  increaseCountAsyncThunk: () => dispatch(actions.increaseCountAsyncThunk()),
  decreaseCountAsyncThunk: () => dispatch(actions.decreaseCountAsyncThunk()),
});

export default connect(mapStateToProps, mapDispatchToProps)(Count);

함수형 컴포넌트도 함께 작성해 보겠습니다.

components/CountFC.jsx

import React, { useEffect, useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';

import * as actions from '../../actions/count';

const CountFC = () => {
  const dispatch = useDispatch();
  const [localCount, setLocalCount] = useState(0);

  const { count: storeCount } = useSelector((state) => state.count);

  useEffect(() => {
    console.log('Component did mount.');
  }, []);

  const increaseCount = useCallback(() => {
    setLocalCount(localCount + 1);
    dispatch(actions.increaseCount());
  }, [localCount, dispatch]);

  const decreaseCount = useCallback(() => {
    setLocalCount(localCount - 1);
    dispatch(actions.decreaseCount());
  }, [localCount, dispatch]);

  const increaseCountAsyncThunk = useCallback(() => {
    dispatch(actions.increaseCountAsyncThunk());
  }, [dispatch]);

  const decreaseCountAsyncThunk = useCallback(() => {
    dispatch(actions.decreaseCountAsyncThunk());
  }, [dispatch]);

  return (
    <>
      {'Function component'}
      <div>
        <div>
          {`localCount: ${localCount}`}
        </div>
        <div>
          {`storeCount: ${storeCount}`}
        </div>
      </div>
      <div onClick={increaseCount}>
        {'+'}
      </div>
      <div onClick={decreaseCount}>
        {'-'}
      </div>
      <div onClick={increaseCountAsyncThunk}>
          {'Increase count async thunk'}
        </div>
        <div onClick={decreaseCountAsyncThunk}>
          {'Decrease count async thunk'}
        </div>
    </>
  )
};

export default CountFC;

각각의 컴포넌트에 Increase count async thunk, Decrease count async thunk 를 누르게 되면 1초 후에 반영되는것을 확인해 볼 수 있습니다.

Redux-saga

같은 방식으로 saga를 적용해보겠습니다.
먼저 의존 모듈을 설치해야겠죠?

$ npm i redux-saga

thunk 와 다르게 saga 를 만들어 주어야합니다. 저는 보통 reducers와 같은 포맷으로 폴더구조를 잡아줍니다.

saga에 대한 자세한 설명은 따로 하지는 않겠습니다.

sagas/index.js

import { all, fork } from 'redux-saga/effects';

import count from './count';

export default function* rootSaga() {
  yield all([
    fork(count),
  ]);
}

sagas/count.js

import { all, delay, fork, put, takeEvery } from 'redux-saga/effects';

import * as countActions from '../actions/count';

function* increaseCountAsync() {
  yield delay(1000);
  yield put({
    type: countActions.INCREASE_COUNT,
  });
}

function* decreaseCountAsync() {
  yield delay(1000);
  yield put({
    type: countActions.DECREASE_COUNT,
  });
}

function* watchCount() {
  yield takeEvery(countActions.INCREASE_COUNT_ASYNC, increaseCountAsync);
  yield takeEvery(countActions.DEFREASE_COUNT_ASYNC, decreaseCountAsync);
}

export default function* countSaga() {
  yield all([
    fork(watchCount),
  ])
};

위 코드처럼 index.js 를 만들어 주고 count.js 로 count saga 도 만들어줍니다. root (index.js) 에서는 count saga를 yield all 를 통해서 돌려주고 count 에서는 countSaga 안에 watchCount를 yield all 해서 실행시켜 줍니다.
watchCount 에서는 takeEvery 통하여 action type에 대한 응답 함수를 정의해줍니다.

각각의 increaseCountAsync, decreaseCountAsync 함수는 위에 thunk 와 마찬가지로 1초 뒤 (yield delay(1000)) 에 INCREASE_COUNT, DECREASE_COUNT action 을 dispatch 해줍니다.

그 다음 원래 없었던 INCREASE_COUNT_ASYNC, DEFREASE_COUNT_ASYNC type을 action 에 정의해주어야합니다.

actions/count.js

// Action types
export const INCREASE_COUNT = 'count/INCREASE_COUNT';
export const DECREASE_COUNT = 'count/DECREASE_COUNT';

export const SET_COUNT = 'count/SET_COUNT';

// Action creators
export const increaseCount = () => {
  return {
    type: INCREASE_COUNT,
  }
};

export const decreaseCount = () => {
  return {
    type: DECREASE_COUNT,
  }
};

export const setCount = () => {
  return {
    type: SET_COUNT,
  }
};

// Action creators for thunk
export const increaseCountAsyncThunk = () => (dispatch, getState) => {
  setTimeout(() => {
    dispatch(increaseCount());
  }, 1000);
};

export const decreaseCountAsyncThunk = () => (dispatch, getState) => {
  setTimeout(() => {
    dispatch(decreaseCount());
  }, 1000);
};

// Action types for saga
export const INCREASE_COUNT_ASYNC = 'count/INCREASE_COUNT_ASYNC';
export const DEFREASE_COUNT_ASYNC = 'count/DECREASE_COUNT_ASYNC';

// Action creators for saga
export const increaseCountAsyncSaga = () => {
  return {
    type: INCREASE_COUNT_ASYNC,
  }
};

export const decreaseCountAsyncSaga = () => {
  return {
    type: DEFREASE_COUNT_ASYNC,
  }
}

밑에 보면 두가지 action type 이 추가 되었고 그에 맞는 action creators 가 추가 된 것을 보실 수 있습니다.
그럼 컴포넌트 단 에서는 increaseCountAsyncSaga, decreaseCountAsyncSaga 이 두 action creator 를 불러주면 될것 같습니다.

기존에 thunk 를 사용할 때에는 increaseCountAsyncThunk, decreaseCountAsyncThunk 이 두 action creator 를 불러주었습니다.

마지막으로 saga 역시 App.js에 미들웨어로 추가를 해주어야합니다.

src/App.js

import React from 'react';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import ReduxThunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';

import './App.css';

// Redux & Redux-Thunk test
import CountComponent from './components/reduxThunkTest/Count';
import CountFCComponent from './components/reduxThunkTest/CountFC';

import reducers from './reducers';
import sagas from './sagas';

const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducers, applyMiddleware(ReduxThunk, sagaMiddleware));

sagaMiddleware.run(sagas);

function App() {

  return (
    <Provider store={store}>
      <div className="App">
        <CountComponent />
        <CountFCComponent />
      </div>
    </Provider>
  );
}

export default App;

위의 코드에서 createSagaMiddleware 를 통하여 sagaMiddleware 를 만들어주고,
이를 applyMiddlewareReduxThunk 와 같이 넣어주었습니다.
한가지 특이한 점은 아까 만든 sagas 를 sagaMiddleware.run 을 통하여 실행시켜주어야합니다.

그렇게 되면 saga 가 동작하고 컴포넌트 단에서 아까 만든 action creator 인 increaseCountAsyncSaga, decreaseCountAsyncSaga 들을 불러주면 됩니다.

마지막으로 컴포넌트를 수정해보도록 하겠습니다.
components/Count.jsx

import React from 'react';
import { connect } from 'react-redux';

import * as actions from '../../actions/count';

class Count extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      localCount: 0,
    }
  }

  componentDidMount() {
    console.log('Component did mount.');
  }

  increaseCount = () => {
    this.setState((prevState) => {
      return {
        localCount: prevState.localCount + 1,
      }
    });
  };

  decreaseCount = () => {
    this.setState((prevState) => {
      return {
        localCount: prevState.localCount - 1,
      }
    });
  };

  render () {
    const { localCount } = this.state;
    const { storeCount,
            increaseStoreCount, decreaseStoreCount,
            increaseCountAsyncThunk, decreaseCountAsyncThunk,
            increaseCountAsyncSaga, decreaseCountAsyncSaga,
    } = this.props;

    return (
      <>
        {'Class component'}
        <div>
          <div>
            {`localCount: ${localCount}`}
          </div>
          <div>
            {`storeCount: ${storeCount}`}
          </div>
        </div>
        <div onClick={() => {
          this.increaseCount();
          increaseStoreCount();
        }}>
          {'+'}
        </div>
        <div onClick={() => {
          this.decreaseCount();
          decreaseStoreCount();
        }}>
          {'-'}
        </div>
        <div onClick={increaseCountAsyncThunk}>
          {'Increase count async thunk'}
        </div>
        <div onClick={decreaseCountAsyncThunk}>
          {'Decrease count async thunk'}
        </div>
        <div onClick={increaseCountAsyncSaga}>
          {'Increase count async saga'}
        </div>
        <div onClick={decreaseCountAsyncSaga}>
          {'Decrease count async saga'}
        </div>
      </>
    )
  }
}

const mapStateToProps = (state) => ({
  storeCount: state.count.count,
});

const mapDispatchToProps = (dispatch) => ({
  increaseStoreCount: () => dispatch(actions.increaseCount()),
  decreaseStoreCount: () => dispatch(actions.decreaseCount()),
  increaseCountAsyncThunk: () => dispatch(actions.increaseCountAsyncThunk()),
  decreaseCountAsyncThunk: () => dispatch(actions.decreaseCountAsyncThunk()),
  increaseCountAsyncSaga: () => dispatch(actions.increaseCountAsyncSaga()),
  decreaseCountAsyncSaga: () => dispatch(actions.decreaseCountAsyncSaga()),
});

export default connect(mapStateToProps, mapDispatchToProps)(Count);

함수형 클래스도 수정해봅니다.

components/CountFC.jsx

import React, { useEffect, useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';

import * as actions from '../../actions/count';

const CountFC = () => {
  const dispatch = useDispatch();
  const [localCount, setLocalCount] = useState(0);

  const { count: storeCount } = useSelector((state) => state.count);

  useEffect(() => {
    console.log('Component did mount.');
  }, []);

  const increaseCount = useCallback(() => {
    setLocalCount(localCount + 1);
    dispatch(actions.increaseCount());
  }, [localCount, dispatch]);

  const decreaseCount = useCallback(() => {
    setLocalCount(localCount - 1);
    dispatch(actions.decreaseCount());
  }, [localCount, dispatch]);

  const increaseCountAsyncThunk = useCallback(() => {
    dispatch(actions.increaseCountAsyncThunk());
  }, [dispatch]);

  const decreaseCountAsyncThunk = useCallback(() => {
    dispatch(actions.decreaseCountAsyncThunk());
  }, [dispatch]);

  const increaseCountAsyncSaga = useCallback(() => {
    dispatch(actions.increaseCountAsyncSaga());
  }, [dispatch]);

  const decreaseCountAsyncSaga = useCallback(() => {
    dispatch(actions.decreaseCountAsyncSaga());
  }, [dispatch]);

  return (
    <>
      {'Function component'}
      <div>
        <div>
          {`localCount: ${localCount}`}
        </div>
        <div>
          {`storeCount: ${storeCount}`}
        </div>
      </div>
      <div onClick={increaseCount}>
        {'+'}
      </div>
      <div onClick={decreaseCount}>
        {'-'}
      </div>
      <div onClick={increaseCountAsyncThunk}>
          {'Increase count async thunk'}
        </div>
        <div onClick={decreaseCountAsyncThunk}>
          {'Decrease count async thunk'}
        </div>
        <div onClick={increaseCountAsyncSaga}>
          {'Increase count async saga'}
        </div>
        <div onClick={decreaseCountAsyncSaga}>
          {'Decrease count async saga'}
        </div>
    </>
  )
};

export default CountFC;

그럼 일전에 만든 thunk 와 똑같이 saga 도 동작하게 됩니다.

결론

저는 주로 thunk 보다는 saga 를 많이 쓰는데 가장 큰 이유는 action creator 가 조금 더 순수한 모양이기 때문입니다. 따라서 action 에는 모양이 다 비슷한 action type 또는 creator 가 있고 조금 더 부가적인 기능은 saga 에 나누어서 사용하는 편입니다. 이러한 action creator 모양 말고도 두가지 미들웨어에 다른점이 있는데, thunk는 하나의 action 으로 store를 업데이트 해 줄 수 없습니다.
즉, 컴포넌트 단에서 thunk creator를 부르면 그 안에서 dispatch 를 통해서 여러가지 하고싶은 일들을 해야합니다. 그에 반해 saga는 컴포넌트 단에서 어떤 action type 을 dispatch 하게 되면 saga와 reducer가 한번에 action type 을 감지하고 각각의 맞는 동작을 하게 되죠. (바로 store 를 업데이트 하거나, 비동기 처리를 하거나가 동시에 이루어짐) 즉, saga는 이벤트를 인터셉트해서 나중에 다시 돌려주는게 아니라 동시성이 보장 된다는 이야기 입니다.

따라서 reducer.js 가 다음과 같다고 가정 할 때에,

import * as countActions from '../actions/count';

const initialStates = {
  count: 0,
}

const reducers = (state = initialStates, action) => {
  const { type } = action;
  switch (type) {
    case countActions.INCREASE_COUNT: {
      return {
        ...state,
        count: state.count +1
      }
    }
    case countActions.DECREASE_COUNT: {
      return {
        ...state,
        count: state.count - 1,
      }
    }
    case countActions.INCREASE_COUNT_ASYNC: {
      return {
        ...state,
      }
    }
    case countActions.DEFREASE_COUNT_ASYNC: {
      return {
        ...state,
      }
    }
    default: {
      return state;
    }
  }
}

export default reducers;

countActions.INCREASE_COUNT_ASYNC, countActions.DECREASE_COUNT_ASYNC 도 동작을 하고,
saga 에

yield takeEvery(countActions.INCREASE_COUNT_ASYNC, increaseCountAsync)
yield takeEvery(countActions.DEFREASE_COUNT_ASYNC, decreaseCountAsync)

도 동시에 반응하여 작업이 진행 된다는 이야기입니다.

때에 맞게 적절히 thunk, saga 나 하나를 사용해서 혼란이 없게 프로젝트를 구성하는게 좋을것 같네요.

profile
개발을 취미로 하는 개발자가 되고픔

0개의 댓글