[18장] 리덕스 미들웨어(redux-saga)
1. 리덕스 사가
Redux를 기반으로 돌아가는 애플리케이션을 생각해보자. 유저가 애플리케이션과 인터렉션하면서 이거저거 누르게 될 것이고, 이 인터렉션에서 액션이 동시다발적으로 발생될 것이다. 뿐만 아니라 Redux 액션과 더불어 일반 로직이나 Ajax 요청 등 온갖 것들이 실행될텐데, 이러한 요청들을 어떻게 보장하고 실행시켜줄 수 있을까?
이러한 상황에서 고려해볼만한 라이브러리가 Redux-Saga이다. 단순히 '비동기 처리에 이점이 있다' 라는 설명 하나로 광고되는 경우가 많지만, Redux 설계를 잘 해뒀다면 사실 비동기 처리 하나만으론 어려운 일이 아니다.
Redux-Saga의 진짜 의미는 리덕스의 액션이 여러 의미를 가지게 되고 그에 맞춰 기능이 확장, 액션과 액션 사이의 체이닝이 발생하기 시작할 때 빛을 발한다. 아래와 같은 상황을 상상해보자.
- '사용자1' 님께서 홈페이지에 입장하셨습니다
- 회원 정보 페이지로 입장
- 회원 정보 로딩
- 정보가 있으면 현금결제용 포인트와 이 유저의 페이지를 방문한 유저를 로딩
정보가 없으면 포인트 멤버쉽 가입창과 '팔로워를 늘려보세요' 라는 창을 로딩
정보가 있건 없건 요즘 핫한 인스타인물을 창 하단에 로딩
회원 정보를 로딩하는 actions을 dispatch했다고 치자. 그럼 이 action에 따라 다른 action이나 이벤트가 파생되어야 한다(실제로 이러한 상황은 매우 흔한 일이다)
- Ajax 콜, 비동기 타이머, 애니메이션 후 콜백, 요청 중 취소, 스로틀링, 디바운싱, 페이지 이동, 웹 소켓
이러한 일들을 부수효과(Side-Effect)라고 하는데, 이러한 애들은 단순히 Redux의 액션 흐름만으론 나타내기가 어렵고, 비동기 처리시엔 어디엔가 dispatch함수의 레퍼런스를 가지고 있다가 필요시에 호출해야 한다.
이러한 부수 효과들을 단순하고 직관적으로 풀어내고자 나온게 Redux-Saga이다. 제너레이터 함수를 쓰기에 문법이 어려울 순 있으나, 근본적으로는 액션이 발생하는지 감시하고 있다가, 특정 액션이 수신되면 이를 dispatch시켜 작업을 처리하는 방식이다.
※ 리덕스-사가 공식 홈페이지의 설명
'redux-saga is a library that aims to make application side effects (i.e. asynchronous things like data fetching and impure things like accessing the browser cache) easier to manage, more efficient to execute, easy to test, and better at handling failures.'
'Contrary to redux thunk, you don't end up in callback hell, you can test your asynchronous flows easily and your actions stay pure.'
2. 리덕스사가 구성
2.1 구조
리덕스사가는 액션을 감시하는 Watcher와 실제 작업을 수행하는 Worker로 구성된다.
- Watcher : 특정 액션이 dispatch되는지 감시하다가 액션이 디스패치되면 Worker에게 일을 시킨다
- Worker : 특정 작업을 수행하는데, 이는 또 다른 액션을 dispatch하거나 기타 작업(리덕스 액션이 아닌)을 처리하는 일이 될 수 있다. 즉, 노예한텐 무슨 일을 시키든 그 나름이라는 뜻.
이 구조의 장점은 각 함수들이 자신만의 일에 집중하므로 실행 시점을 알기 편해진다는 것이다. 또한, 자신 외에 별도 부수효과에 신경쓸 필요가 없다는 것이다.
2.2 effect-creator 메서드
effect란 미들웨어에 의해 수행되는 명령을 담고있는 간단한 자바스크립트 객체를 말한다. 따라서 effect-creator란 부수효과를 처리하기 위해 미들웨어에게 전달할 객체를 만든다는 뜻이다.
Worker가 이 메서드들을 호출하면서 이펙트를 만들고, 얘를 미들웨어로 전달하면서 결과적으로 미들웨어가 작업을 처리할 수 있게 되는 것.
################# Watcher ##################
# take( pattern )
- pattern : '*', function, string, array 네 가지 타입이 올 수 있고, 각자 조금씩 의미하는 바가 다르다
- '*' : 모든 액션을 감시하겠다는 뜻
- function : 함수의 결과가 참인 경우를 감시한다는데 뭔 뜻인지 잘 모르겠다
- string : 흔히 우리가 정의하는 문자열로 정의된 액션을 말한다
- array : 여러개의 액션을 감시하고 싶은 경우 배열에 담아서 전달한다
pattern에 정의된 특정 액션을 감시하는 용도로 쓰인다. 근데 사실 take로 감시하진 않고 takeEvery, takeLatest, takeLeading 등등을 사용한다. take와 용도는 같되 쓰임새가 약간씩 다름.
# takeEvery( pattern, saga [, ...args ] )
- saga : worker에서 수행 할 제너레이터 함수 (혹은 일반 함수)
- args : started task에 전달될 인수들
e.g. takeEvery( [ 'USER_LOADING', 'USER_POINT_LOADING', 'FOLLOWER_LOADING' ], loadUserInitialSet )
들어오는 모든 액션에 대해 특정 작업을 처리한다. 이전에 처리중이던 작업이 있건 말건 감시하는 액션만 맞으면 작업을 계속해서 지시한다.
# takeLatest( pattern, saga [, ...args ] )
이전에 처리중이던 작업이 있다면 모두 무시하고 가장 마지막에 입력된 작업만 수행한다.
################# Worker ##################
# put( action )
액션을 디스패치한다.
# call( fn, ...args ) :
- fn : 제너레이터 함수. 혹은 Promise를 반환하거나 어떠한 값을 반환하는 함수
- args : fn 함수에 전달할 인수(배열형태)
함수를 호출해서 실행시켜주는 이펙트. 만약 전달해준 fn이 Promise를 반환하는 함수라면 미들웨어는 제너레이터를 잠시 멈추고 promise가 resolved될 때 까지 기다렸다가 재개된다(blocking)
# fork( fn, ...args )
call과 유사하나 함수 호출이 완료되길 기다리지 않으며, 하위 Task를 생성하고 이후 곧바로 실행을 재개한다(non-blocking)
e.g. loadUser를 실행하는데 혹시 아직 실행중이면 그냥 멈춰주셈 ㅅㄱ
const task = yield fork( loadUser );
if ( task.isRunning( ) ) {
task.cancel( );
}
※ Task
백그라운드에서 실행되는 프로세스.
3. 예시
실제로 코드를 보면 주저리 주저리 길긴 하겠지만, 본질적으로는 아래 4단계를 써넣은 것 뿐이다.
- 워커 함수를 정의한다
- 와치 함수를 정의한다
- 각자 노예들을 관리하는 와치 함수들을 하나로 통합한다(루트 사가)
- 스토어에 미들웨어로 은근슬쩍 껴넣고 잘 돌아가길 기도한다
// 파일 경로 : modules/index.js
import { sampleSaga } from "./sample";
const RootReducer = combineReducers({
counter,
sample,
loading,
});
export function* rootSaga() {
yield all([counterSaga(), sampleSaga()]);
}
export default RootReducer;
파일 경로 : modules/sample.js
import { createAction, handleActions } from 'redux-actions';
import * as api from '../lib/api';
import createRequestThunk from '../lib/createRequestThunk';
import { startLoading, finishLoading } from './loading';
import { call, put, takeLatest } from '@redux-saga/core/effects';
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';
const initialState = {
post: null,
users: null,
};
export const getPost = createAction(GET_POST, id => id);
export const getUsers = createAction(GET_USERS);
function* getPostSaga(action) {
yield put(startLoading(GET_POST));
try {
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);
}
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;