[React다루는기술] 24. Redux Saga Middleware(비동기 작업)


Redux Saga는 Redux 애플리케이션에서 비동기 작업을 관리하기 위한 미들웨어로 제너레이터 함수를 이용한 라이브러리이다.


1. redux-saga

Redux Saga는 Redux 애플리케이션에서 비동기 작업을 관리하기 위한 미들웨어이다. 주로 네트워크 요청, 데이터 가져오기, 캐시 처리 등의 비동기 작업을 처리하는 데 사용된다. Redux Saga는 제너레이터 함수를 기반으로 동작하며, 액션들을 모니터링하고 필요한 비동기 작업을 실행한다.

  1. 비동기 작업의 분리: Redux Saga를 사용하면 애플리케이션의 비즈니스 로직과 비동기 작업을 분리할 수 있다. 이는 코드를 더 읽기 쉽고 유지보수하기 쉽게 만든다. 비동기 작업의 로직이 액션과 리듀서 사이에 분산되지 않고 Saga에서 중앙 집중적으로 관리되므로 코드가 더 깔끔하고 직관적이게 된다.

  2. 효율적인 비동기 제어: Redux Saga는 제너레이터 함수를 사용하여 비동기 흐름을 명확하게 제어할 수 있다. 비동기 작업의 세부 사항을 명시적으로 제어할 수 있으므로 복잡한 비즈니스 로직을 보다 쉽게 처리할 수 있다. 또한, 다양한 비동기 작업들을 순차적으로 또는 병렬로 처리할 수 있어서 성능을 최적화할 수 있다.

  3. 테스트 용이성: Redux Saga를 사용하면 테스트하기 쉬운 코드를 작성할 수 있다. Saga의 비동기 작업은 순수 자바스크립트 함수로 분리되어 있기 때문에 모의(mock) 함수를 사용하여 쉽게 테스트할 수 있다. 이는 코드의 품질을 높이고 버그를 줄이는 데 도움이 된다.

  4. 다양한 기능 제공: Redux Saga는 다양한 기능을 제공하여 비동기 작업을 더욱 효율적으로 처리할 수 있다. 예를 들어, takeEvery, takeLatest, throttle, delay 등의 여러 사가 이펙트를 사용하여 비동기 작업의 동작을 세밀하게 제어할 수 있다.

이러한 이유로 Redux Saga는 Redux 애플리케이션에서 비동기 작업을 처리하는 데 매우 실용적이다.


제너레이터 함수

redux-saga 에서는 ES6의 generator 함수라는 문법을 사용하기에 redux-saga 를 접하기전에 제너레이터 사용 및 작동 방식에 알아보자.

function* generatorFunction() {
 let obj = yield;
 console.log('# obj 1 = ', obj.type)
 yield obj;
 
 obj = yield;
 console.log('# obj 2 = ', obj)
 yield obj;
}

const generator = generatorFunction();
console.log(generator.next())
console.log(generator.next( { type: "A" }))
console.log(generator.next())
console.log(generator.next( { type: "B", title: "REACT" }))
(2) {value: undefined, done: false}
# obj 1 = A
(2) {value : {type: "A"}, done: false}
(2) {value: undefined, done: false}
# obj 2 = 
(2) {type: "B", title: "REACT"}
(2) {value: {type: "B", title: "REACT"}, done: false}


기존에 thunk 함수로 구현했던 비동기 카운터를 리덕스 모듈 counter.js 에서 redux-saga 를 적용하는 포스팅이기에 redux-saga 를 설치해주자

$ yarn add redux-saga


1-1. Redux Saga의 counterSaga 제너레이터 함수

위의 코드는 Redux Saga에서 사용되는 제너레이터 함수인 counterSaga이다. 이 함수는 특정 액션들에 대한 비동기 작업을 수행한다.

  • takeEvery: 들어오는 모든 액션에 대해 특정 작업을 처리한다. 예를 들어, INCREASE_ASYNC 액션이 발생할 때마다 increaseSaga 함수를 호출한다.
  • takeLatest: 가장 마지막으로 실행된 작업만을 수행한다. 즉, 동시에 여러 번 요청이 오더라도 마지막 요청에 대해서만 작업을 수행합니다. 예를 들어, DECREASE_ASYNC 액션이 여러 번 발생할 경우, 마지막으로 발생한 액션에 대해서만 decreaseSaga 함수를 호출합니다.

takeEvery와 takeLatest 효과를 사용하여 특정 액션들에 대한 비동기 작업을 관리해서 서버로부터 데이터를 가져오거나 상태를 변경하는 등의 작업을 수행 할 수 있다.


/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);

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); //1초를 기다린다.
  yield put(decrease()); //특정 액션을 디스패치한다.
}

export function* counterSaga() {
  //takeEvery 는 들어오는 모든 액션에 대해 특정 작업을 처리
  yield takeEvery(INCREASE_ASYNC, increaseSaga);
  //takeLatest 는 가장 마지막으로 실행된 작업만 수행
  yield takeLatest(DECREASE_ASYNC, decreaseSaga);
}

const initialState = 0;

const counter = handleActions(
  {
    [INCREASE]: (state) => state + 1,
    [DECREASE]: (state) => state - 1,
  },
  initialState
);

export default counter;


아래와 같이 화면에서 counter 컴포넌트를 “+”를 빠르게 연속으로 클릭하면 takeEvery이기에 비동기지만 클릭수 만큼 액션과 디스패치는 호출된다. “-“를 빠르게 연속으로 클릭하면 takeLatest이기에 비동기로 액션이 중첩되게 되면 마지막 요청에 대해서만 디스패치는 호출된다.

learn-redux-saga-s1


1-2. Redux Saga의 rootSaga

rootSaga: Redux Saga에서 사용되는 여러 개의 사가를 합치는 역할을 한다. all 함수를 사용하여 여러 사가를 결합하여 하나의 루트 사가로 생성한다. 이 루트 사가는 애플리케이션의 비동기 작업 흐름을 관리하게 된다.

/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;


1-3. Redux Saga의 createSagaMiddleware

sagaMiddleware는 Redux Saga를 사용하기 위해 추가된 미들웨어이다. 이렇게 함으로써 Redux와 Redux Saga를 함께 사용하여 비동기 작업을 처리할 수 있다.

index.js

//...
import rootReducer, { rootSaga } from "./modules";
import createSagaMiddleware from "redux-saga";
//...

const logger = createLogger();
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  rootReducer,
  applyMiddleware(logger, thunk, sagaMiddleware)
);

sagaMiddleware.run(rootSaga);
//...


1-4. Redux Saga로 API요청

제너레이터 함수를 사용하는 이유는 Redux Saga에서 비동기 작업을 처리하기 위해서이다. Redux Saga에서는 비동기 작업을 수행하는 동안 애플리케이션의 상태를 블로킹하지 않으면서도 비동기 코드의 순서를 제어할 수 있어야 한다. 그래서 제너레이터 함수를 사용한다.

제너레이터 함수는 함수 실행을 중간에 멈추고 다시 시작할 수 있는 기능을 제공한다. 이를 통해 비동기 작업을 수행하는 동안 중간에 멈추고 다른 작업을 수행하거나, 작업이 완료될 때까지 기다릴 수 있다. 이러한 특징을 활용하여 Redux Saga에서는 yield 키워드를 사용하여 비동기 작업을 수행하고, 해당 작업이 완료될 때까지 기다리는 방식으로 코드를 작성한다.


Redux Saga 에서 put과 call 함수는

  • put: put 함수는 Redux 액션을 디스패치하는 데 사용된다. 즉, 액션을 스토어로 보낸다. 이 함수는 Redux의 dispatch와 유사하게 동작하지만, 제너레이터 함수 내에서 사용된다. 예를 들어, put({ type: ‘INCREMENT’ })와 같이 사용됩니다.

  • call: call 함수는 동기 함수나 Promise를 호출할 때 사용됩니다. Redux Saga는 이 함수를 사용하여 비동기 작업을 호출하고, 해당 작업이 완료될 때까지 제너레이터를 일시 중단한다. call 함수는 첫 번째 매개변수로 호출할 함수를 받고, 나머지 매개변수는 해당 함수에 전달된다.


아래 코드로 설명을 하면

call 함수는 이를 호출하고, Promise가 완료될 때까지 제너레이터를 일시 중단합니다. 그 후에는 call 요청이 완료되면 해당 결과를 반환한다.

  • yield 키워드 : 호출된 함수의 결과가 반환될 때까지 제너레이터 함수를 일시 중단
  • call 함수 : 비동기 작업이 완료되면 그 결과를 반환
const post = yield call(api.getPost, action.payload);


/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);
console.log("# sample.js :: ", getPost);

function* getPostSaga(action) {
  yield put(startLoading(GET_POST)); //로딩 시작

  try {
    //call 함수는 이를 호출하고, Promise가 완료될 때까지 제너레이터를 일시 중단합니다. 그 후에는 call 요청이 완료되면 해당 결과를 반환한다.
    //- yield 키워드는 호출된 함수의 결과가 반환될 때까지 제너레이터 함수를 일시 중단
    //- call 함수는 비동기 작업이 완료되면 그 결과를 반환
    const post = yield call(api.getPost, action.payload);

    yield put({
      type: GET_POST_SUCCESS,
      payload: post.data,
    });
  } catch (e) {
    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) => console.log("***", action.payload),
    [GET_POST_SUCCESS]: (state, action) => ({
      ...state,
      post: action.payload,
    }),
    [GET_USERS_SUCCESS]: (state, action) => ({
      ...state,
      users: action.payload,
    }),
  },
  initialState
);

export default sample;

비동기 작업을 처리할 경우 redux-thunk, redux-saga 등 미들웨어를 사용하는 것은 좋은 방법이다.




react-deal-book-img