redux-saga
redux-thunk 다음으로 많이 사용하는 비동기 작업 관련 미들웨어이다.
사용하기 좋은 상황
- 기존 요청을 취소 처리해야 할때 ( 불필요한 중복 요청 방지 )
- 특정 액션이 발생했을 때 다른 액션을 발생시키거나, API 요청 등 리덕스와 관계없는 코드를 실행했을 때
- 웹소켓을 사용할 때
- API 요청 실패 시 재요청해야 할 때
제너레이터 함수
redux-saga 에서는 ES6의 제너레이터 함수를 사용한다. (처음 다뤄봐서 이해하기 힘들었다....)
제너레이터 함수를 사용하면 함수에 값을 순차적으로 반환할 수 있다.
또한 함수의 흐름을 도중에 멈춰 눻았다가 이어 진행할 수 있다.
function* generatorFunction() {
console.log(`11`);
yield 1;
console.log(`22`);
yield 2;
console.log(`33`);
yield 3;
return 4;
}
const generator = generatorFunction();
generator.next();
// 11
// {value: 1, done: false}
generator.next();
// 22
// {value: 2, done: false}
generator.next();
// 33
// {value: 3, done: false}
generator.next();
// {value: 4, done: false}
generator.next();
// {value: undefined, done: false}
제너레이터는 처음 만들어지면 함수의 흐름은 멈춰 있는 상태이다.
next() 가 호출되면 다음 yield가 있는 곳까지 호출하고 함수가 멈춘다.
제너레이터 함수를 사용하면 함수를 도중에 멈출 수도 있고, 순차적으로 여러 값을 반환시킬 수도 있다.
redux-saga는 제너레이터 함수 문법을 기반으로 비동기 작업을 관리해 준다.
비동기 카운터 만들기
우선 redux-saga를 설치하자
npm add redux-saga
INCREASE_ASYNC와 DECREASE_ASYNC라는 액션 타입을 생성하고, 액션 생성 함수, 제너레이터 함수를 만든다.
이 제너레이터 함수를 사가(saga)라고 불린다.
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)
//Thunk생성 함수 만들기
//1초 뒤에 increase 혹은 decrease 함수를 디스패치함
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);
// takeLast는 기존에 진행 중이던 작업이 있다면 취소 처리하고
// 가장 마지막으로 실행된 작업만 수행한다.
yield takeLatest(DECREASE_ASYNC, decreaseSaga);
}
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 from './counter';
import { counterSaga } from './counterWithSaga';
import sample from './sample';
import loading from './loading';
import { all } from '@redux-saga/core/effects';
const rootReducer = combineReducers({
counter,
sample,
loading
});
// 비동기 카운터 만들기
export function* rootSaga() {
// all함수는 여러 사가를 합쳐주는 역활을 한다.
yield all([counterSaga()]);
}
export default rootReducer;
이제 스토어에 redux-saga 미들웨어를 적용하자
( 이전에 const sagaMiddleware = createSagaMiddleware() 를 선언했다)
미들웨어에 createSagaMiddleware()를 적용하며
createSagaMiddleware를 불러와 sagaMiddleware.run(rootSaga)로 적용시켜준다.
index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import App from './App';
import reportWebVitals from './reportWebVitals';
import rootReducer, { rootSaga } from './modules';
import { createLogger } from 'redux-logger';
import ReduxThunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';
import { composeWithDevTools } from 'redux-devtools-extension';
const root = ReactDOM.createRoot(document.getElementById('root'));
const logger = createLogger();
const sagaMiddleware = createSagaMiddleware(); // 비동기 카운터 만들기
const store = configureStore({
reducer: rootReducer,
middleware: [logger, ReduxThunk, sagaMiddleware], // 비동기 카운터 만들기
});
sagaMiddleware.run(rootSaga);
root.render(
<Provider store={store}>
<App />
</Provider>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
미들웨어를 적용했다면 CounterContainer 컴포넌트를 App 컴포넌트에 렌더링 해서 작동하는지 확인해보자.
App.js
import CounterContainer from './container/CounterContainer';
const App = () => {
return (
<div>
<CounterContainer/>
</div>
);
}
export default App;
API 요청 상태 관리하기
sample 리덕스 모듈을 수정해 보자
modules/sampleWithSaga.js
import { call, put, takeLatest } from '@redux-saga/core/effects';
import { createAction } from '@reduxjs/toolkit';
import { handleActions } from 'redux-actions';
import * as api from '../lib/api';
import { finishLoading, startLoading } 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_USER';
const GET_USERS_SUCCESS = 'sample/GET_USER_SUCCESS'; // 리듀서에서 더이상로딩 중에 대한 상태를 관리할 필요가 없다.
const GET_USERS_FAILURE = 'sample/GET_USER_FAILURE';
// 액션 함수 작성
export const getPost = createAction(GET_POST, id => id);
export const getUsers = createAction(GET_USERS, id => id);
function* getPostSaga(action) {
yield put(startLoading(GET_POST)); // 로딩시작
// 파라미터로 action을 받아 오면 액션의 정보를 조회할 수 있다.
try {
// call을 사용하면 Promise를 반환하는 함수를 호출하고, 기다릴 수 있다.
// 첫 번째 파라미터는 함수, 나머지 파라미터는 해당 함수에 넣을 인수이다.
const post = yield call(api.getPost, action.payload); // api.getPost(action.pay-load)를 의미
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 = {
// loading: {
// GET_POST: false,
// GET_USERS: false
// },
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로 넣어 줘야 한다.
API를 호출해야 하는 상황에는 사가 내부에서 직접 호출하지 않고, call 함수를 사용한다.
call 함수의 경우 첫번째 인수는 호출하고 싶은 함수이고, 그 뒤에 오는 인수는 해당 함수에 넣는 인수이다.
지금은 기능 구현을 중요시 생각하고 나중에 리팩토링 하자.
modules/index.js
// 루트 리듀서
import { combineReducers } from 'redux';
import counter from './counterWithSaga';
import { counterSaga } from './counterWithSaga';
import sample, { sampleSaga } from './sampleWithSaga';
import loading from './loading';
import { all } from '@redux-saga/core/effects';
const rootReducer = combineReducers({
counter,
sample,
loading
});
// 비동기 카운터 만들기
export function* rootSaga() {
// all함수는 여러 사가를 합쳐주는 역활을 한다.
yield all([counterSaga(), sampleSaga()]);
}
export default rootReducer;
사가를 등록했다면 App 컴포넌트에서 SampleContainer를 렌더링하자
App.js
import SampleContainer from './container/SampleContainer';
const App = () => {
return (
<div>
<SampleContainer/>
</div>
);
}
export default App;
리팩토링
thunk 때와 같이 반복되는 코드를 함수화 해서 리팩토링하자
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)); // 로딩 끝
};
}
기존에 구현했던 사가를 다음과 같이 짧은 코드로 구성할 수 있게 되었다.
modules/sampleWithSaga.js
import { takeLatest } from '@redux-saga/core/effects';
import { createAction } from '@reduxjs/toolkit';
import { handleActions } from 'redux-actions';
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_USER';
const GET_USERS_SUCCESS = 'sample/GET_USER_SUCCESS'; // 리듀서에서 더이상로딩 중에 대한 상태를 관리할 필요가 없다.
// 액션 함수 작성
export const getPost = createAction(GET_POST, id => id);
export const getUsers = createAction(GET_USERS, id => id);
// createRequestSaga를 통해 짧은 코드 구성
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;
알아두면 좋은 기능들
1. Select을 이용해 내부에서 현재 상태 조회
// 비동기 카운터 만들기
import { createAction, handleActions } from 'redux-actions';
import { delay, put, takeEvery, takeLatest, select } from 'redux-saga/effects';
(...)
//Thunk생성 함수 만들기
//1초 뒤에 increase 혹은 decrease 함수를 디스패치함
function* increaseSaga() {
yield delay(1000); // 1초 기다린다.
yield put(increase()); // 특정 액션을 디스패치한다.
const number = yield select(state => state.counter);
// select을 이용하여 내부 현제 상태를 조회, state는 스토어 상태를 의미
console.log(`현재 값은 ${number}입니다.`)
};
(...)
2. throttle을 이용해 사가가 실행되는 주기를 제한
// 비동기 카운터 만들기
import { createAction, handleActions } from 'redux-actions';
import { delay, put, takeEvery, takeLatest, select, throttle } from 'redux-saga/effects';
(...)
export function* counterSaga() {
// takeEvery는 들어오는 모든 액션에 대해 특정 작업을 처리한다.
// yield takeEvery(INCREASE_ASYNC, increaseSaga);
// 사가가 실행되는 주기를 throttle로 제한하는 방법
// 첫 번째 파라미터: n초 * 1000
yield throttle(3000, INCREASE_ASYNC, increaseSaga);
// takeLast는 기존에 진행 중이던 작업이 있다면 취소 처리하고
// 가장 마지막으로 실행된 작업만 수행한다.
yield takeLatest(DECREASE_ASYNC, decreaseSaga);
}
(...)
지금까지 redux-saga를 다뤄보았다.
많은 경우에 thunk를 다루지만 특정 상황에서는 saga를 사용하면 더 좋겠다는 생각이 들었다.
특히 다른 액션의 디스패치가 필요할 경우에는 saga 사용이 더 좋을거 같다는 생각을 하게됬다.
지금 까지 리덕스의 미들웨어에 대해 공부했고
나에게 어려운 개념들이니 계속해서 반복적으로 생각날때마다 정리한 글들을 봐야겠다.
'React' 카테고리의 다른 글
React) 20장 서버 사이드 렌더링 (1) SSR 구현하기 (0) | 2022.05.31 |
---|---|
React) 19장 코드 스플리팅 ( React.lazy-Suspense, loadable_component ) (0) | 2022.05.23 |
React) 18장 리덕스 미들웨어를 이용한 비동기 작업관리 ( Thunk ) (0) | 2022.05.20 |
React) 18장 리덕스 미들웨어를 이용한 비동기 작업관리( 미들웨어 ) (0) | 2022.05.20 |
React) 17장 리덕스를 이용한 Todo리스트 (0) | 2022.05.14 |