[말로 풀어쓴 React] 리덕스 미들웨어를 통한 비동기 작업 관리 3

DongGu·2021년 2월 27일
0

목차

18.1 작업 환경 준비

18.2 미들웨어란?

18.3 비동기 작업을 처리하는 미들웨어 사용

18.3.2 redux-saga

redux-thunk는 함수 형태의 액션을 디스패치하여 미들웨어에서 해당 함수에 스토어의 dispatch와 getState를 파라미터로 넣어서 사용하는 원리이다. 그래서 구현한 thunk 함수 내부에서 원하는 API 요청도 하고, 다른 액션을 디스패치하거나 현재 상태를 조회하기도 했다.

이번에 할 redux-saga는 좀 더 까다로운 상황에 적합하다. 다음과 같은 상황을 예로 들 수 있다.

  • 기존 요청을 취소 처리해야 할 때 (불필요한 중복 요청 방지)
  • 특정 액션이 발생했을 때 다른 액션을 발생시키거나, API 요청 등 리덕스와 관계없는 코드를 실행할 때
  • 웹소켓을 사용할 때
  • API 요청 실패 시 재요청해야 할 때

  • 18.3.2.1 제너레이터 함수 이해하기
    redux-saga에서는 ES6의 제너레이터 함수라는 문법을 사용한다. 이 문법의 핵심 기능은 함수를 작성할 때, 함수를 특정 구간에 멈춰놓았다가 원할 때 다시 돌아오게 할 수 있다.
function weirdFunction() {
  return 1;
  return 2;
  return 3;

하나의 함수에서 여러 개를 리턴하는 것은 불가능하므로, 이 코드는 호출할 때마다 1만 반환된다. 하지만 제너레이터 함수를 사용하면 함수에서 값을 순차적으로 반환할 수 있다. 게다가 함수의 흐름을 도중에 멈춰놓고 있다가 다시 이어서 진행시킬 수도 있다.

function* genereatorFunction(){
  console.log('안녕');
  yield 1;
  console.log('제너레이터 함수');
  yield 2;
  console.log('function*');
  yield 3;
  return 4;
}

const generator = generatorFunction();

제너레이터 함수를 만들 때는 function* 키워드를 쓴다. 제너레이터 함수를 호출했을 때 반환되는 객체를 제너레이터라고 부른다.

이제 다음 코드를 순차적으로 한 줄씩 입력하고 어떤 결과가 나타나는지 확인해보자.

generator.next();
// 안녕하세요
// {value: 1, done: false}

generator.next();
// 제너레이터 함수
// {value: 2, done: false}

generator.next();
// function*
// {value: 3, done: false}

generator.next();
// {value: 4, done: true}

generator.next();
// {value: defined, done: true}

제너레이터가 처음 만들어지면 함수의 흐름은 멈춰있는 상태다. next()가 호출되면 다음 yield가 있는 곳까지 호출하고 다시 함수가 멈춘다. 제너레이터 함수를 사용하면 함수를 도중에 멈출 수도 있고, 순차적으로 여러 값을 반환시킬 수도 있다. next 함수에 파라미터를 넣으면 제너레이터 함수에서 yield를 사용하여 해당 값을 조회할 수도 있다.

function* sumGenerator(){
  console.log('sumGenerator가 만들어졌다.');
  let a = yield;
  let b = yield;
  yield a + b;
}

const sum = sumGenerator();
sum.next();
// sumGenerator가 만들어졌다.
// {value: undefined, done: false}

sum.next(1);
// {value: undefined, done: false}

sum.next(2)
// {value: 3, done: false}

sum.next()
// {value: undefined, done: true}

redux-saga는 제너레이터 함수 문법을 기반으로 비동기 작업을 관리해준다. redux-saga는 우리가 디스패치하는 액션을 모니터링해서 그에 따라 필요한 작업을 따로 수행할 수 있는 미들웨어다.

function* watchGenerator()[
  console.log('모니터링 중...');
  let prevAction = null;
  while (true) {
    const action = yield;
    console.log('이전 액션: ', prevAction);
    prevAction = action;
    if (action.type === 'HELLO'){
      console.log('안녕하세요!');
    }
  }
}

const watch = watchGenerator();

watch.next();
// 모니터링 중..
// {value: undefined, done: false}

watch.next({type:"TEST"});
// 이전 액션: null
// {value: undefined, done: false}

watch.next({type:"HELLO"});
// 이전 액션: {type: "TEST"}
// 안녕하세요!
// {value: undefined, done:false}
  • 18.3.2.2 비동기 카운터 만들기
    yarn add redux-saga

INCREAMENT_ASYNC, DECREMENT_ASYNC라는 액션 타입을 선언한다. 해당 액션에 대한 액션 생성 함수도 만들고, 이어서 제너레이터 함수를 만든다. 이 제너레이터 함수를 사가(saga)라고 부른다.

increaseAsync, decreaseAsync, increaseSaga, decreaseSaga들이 어디서 호출되었는지 모르겠다
-> 아마 //modules/index.js에서 function* rootSaga()에서 쓰는 것 같다.

// modules/counter.js
import {createAction, handleActions} from 'redux-actions';
import {delay, put, takeEvery, takeLatest} from 'redux-saga/effects';

const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
const INCREASE_ASYNC = 'counter/INCREASE_ASYNC';
const DECREASE_ASYNC = 'counter/DECREASE_ASYNC';

export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);
// 마우스 클릭 이벤트가 payload 안에 들어가지 않도록
// () => undefined를 두 번째 파라미터로 넣어준다.

export const increaseAsync = createAction(INCREASE_ASYNC, () => undefined);
export const decreaseAsync = createAction(DECREASE_ASYNC, () => undefined);

function* increaseSaga(){
  yield delay(1000); // 1초를 기다린다.
  yield put(increase()); // 특정 액션을 디스패치한다.
}

function* decreaseSaga(){
  yield delay(1000);
  yield put(decrease()); // 특정 액션을 디스패치한다.
}

export function* counterSaga(){
  // takeEvery는 들어오는 모든 액션에 대해 특정 작업을 처리해준다.
  yield takeEvery(INCREASE_ASYNC, increaseSaga);
  // takeLatest는 기존에 진행 중이던 작업이 있다면 취소 처리하고
  // 가장 마지막으로 실행된 작업만 수행한다.
  yield takeLatest(DECREASE_ASYNC, decreaseSaga);
}

const initialState = 0; // 상태는 꼭 객체일 필요 없다. 숫자도 작동한다.
const counter = hanleActions(
  {
    [INCREASE]: state => state + 1,
    [DECREASE]: state => state - 1
  },
  initialState
);
export default counter;

루트 리듀서를 만들었던 것처럼 루트 사가를 만들어야 한다. 추후 다른 리듀서에게도 사가를 만들어 등록할 것이기 때문이다.

// modules/index.js
import {combineReducers} from 'redux';
import {all} from 'redux-saga/effects';
import counter, {counterSaga} from './counter';
import sample from './sample';
import loading from './loading';

const rootReducer = combineReducers({
  counter,
  sample,
  loading
});

export function* rootSaga(){
  // all 함수는 여러 사가를 합쳐주는 역할을 한다.
  yield all([counterSaga()]);
}

export default rootReducer;

이제 스토어에 redux-saga 미들웨어를 적용하자.

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import {createStore, applyMiddleware} from 'redux';
import { Provider } from 'react-redux';
import './index.css';
import App from './App';
import rootReducer, {rooSaga} from './moduels';
import {createLogger} from 'redux-logger';
import ReduxThunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';

const logger = createLogger();
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  rootReducer,
  applyMiddleware(logger, ReduxThunk, sagaMiddleware)
);
sagaMiddleware.run(rootSaga);

ReactDOM.render(
  <Provider store={store}>
  	<App/>
  </Provider>,
  document.getElementById('root')
);

스토어에 미들웨어를 적용했다면 CounterContainer 컴포넌트를 App 컴포넌트에 렌더링하여 작동하는지 확인해보자. counter 리덕스 모듈이 변경되기는 했지만, 컨테이너 컴포넌트에서 수정해야 할 것은 없다. 기존에 사용 중이던 thunk 함수와 똑같은 이름으로 액션 생성함수(INCREASE_ASYNC, DECREASE_ASYNC)를 만들었기 때문이다.

// App.js
import React from 'react';
import CounterContainer from './containers/CounterContainer';

const App = () => {
  return (
    <div>
    	<CounterContainer />
    </div>
  );
};
export default App;

리덕스 개발자 도구를 적용하여 어떤 액션이 디스패치되고 있는지 편하게 확인할 것이다. 먼저 리덕스 개발자 도구 라이브러리르 설치한다. $ yarn add redux-devtools-extension

이 라이브러리의 composeWithDevTools 함수를 리덕스 미들웨어와 함께 사용할 땐 그냥 applyMiddleware 부분을 감싸주면 된다.

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import {createStore, applyMiddleware} from 'redux';
import { Provider } from 'react-redux';
import './index.css';
import App from './App';
import rootReducer, {rooSaga} from './moduels';
import {createLogger} from 'redux-logger';
import ReduxThunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';
import {composeWithDevTools} from 'redux-devtools-extension';

const logger = createLogger();
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(logger, ReduxThunk, sagaMiddleware))
);
sagaMiddleware.run(rootSaga);

ReactDOM.render(
  <Provider store={store}>
  	<App/>
  </Provider>,
  document.getElementById('root')
);

이대로 실행해보면 increaseSaga는 takeEvery라 클릭 횟수만큼 카운터가 증가한다. decreaseSaga는 takeLatest라서 한 번만 디스패치 된다. 여러 액션이 중첩되었을 때 기존 것들은 무시하고 가장 마지막 액션만 처리하기 때문이다.

이상으로 Counter에 Saga를 적용해봤다.


이제부터는 API 요청에 Saga를 적용해볼 것이다.

  • 18.3.2.3 API 요청 상태 관리하기
    이번에는 redux-saga를 사용하여 API 요청을 해보겠습니다. 기존에 thunk로 관리하던 액션 생성함수를 없애고, 사가를 사용하여 처리한다. sample 모듈을 다음과 같이 수정한다.

  • delay
    설정된 시간 이후에 resolve하는 Promise객체를 리턴한다.
    ex) delay(1000): 1초 기다린다.

  • call
    함수의 첫 번째 파라미터는 함수, 나머지 파라미터에는 해당 함수에 넣을 인수이다.
    ex) call(delay, 1000): delay(1000)을 call 함수를 이용해 이렇게 적을 수도 있다. call은 주어진 함수를 실행하고, put은 스토어에 인자로 들어온 action을 dispatch하는 차이점이 있다.

  • put
    특정 액션을 dispatch하도록 한다
    ex) put({type: 'INCREMENT'}): INCREMENT action을 dispatch한다.

  • all
    all을 이용해 제너레이터 함수를 배열의 형태로 인자에 넣어주면, 제너레이터 함수들이 병행적으로 동시에 실행되고 전부 resolve될 때까지 기다린다. Promise.all과 비슷하다
    ex) yield all([testSaga1(), testSaga2()])

// modules/sample.js
import {createAction, handleActions} from 'redux-actions';
import {call, put, takeLatest} from 'redux-saga/effects';
import * as api from '../lib/api';
import {startLoading, finishLoading} from './loading';

// 액션 타입을 선언한다.
const GET_POST = 'sample/GET_POST';
const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS';
const GET_POST_FAILURE = 'sample/GET_POST_FAILURE';

const GET_USERS = 'sample/GET_USERS';
const GET_USERS_SUCCESS = 'sample/GET_USERS_SUCCESS';
const GET_USERS_FAILURE = 'sample/GET_USERS_FAILURE';

export const getPost = createAction(GET_POST, id=>id);
export const getUsers = createAction(GET_USERS);

function* getPostSaga(action){
  yield put(startLoading(GET_POST)); // 로딩 시작
  // 파라미터로 action을 받아오면 액션의 정보를 조회할 수 있다
  try {
    // call을 사용하면 Proise를 반환하는 함수를 호ㅜ출하고, 기다릴 수 있다.
    // 첫 번째 파라미터는 함수, 나머지 파라미터는 해당 함수에 넣을 인수이다.
    const post = yield call(api.getPost, action.payload); // apit.getPost(action.payload)를 의미
    yield put({
      type: GET_POST_SUCCESS,
      payload: post.data
    });
  } catch(e){
    // try/catch 문을 사용하여 에러도 잡을 수 있다.
    yield put({
      type: GET_POST_FAILURE,
      payload: e,
      error: true
    });
  }
  yield put(finishLoading(GET_POST)); // 로딩 완료
}

function* getUsersSaga() {
  yield put(startLoading(GET_USERS));
  try {
    const users = yield call(api.getUsers);
    yield put({
      type: GET_USERS_SUCCESS,
      payload: users.data,
    });
  } catch (e) {
    yield put({
      type: GET_USERS_FAILURE,
      payload: e,
      error: true,
    });
  }
  yield put(finishLoading(GET_USERS));
}

export function* sampleSaga() {
  yield takeLatest(GET_POST, getPostSaga);
  yield takeLatest(GET_USERS, getUsersSaga);
}

// 초기 상태를 선언한다.
// 요청의 로딩 중 상태는 loading이라는 객체에서 관리한다.

const initialState = {
  post: null,
  users: null,
};

const sample = handleActions(
  {
    [GET_POST_SUCCESS]: (state, action) => ({
      ...state,
      post: action.payload,
    }),
    [GET_USERS_SUCCESS]: (state, action) => ({
      ...state,
      users: action.payload,
    }),
  },
  initialState
);

export default sample;

여기서 GET_POST 액션의 경우에는 API 요청을 할 때 어떤 id로 조회할지 정해줘야 한다. redux-saga를 사용할 떄는 id처럼 요청에 필요한 값을 액션의 payload로 넣어줘야 한다. 예를 들어 지금 상황이라면 다음과 같은 액션이 디스패치된다.

{
  type: 'sample/GET_POST',
  payload: 1
}

그러면 이 액션을 처리하기 위한 사가를 작성할 때 payload 값을 API를 호출하는 함수의 인수로 넣어줘야 한다.

API를 호출해야 하는 상황에는 사가 내부에서 직접 호출하지 않고 call 함수를 사용한다. call 함수의 경우, 첫 번째 인수는 호출하고 싶은 함수이고, 그 뒤에 오는 인수들은 해당 함수에 넣어주고 싶은 인수이다. 지금 getPostSaga의 경우에는 id를 의미하는 action.payload가 인수가 된다.

// modules/index.js
import { combineReducers } from "redux";
import { all } from "redux-saga/effects";
import counter, { counterSaga } from "./counter";
import sample, { sampleSaga } from "./sample";
import loading from "./loading";

const rootReducer = combineReducers([counter, sample, loading]);

export function* rootSaga() {
  // all 함수는 여러 사가를 합쳐주는 역할을 한다
  yield all([counterSaga(), sampleSaga()]);
}
export default rootReducer;

사가를 등록한 후, App 컴포넌트에서 SampleContainer를 렌더링한다.

import React from "react";
import SampleContainer from "./containers/SampleContainer";

const App = () => {
  return (
    <div>
      <SampleContainer />
    </div>
  );
};
export default App;
  • 18.3.2.4 리팩토링
    thunk 함수를 위해 createRequestThunk라는 함수를 만들었던 것처럼 createRequestSaga라는 함수를 만들 것이다.
// lib/createRequestSaga.js
import { call, put } from "redux-saga/effects";
import { startLoading, finishLoading } from "../modules/loading";

export default function createRequestSaga(type, request) {
  const SUCCESS = `${type}_SUCCESS`;
  const FAILURE = `${type}_FAILURE`;

  return function* (action) {
    yield put(startLoading(type)); // 로딩 시작
    try {
      const response = yield call(request, action.payload);
      yield put({
        type: SUCCESS,
        payload: response.data,
      });
    } catch (e) {
      yield put({
        type: FAILURE,
        payload: e,
        error: true,
      });
    }
    yield put(finishLoading(type));
  };
}

기존에 구현했던 사가를 다음과 같이 짧은 코드로 구현할 수 있다.
getPostSaga, getUsersSaga 부분을 lib/createRequestSaga를 불러와 간단히 표현했다

// modules/sample.js
import { createAction, handleActions } from "redux-actions";
import { call, put, takeLatest } from "redux-saga/effects";
import * as api from "../lib/api";
import createRequestSaga from "../lib/createRequestSaga";
import { startLoading, finishLoading } from "./loading";

(...)

const getPostSaga = createRequestSaga('GET_POST', api.getPost)
const getUsersSaga = createRequestSaga('GET_USERS', api.getUsers)


export function* sampleSaga() {
  yield takeLatest(GET_POST, getPostSaga);
  yield takeLatest(GET_USERS, getUsersSaga);
}
(...)
export default sample;
  • 18.3.2.5 알아두면 유용한 기능들
    - 사가 내부에서 현재 상태를 조회하는 방법
import {createAction, handleActions} from 'redux-actions';
import {delay, put, takeEvery, takeLatest, select} from 'redux-saga/effects';

(...)

function* increaseSaga(){
    yield delay(1000); // 1초를 기다린다
    yield put(increase()); // 특정 액션을 디스패치한다.
    const number = yield select(state => state.counter); // state는 스토어 상태를 의미한다.
    console.log(`현재 값은 ${number}이다`);
}
- 사가가 실행되는 주기를 제한하는 방법
takeEvery 대신 throttle이란 함수를 사용하면 사가가 n초에 단 한 번만 호출도도록 설정할 수 있다. 예를 들어 counterSaga를 다음과 같이 수정하면 increaseSaga는 3초에 단 한 번만 호출된다.
import { createAction, handleActions } from "redux-actions";

import {
    delay,
    put,
    takeEvery,
    takeLatest,
    select,
    throttle,
} from 'redux-saga/effects';

(...)

export function* counterSaga() {
    // 첫번 째 파라미터: n초 * 1000
    yield throttle(3000, INCREASE_ASYNC, increaseSaga);
    // takeLatest는 기존에 진행 중이던 작업이 있다며 취소 처리하고
}
profile
코딩하는 신방과생

0개의 댓글