본문 바로가기
Frontend/React

[React] 리액트 미들웨어 redux-saga

by 디스코비스킷 2023. 12. 28.
반응형

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

redux-saga

redux-saga는 redux-thunk 다음으로 많이 사용하는 비동기작업 관련 미들웨어이다.

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

redux-saga는 좀 더 까다로운 상황에서 유용하다.

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

제너레이터 함수 이해

리덕스사가에서는 ES6의 제너레이터 함수 문법을 사용한다. 이 문법의 핵심 기능은 함수를 작성할때 함수를 특정구간에 멈춰놓을 수 있고, 원할때 다시 돌아가게 할 수도 있다는 것이다.

일반적인 함수는 값을 여러개 반환하는것 불가능.
하지만 제너레이터 함수를 사용하면 값을 순차적으로 반환(yield)할 수 있다. 제너레이터와 이터러블 객체와 함께 사용하면 손쉽게 데이터 스트림을 만들 수 있다.

function* generatorFunction() {
    console.log('안녕');
      yield 1;
      console.log('안뇽');
      yield 2;
      console.log('하이');
      yield 3;
      return 4;
}

제너레이터 함수 작성법
제너레이터 함수라 불리는 특별한 문법 구조, function*이 필요하다.
함수를 작성한 뒤에는 제너레이터를 생성해야한다.

const generator = generatorFunction();

제너레이터 함수는 일반 함수와 동작 방식이 다르다. 제너레이터 함수를 호출하면 코드가 실행되지 않고, 대신 실행을 처리하는 특별 객체, 제너레이터가 반환된다.

next()는 제너레이터의 주요 메서드이다. next()를 호출하면 가장 가까운 yield <value>문을 만날 때까지 실행이 지속됩니다(value를 생략할 수도 있는데, 이 경우엔 undefined가 됩니다). 이후, yield <value>문을 만나면 실행이 멈추고 산출하고자 하는 값인 value가 바깥 코드에 반환됩니다.

next()는 항상 아래 두 프로퍼티를 가진 객체를 반환한다.

  • value: 산출 값
  • done: 함수 코드 실행이 끝났으면 true, 아니라면 false
    generator.next(); // { value: 1, done: false }
    generator.next(); // { value: 2, done: false }
    generator.next(); // { value: 3, done: false }
    generator.next(); // { value: 4, done: true }
    generator.next(); // { value: undefined, done: true }

제너레이터 함수를 사용하면 함수 도중에 멈출 수 있고, 순차적으로 여러값을 반환할 수 있다. 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는 우리가 디스패치하는 액션을 모니터링해서 그에 따라 필요한 작업을 따로 수행할 수 있는 미들웨어이다.

비동기 카운터 만들기

기존에 thunk함수로 구현했던 비동기 카운터를 redux-saga를 사용하여 구현해볼것이다.

설치
$ npm install redux-saga

counter 리덕스 모듈에서 기존 thunk함수 제거하고 액션타입을 새로 선언한다.
해당 액션에 대한 액션 생성함수도 만들고 제너레이터 함수도 만든다.
제너레이터 함수를 사가(saga)라고 한다.

modules/counter.js counter 리덕스 모듈만들기

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() {
  yield takeEvery(INCREASE_ASYNC, increaseSaga); // takeEvery: 들어오는 모든 액션에 대해 특정작업 처리
  yield takeLatest(DECREASE_ASYNC, decreaseSaga); // takeLatest: 기존에 진행중이던 작업이 있다면 취소처리하고 가장 마지막으로 실행된 작업만 실행
}


const initialState = 0;

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

export default counter;

modules/index.js 루트사가 만들기

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

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

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

export default rootReducer;

main.jsx 스토어에 사가 미들웨어 적용

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import rootReducer, { rootSaga } from './modules/index.js'
import { applyMiddleware, legacy_createStore as createStore } from "redux";
import { Provider } from 'react-redux';
// import loggerMiddleware from './lib/loggerMiddleware.js';
import { createLogger } from 'redux-logger';
import { thunk } from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';

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

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


+1버튼을 두번누르면 INCREASE_ASYNC액션이 두번 디스패치되고, INCREASE액션도 두번 디스패치된다. takeEvery를 사용하여 increaseSaga를 등록해서 디스패치되는 모든 INCREASE_ASYNC 액션에 대해 1초후 INCREASE 액션을 발생시켜준다.

-1버튼을 두번누르면 DECREASE_ASYNC액션이 두번 디스패치 되었음에도 불구하고 DECREASE 액션이 한번만 디스패치 된다. takeLatest를 사용하여 decreaseSaga를 등록해서, 여러 액션이 중첩되어 디스패치 되었을때는 기존의 것들은 무시하고 가장 마지막 액션만 제대로 처리한다.

redux-saga의 주요함수

  • delay: 설정시간 이후 resolve하는 Promise객체 리턴
  • call: 함수의 첫번째 파라미터는 함수, 나머지 파라미터는 해당함수에 넣을 파라미터
  • put: 특정 액션 디스패치
  • all: all 함수를 사용해서 제너레이터 함수를 배열의 형태로 인자로 넣어주면, 제너레이터 함수들이 병행적으로 동시에 실행되고, 전부 resolve될때까지 기다린다. Promise.all과 비슷.
  • takeEvery: 들어오는 모든 액션에 대해 특정작업 처리
  • takeLatest: 기존에 진행중이던 작업이 있다면 취소처리하고 가장 마지막으로 실행된 작업만 실행
  • select: 사가 내부에서 현재상태를 조회

API 요청상태 관리하기

redux-saga를 사용해 API 요청해보기

modules/sample.js

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

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

  try {
    const post = yield call(api.getPost, action.payload); // 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(action) {
  yield put(startLoading(GET_USERS));

  try {
    const users = yield call(api.getUsers, action.payload);
    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액션의 경우 어떤 id로 조회할지 id값이 필요.
redux-saga를 사용할때는 id처럼 요청에 필요한 값을 액션의 payload로 넣어줘야한다.

API를 호출해야하는 상황에서 사가 내부에서 직접 호출하지 않고 call함수를 사용한다.
call함수의 경우, 첫번째 인수는 호출하고싶은 함수, 두번째 인수는 해당함수에 넣어줄 인수이다.

const post = yield call(api.getPost, action.payload);
getPost에 필요한 인수는 id에 해당하는 action.payload이라 위와 같이 작성한다.

modules/index.js 루트사가 등록

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

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

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

export default rootReducer;


포스트와 유저데이터를 잘 받아 나타난다.

리팩토링

사가쪽 반복되는 코드를 유틸함수로 빼서 간결하게 만들어보자..
thunk함수를 위해 createRequestThunk라는 함수를 만들었던것 처럼 createRequestSaga를 만든다.

lib/createRequestSaga.js

import { call, put } from 'redux-saga/effects';
import { finishLoading, startLoading } 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));
    }
}

modules/sample.js

import { createAction, handleActions } from "redux-actions";
import { takeLatest } from 'redux-saga/effects';
import * as api from "../lib/api";
import createRequestSaga from "../lib/createRequestSaga";

const GET_POST = "sample/GET_POST";
const GET_POST_SUCCESS = "sample/GET_POST_SUCCESS";

const GET_USERS = "sample/GET_USERS";
const GET_USERS_SUCCESS = "sample/GET_USERS_SUCCESS";

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

// saga
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);
}
// 초기상태 선언
// 요청의 로딩 중 상태는 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;

알아두면 유용한 기능들

select

사가내부에서 현재 상태를 조회하는 방법
modules/counter.js

import { createAction, handleActions } from "redux-actions";
import { delay, put, takeEvery, takeLatest, select } from "redux-saga/effects";

...
function* increaseSaga() {
  yield delay(1000);
  yield put(increase());
  const number = yield select(state => state.counter); // state는 스토어의 상태
  console.log(`현재값은 ${number}입니다.`);
}
...

사가내부에서 현재 상태를 참조해야 하는 상황이 생기면 이렇게 select를 사용한다.

yield select(state => state.counter)
스토어의 state.counter값을 조회할 수 있다.

throttle

사가가 실행되는 주기 제한하기(throttle)
takeEvery 대신 throttle이라는 함수를 사용하면 사가가 n초에 한번만 호출되도록 설정할 수 있다.

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

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

yield throttle(3000, INCREASE_ASYNC, increaseSaga);

정리

redux-thunk는 일반함수로 이루어져 간단명료하고
redux-saga는 진입장벽이 있지만 복잡한 상황에서 더 효율적으로 작업을 관리할 수 있다.

미들웨어를 사용하지 않고 비동기작업을 하는것이 틀린 방법은 아니다.
비동기작업을 처리할 때 리덕스 미들웨어를 사용하는 이유는,
결국 좀 더 편하게 처리하기 위함이다.

그러므로 오히려 불편하다고 느낀다면 사용하지 않는편이 좋을 수 있다.

반응형

최근댓글

최근글

© Copyright 2024 ttutta