March 01, 2020
redux 비동기 처리를 위해 redux-saga를 사용해보신 적이 있으신가요?
“The mental model is that a saga is like a separate thread in your application that’s solely responsible for side effects. redux-saga is a redux middleware, which means this thread can be started, paused and cancelled from the main application with normal redux actions, it has access to the full redux application state and it can dispatch redux actions as well.”
출처: redux-saga 공식 문서
“redux-saga의 mental model은 saga가 Effect를 전적으로 처리한다는 것입니다. Redux의 Action을 통해 작업을 시작, 중지, 취소시키거나 Redux에서 관리되는 store에 접근할 수 있고, Action을 dispatch 할 수 있습니다.”
이 말을 완전히 이해하려면 Saga Pattern에 대한 이해가 필요합니다.
Saga Pattern은 마이크로 서비스의 등장과 함께 주목받기 시작했습니다. Saga Pattern에 대해서는 다양한 해석이 존재하는데, MSDN에서는 Saga를 CQRS Pattern의 Process Manager로 보고 있습니다. Saga의 기본 개념은 분산 transaction의 필요성을 제거하고, transaction마다 Compensating transaction(보상 transaction)을 정의하는 것입니다.
Compensating transaction(보상 transaction)이란 각 transaction에서 오류가 발생했을 때 수행되는 transaction을 말합니다.
예를 들어 아래와 같은 서비스가 있다고 가정해 봅시다.
사용자로부터 주문을 받고, 결제, 재고관리, 배달 작업을 처리합니다. 각 단계는 각각의 Service에서 관리합니다.
Saga
는 transaction이 각 서비스에서 데이터를 업데이트하는 일련의 local transaction입니다. 첫 번째 transaction은 외부 요청에 의해 시작되고, 다음 단계의 transaction은 이전 작업이 완료된 후 시작됩니다.
Saga Transaction을 구현하는 방법에는 대표적으로 두 가지 방법이 있습니다.
위 예시에서 Event흐름은 다음과 같습니다.
Event/Choreography
방법에서 Rollback은 다음과 같은 단계로 진행됩니다.
Order Service와 Payment Service는 두 가지 작업을 진행합니다:
Note. 각 transaction은 id를 가지고 있어, 모든 리스너가 발생한 transaction을 즉시 알 수 있습니다.
Command/Orchestration
방식은 각 서비스가 작업을 수행할 시점과 작업 내용을 관리하는 Orchestrator를 따로 두는 방식입니다. Saga Orchestrator는 command/reply
형태로 각 서비스와 통신하여 수행할 작업을 전달합니다.
아래 예시를 통해 살펴보겠습니다.
OSO는 주문을 처리하는 데에 필요한 모든 transaction을 관리합니다. 문제가 발생하면 각 Service에게 command를 전달해서 Rollback을 수행하게 합니다.
Saga Orchestrator는 command와 그 command에 해당하는 상태를 관리하는 State Machine
으로 구현합니다.
OSO는 transaction이 실패했음을 인지하고, Rollback을 수행합니다.
Orchestration Saga는 다음과 같은 장점이 있습니다.
command/reply 형태로 관리하기 때문에 Service의 복잡성이 줄어듭니다.
그러나, 단점도 있습니다.
Command/Orchestration
구조는 서비스 간에 많은 이벤트나 context를 공유하는 경우, Event Routing이 복잡할 경우 용이합니다.
Event Routing이란 이벤트가 어떤 Service에게 전달되어야 하고, 이후 어떤 이벤트가 진행되어야 하는지 나타낸 것입니다.
Events/Choreography
는 Orchestrator에 대한 관리 부담이 없기 때문에 전체 Service의 규모가 작고 Event 간의 종속성이 많지 않은 경우에 선택하는 것이 좋습니다.
앞서 살펴본 Saga
는 redux-saga와 어떻게 이어질까요? redux-saga는 발생하는 Action과 관리되는 State사이에서 흐름을 관리하는 Orchestrator 로서 존재합니다.
function sagaMiddleware({ getState, dispatch }) {
// Initialize Saga
return next => action => {
if (sagaMonitor && sagaMonitor.actionDispatched) {
sagaMonitor.actionDispatched(action)
}
const result = next(action) // Reducer에 dispatch channel.put(action) // Saga에게 action이 dispatch되었음을 알리기
return result
}
}
Saga를 통하는 모든 action은 Reducer에 먼저 dispatch되고, channel이라고 하는 saga의 커뮤니케이션 통로를 통해 action이 dispatch되었음을 Saga에게 알려줍니다.
아래 예시를 통해 좀 더 자세히 살펴보겠습니다.
import { put, takeEvery, delay } from 'redux-saga/effects'
// Our worker Saga: will perform the async increment task
export function* incrementAsync() {
yield delay(1000)
yield put({ type: 'INCREMENT' })
}
// Our watcher Saga: spawn a new incrementAsync task on each INCREMENT_ASYNC
export function* watchIncrementAsync() {
yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}
redux까지 포함하면, 다음과 같은 Flow로 표현할 수 있습니다.
Saga
는 INCREMENT_ASYNC action을 listen하고 delay와 put이라는 effect를 yield합니다. Saga는 Effect를 yield하고, JavaScript 객체를 반환합니다.
Middleware는 이 Effect를 받아서 처리합니다. 위 예시에서는 첫 번째 yield delay가 중단되고, 1초가 지날 때까지 대기하게 됩니다.
Note. redux-saga의 Effect는 blocking effect와 non-blocking effect로 구분됩니다. Blocking Effect는 처리가 완료될 때까지 기다리며 Non-blocking Effect는 완료를 기다리지 않고 진행합니다. 대표적인 Blocking eEffect로는 call이 있고, Non-blocking Effect에는 fork가 있습니다.
위에서 언급한 것처럼, Saga는 Effect를 yield하고 JavaScript객체를 반환합니다. 아래 코드는 redux-saga의 internal effect 코드입니다.
// redux-saga/internal/effect.js
const makeEffect = (type, payload) => ({
[IO]: true,
combinator: false,
type,
payload,
})
export function call(fnDescriptor, ...args) {
// Validate...
return makeEffect(effectTypes.CALL, getFnCallDescriptor(fnDescriptor, args))
}
export function fork(fnDescriptor, ...args) {
// Validate...
return makeEffect(effectTypes.FORK, getFnCallDescriptor(fnDescriptor, args))
}
export function race(effects) {
const eff = makeEffect(effectTypes.RACE, effects)
eff.combinator = true
return eff}
redux-saga의 effect들은 마치 액션 생성 함수(action creator function)처럼 makeEffect(...)
함수의 결과로 만들어진 객체를 반환합니다. 이렇게 어떤 작업을 수행하는지에 대한 정보를 담고 있는 effect객체를 반환하면, 실질적인 로직 수행은 middleware에서 이루어집니다.
같은 이벤트가 연속적으로 올 때, Saga는 Event를 어떻게 Orchestration할 수 있을까요? redux-saga에서는 takeLatest API를 제공합니다.
export default function takeLatest(patternOrChannel, worker, ...args) {
const yTake = { done: false, value: take(patternOrChannel) }
const yFork = ac => ({ done: false, value: fork(worker, ...args, ac) })
const yCancel = task => ({ done: false, value: cancel(task) })
// Set action and task
return fsmIterator(
{
q1() { return { nextState: 'q2', effect: yTake, stateUpdater: setAction }
},
q2() { return task
? { nextState: 'q3', effect: yCancel(task) }
: { nextState: 'q1', effect: yFork(action), stateUpdater: setTask }
},
q3() { return { nextState: 'q1', effect: yFork(action), stateUpdater: setTask }
},
},
'q1',
`takeLatest(${safeName(patternOrChannel)}, ${worker.name})`,
)
}
q1
을 시작으로, 동일한 Event가 발생하면 이전 Event를 Cancel(yCancel)하고 nextState에 fork로 전달합니다. Orchestrator Pattern에서 command를 통해 Rollback을 구현했던 것처럼, Saga에서는 Cancel을 통해 effect를 관리하고 있습니다.
redux-saga는 Effect 객체를 통해 Side Effect를 관리하기 때문에 테스트 코드 작성이 용이합니다. 마치 각 단계를 하나씩 진행해주는 것처럼 테스트 코드를 작성할 수 있습니다.
아래의 코드를 예시로 들어 보겠습니다.
export function* fetchHelloWorld() {
try {
const helloText = yield select(helloSelector.text);
const { data } = yield call(
getHello,
helloText,
);
yield put(helloWorldActions.success(data));
} catch(error) {
yield put(helloWorldActions.fail(error.status));
}
}
코드에서 yield
부분이 있는 곳을 Step
으로 바라보고, 테스트 코드를 작성하겠습니다.
describe('HelloWorldsaga', () => {
it('should dispatch success action', async () => {
// Given
const testRequest = {};
const testResult ={
data: {
text: 'Mock Text'
},
};
const gen = fetchHelloWorld(); // 0 // Then
expect(gen.next().value).toEqual(select(helloSelector.text)); // 1
expect(gen.next(testRequest).value).toEqual(
call(getHello, testRequest)
); // 2
expect(gen.next(testResult).value).toEqual(
put(helloWorldActions.success(testResult.data))
); // 3
expect(gen.next().done).toBeTruthy(); // 4
});
});
fetchHelloWorld
라는 saga를 gen
으로 정의했습니다.select(helloSelector.text)
effect와 gen의 next 단계가 일치하는 지 검사합니다.call
을 수행하는 부분입니다. call에는 fn
과 args
를 받도록 되어 있으니, gen.next(call단계)
에 testRequest값을 함께 넘겨줍니다. 그리고 이 결과가 실제로 call(getHello, testRequest)
와 같은지 비교합니다.gen.next
의 인자로 넘겨줍니다.fetchHelloWorld
saga에서 더 이상의 yield가 없으므로, 이 단계에서 next()의 값은 done입니다.더 자세한 내용은 redux-saga:testing과 Jbee님의 Store와 비즈니스 로직 테스트글을 참고해 주세요.
이번 글에서 Saga Pattern과 redux-saga에 대해서 정리해봤습니다. Saga는 명령을 내리는 역할만 하고, 실제 어떤 직접적인 동작은 미들웨어가 처리하는 Command/Orchestration
구조로 서비스 간에 많은 이벤트나 context와 복잡한 Event Routing을 관리하는 데에 있어 좋은 선택이 될 수 있습니다.
또한, 미들웨어는 saga에게 오직 yield
값을 받아서 동작을 수행하는 구조이기 때문에, Test단락에서 언급한 것처럼 Test작성에 용이합니다.