docs(KO)
docs(EN)
docs/api(EN)
redux-saga로 비동기처리와 분투하다
Redux-saga: 제너레이터와 이펙트
redux-saga
는 애플리케이션의 side effect 를 보다 쉽게 관리하고, 실행하기 쉽고, 테스트하기 쉬우 며, 오류를 더 잘 처리하는 것을 목표로 하는 라이브러리입니다.
Mental model은 saga
가 side effect에 대한 책임이 있는 애플리케이션의 별도 스레드와 같다는 것입니다. redux-saga는 redux 미들웨어이기 때문에,이 스레드는 정상적인 redux 작업으로 메인 애플리케이션에서 시작, 일시 중지 및 취소 할 수 있으며 전체 redux 애플리케이션 상태에 액세스 할 수 있으며 redux 작업도 전달(dispatch)할 수 있습니다.
Generators라는 ES6 기능을 사용하여 이러한 비동기 흐름을 쉽게 읽고, 쓰고, 테스트 할 수 있습니다. 이렇게하면 비동기 흐름이 표준 동기 JavaScript 코드처럼 보입니다. (async/await와 비슷하지만 Generator는 우리가 필요로하는 몇 가지 멋진 기능을 더 가지고 있습니다)
비동기 작업을 처리하기 위해 이전에 redux-thunk를 사용했을 수 있습니다. redux thunk와는 달리 콜백 지옥으로 끝나지 않고 비동기 흐름을 쉽게 테스트 할 수 있으며 작업은 순수하게 유지됩니다.
Side effect: data fetching와 같은 비동기식 작업 또는 브라우저 캐시 액세스와 같은 순수하지 않은 동작
프로젝트가 이미 React, Redux로 이루어져있고, redux-saga
를 추가한다고 가정하겠습니다.
폴더 구조는 이렇습니다.
...
/store
/reducer
/saga
|index.js
|exampleSaga.js
|index.js
App.jsx
/store/saga/exampleSaga.js
import { delay } from 'redux-saga'
import { put, takeEvery } from 'redux-saga/effects'
// worker Saga: 비동기 증가 태스크를 수행할겁니다.
export function* incrementAsync() {
yield delay(1000)
yield put({ type: 'INCREMENT' })
}
// watcher Saga: 각각의 INCREMENT_ASYNC 에 incrementAsync 태스크를 생성할겁니다.
export function* watchIncrementAsync() {
yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}
/store/saga/index.js
// 모든 Saga들을 한번에 시작하기 위한 단일 entry point 입니다.
export default function* rootSaga() {
yield all([
helloSaga(),
watchIncrementAsync()
])
}
/store/index.js
// ...
import rootSaga from './sagas'
const sagaMiddleware = createSagaMiddleware()
const store = ...
sagaMiddleware.run(rootSaga)
// ...
들어가기에 앞서서 어려운 개념을 먼저 짚어보자.
앞서 말했듯이 redux-saga
에서는 Generator 문법을 사용한다. 그리고 redux-saga
에서 말하는 saga
는 바로 Generator function이다. (Generator가 아니다) saga
를 만들어 redux-saga
미들웨어에 등록하면 이 미들웨어가 generator function 으로부터 만들어진 generator를 계속 실행하여 산출된 모든 effect를 실행한다. 즉, saga
는 yield 값만을 반환하기만 하고 미들웨어가 이 값(effect)를 받아서 실행하는 역할을 맡는다. saga
는 단순히 반환만 하기 때문에 (직접 비동기 처리를 하지 않음) 테스트가 훨씬 용이해진다.
그럼 Effect는 무엇인가? saga
가 반환하는 값이자 미들웨어가 실행할 명령을 담고 있는 자바스크립트 객체라고 생각하면 된다. 이를 통해 saga
가 비동기 처리를 하지 않고 미들웨어에게 실행의 책임을 떠넘길 수 있는 것이다. 정리하자면, Saga는 명령을 담고 있는, 이펙트라 부르는 순수한 객체를 yield 할 것이고, 미들웨어는 이런 명령들을 해석해 처리하고, 그 결과를 다시 Saga에 돌려준다. 바로 위 reference 에서 가져온 내용이며 정리가 훨씬 잘되어있으니 꼭 읽어보길 바란다.
하나의 saga
가 실행되는 것을 task라고 부른다.
redux-saga
에서는 Task를 만들기 위해 내부 함수를 감싸는 몇몇 helper 함수를 제공한다. (effect creators라고도 부른다.)
function | description |
---|---|
takeEvery(action, sagaFn) | action이 발생할 때마다 Task(sagaFn)가 실행되게 한다. 여러 개의 Task를 동시에 시작할 수 있다. redux-thunk 와 비슷한 기능이다. |
takeLast(action, sagaFn) | 마지막으로 발생한 하나의 action에만 Task(sagaFn)가 실행되게 한다. 실행중이던 Task는 action이 발생하면 취소되고 새로운 Task가 실행된다. |
Saga 로직을 표현하기 위해서 saga
는 순수 Javascript object를 yield한다. 이런 object를 effect
라고 부른다. effect
란 미들웨어에 의해 해석되는 몇몇 정보들을 담고있는 간단한 객체이다. 어떤 기능을 수행하기 위해 미들웨어에 전해지는 명령(스토어에 액션을 dispatch하는 행위나 비동기 함수를 호출하는 등)이라고 볼 수 있다.
effect
는 redux-saga/effects
에서 제공하는 함수(effect creator
)로 만들어진다. 대표적인 effect creator
는 아래와 같다.
function | description |
---|---|
select | state에서 필요한 데이터를 꺼낸다. |
put | Action을 dispatch한다. |
take | Action/이벤트 발생을 기다린다. |
call(fn, ...args) | Promise의 완료를 기다린다. apply 함수와 동일하다. |
fork | 다른 Task를 시작한다. |
join | 다른 Tack의 종료를 기다린다. |
take
)위에서 살펴봤던 takeEvery
함수를 사용하는 것은 redux-thunk
와 유사하다. 사실, takeEvery
는 take
와 fork
함수를 사용한 high-level API에 불과하다.
const takeEvery = (patternOrChannel, saga, ...args) => fork(function*() {
while (true) {
const action = yield take(patternOrChannel)
yield fork(saga, ...args.concat(action))
}
})
take
는 특정한 액션이 dispatch될 때까지 기다린다. takeEvery
의 경우, 실행된 태스크는 그들이 다시 실행될 때에 대한 관리 방법이 없다. take
의 경우 액션이 푸시(push)되는 대신, saga 스스로 액션을 풀링(pulling)하기 때문에 특별한 컨트롤 프로우를 수행할 수 있게 한다. 전통적인 액션의 푸시 접근법을 해결하는 것이다.
이 pulling 접근법은 동기적(synchronous) 스타일로 컨트롤 플로우를 표현할 수 있게 한다. 예를 들어 LOGIN
, LOGOUT
액션을 이용하여 로그인 플로우를 만들고 싶을 때 takeEvery
를 이용하면 두 개의 테스크(saga)를 작성해야 했을 것이다. 하지만 take
를 사용해 하나의 태스크로 만들 수 있다.
function* loginFlow() {
while (true) {
yield take('LOGIN')
// ... perform the login logic
yield take('LOGOUT')
// ... perform the logout logic
}
}
fork
)saga
는 effect creators 함수를 통해 blocking 하게 작동한다. 이 때문에 발생하는 몇 가지 문제점이 있어 non-blocking을 지원하기 위한 fork
effect creator를 제공한다. Task를 fork
한다면 그 테스크는 백그라운드에서 시작되고, 호출자는 fork된 테스크가 종료될 때까지 기다리지 않고 플로우를 계속해서 진행한다. yield fork
는 task object 를 반환하기 때문에 후에 테스크 취소가 가능하다.
all
)병렬 처리가 필요한 경우 all
을 사용한다.
import { all, call } from 'redux-saga/effects'
// correct, effects will get executed in parallel
const [users, repos] = yield all([
call(fetch, '/users'),
call(fetch, '/repos')
])
위와 같이 effectdml 배열을 yield하면, 제너레이터는 모든 effect들이 resolve되거나, 어느 하나라도 reject될 때까지 봉쇄(blocked)된다.
race
)여러 task를 병렬로 시작하지만, 그 task를 전부 기다리고 싶지 않을 때가 있다. 이 경우는 race
를 사용한다. race
는 첫 번째로 resolve(or reject)된 task가 나오면 나머지 task를 자동으로 취소시킨다.
take
는 스토어에 dispatch될 액션이 들어오면 resolve 되었다. 그리고 put
은 액션을 인자로 dispatch함으로써 resolve된다. saga가 시작될 때 미들웨어는 자동으로 take/put
을 스토어와 연결한다. 이 두 이펙트는 saga의 입력/출력처럼 보일 수 있다.
redux-saga
는 리덕스 미들웨어 환경 바깥에서 사가를 실행하고 커스텀 입/출력에 연결할 수 있는 방법을 제공한다.
import { runSaga } from 'redux-saga'
function* saga() { ... }
const myIO = {
subscribe: ..., // this will be used to resolve take Effects
dispatch: ..., // this will be used to resolve put Effects
getState: ..., // this will be used to resolve select Effects
}
runSaga(
saga(),
myIO
)
채널은 외부의 이벤트 소스 또는 사가 간의 통신을 위해 해당 이펙트를 일반화한다. 또한 스토어에서 특정 작업을 대기열에 넣을 때도 사용할 수 있다.
이 장에서, 다음 내용을 살펴볼 것이다.
yield actionChannel
이펙트를 이용해 스토어의 특정 액션을 버퍼링하는 방법eventChannel
팩토리함수를 사용하여 take
이펙트를 외부 이벤트 소스에 연결하는 방법channel
팩토리 함수를 이용하여 채널을 만드는 방법과 사가 간의 통신을 위해 take
/put
이펙트에 이를 사용하는 방법actionChannel
effect 사용하기import { take, fork, ... } from 'redux-saga/effects'
function* watchRequests() {
while (true) {
const {payload} = yield take('REQUEST')
yield fork(handleRequest, payload)
}
}
function* handleRequest(payload) { ... }
위 예제는 전형적인 watch와 fork 패턴이다. 짧은 시간에 많은 액션이 들어온다면 동시에 많은 haneldRequest
테스크가 실행될 것이다. 근데 우리는 이 테스크를 순차적으로 처리하고 싶다. 그래서 우리는 아직 처리되지 않은 액션을 대기열에 집어넣고, 현재 요청을 모두 처리했다면 대기열에서 다음 것을 가져올 것이다. 이를 actionChannel
을 사용해 구현할 수 있다.
import { take, actionChannel, call, ... } from 'redux-saga/effects'
function* watchRequests() {
// 1- Create a channel for request actions
const requestChan = yield actionChannel('REQUEST')
while (true) {
// 2- take from the channel
const {payload} = yield take(requestChan)
// 3- Note that we're using a blocking call
yield call(handleRequest, payload)
}
}
function* handleRequest(payload) { ... }
actionChannel
을 생성한다. take
와의 차이점은, saga가 아직 그 액션을 처리할 준비가 되지 않았다면 actionChannel
은 들어오는 메시지(액션)을 버퍼링할 수 있다는 것이다.take(pattern)
에 넣을 패턴을 사용한 것처럼, take(channel)
도 가능하다. take
는 메시지를 받을 수 있을 때만 saga를 봉쇄할 것이다. 채널 버퍼에 메시지가 저장되어 있을 경우에만 봉쇄되지 않고 진행할 것이다. call(handleRequest)
가 반환될 때까지 봉쇄를 유지할 것이다. 하지만 봉쇄되어 있는 중에 다른 REQUEST
액션이 dispatch된다면, 그것은 채널의 버퍼에 저장될 것이다. saga가 call(handleRequest)
에 의해 재개되고 다음 yield take(requestChan)
이 실행될 때, take
는 대기열에 저장된 메시지를 resolve할 것이다.기본적으로 actionChannel
은 제한 없이 버퍼링이 가능하나 버퍼 인자를 주어 제한할 수도 있다.
eventChannel
팩토리를 사용해 외부 이벤트에 연결하기eventChannel(effect creator가 아닌 팩토리 함수)는 리덕스 스토어가 아닌 외부 이벤트를 위한 채널을 생성한다.
이 예제는 일정한 간격마다 채널을 생성한다.
import { eventChannel, END } from 'redux-saga'
function countdown(secs) {
return eventChannel(emitter => {
const iv = setInterval(() => {
secs -= 1
if (secs > 0) {
emitter(secs)
} else {
// this causes the channel to close
emitter(END)
}
}, 1000);
// The subscriber must return an unsubscribe function
return () => {
clearInterval(iv)
}
}
)
}
eventChannel
의 첫 번째 인자는 구독자(subscriber) 함수이다. 구독자의 역할은 외부의 이벤트 소스를 초기화하고 (위의 setInterval
사용), 제공된 emitter
를 실행하여 소스에서 채널로 들어오는 모든 이벤트를 라우팅한다. 위의 예제에서 우리는 매 초마다 emitter
를 호출한다.
주의: 이벤트 채널을 통해 null 또는 undefined를 전달하지 않도록 해야한다. 숫자를 전달하는 것이 좋지만, 이벤트 채널 데이터를 리덕스 액션처럼 구조화하는 것을 추천한다. number를 { number }로 바꾸는 것과 같이.
emitter(END) 호출에도 주의해야 한다. 우리는 채널 소비자에게 채널이 폐쇄되었다는 것을 알리기 위해 사용한다. 이는 더 이상 다른 메시지가 이 채널을 통해 올 수 없다는 것을 의미한다.
우리의 사가에서 이 채널을 어떻게 쓰는지 보자 (이 예제는 저장소(repository)의 cancellable-counter 예제에서 가져왔습니다.)
import { take, put, call } from 'redux-saga/effects'
import { eventChannel, END } from 'redux-saga'
// creates an event Channel from an interval of seconds
function countdown(seconds) { ... }
export function* saga() {
const chan = yield call(countdown, value)
try {
while (true) {
// take(END) will cause the saga to terminate by jumping to the finally block
let seconds = yield take(chan)
console.log(`countdown: ${seconds}`)
}
} finally {
console.log('countdown terminated')
}
}
사가는 take(chan)를 yield하고 있다. 메시지가 채널에 들어가기 전까지 사가는 봉쇄된다. 위의 예제에서, 이는 emitter(secs)를 호출할 때와 일치한다. 우리가 try/finally 구역 내에서 전체 while(true) {...}
를 실행하고 있는 것에 주목해보자. countdown의 interval이 종료되면, countdown 함수는 emitter(END)를 호출함으로써 이벤트 채널을 폐쇄한다. 채널을 닫으면 그 채널에서 take에 봉쇄된 모든 사가들을 종료시키는 효과가 있다. 예제에서, 사가를 종료하면 finally 구간으로 점프하게 된다 (finally 구간이 없으면 그냥 종료된다).
구독자는 unsubscribe 함수를 반환한다. 이것은 이벤트 소스가 완료되기 전에 채널 구독을 취소하는 데에 사용된다. 이벤트 채널의 메시지를 소비하는 사가 내에서 이벤트 소스가 완료되기 전에 일찍 나가기를 원한다면 (예로, 사가가 취소됨) chan.close()를 호출해 채널을 폐쇄하고 구독을 취소할 수 있다.
여기 웹 소켓 이벤트를 사가에 전달하여 이벤트 채널을 사용하는 또 다른 예제가 있다. ping
이라는 서버 메시지를 기다리고 있고, 조금 뒤에 pong
이라는 메시지로 답한다고 가정해보자.
import { take, put, call, apply } from 'redux-saga/effects'
import { eventChannel, delay } from 'redux-saga'
import { createWebSocketConnection } from './socketConnection'
// this function creates an event channel from a given socket
// Setup subscription to incoming `ping` events
function createSocketChannel(socket) {
// `eventChannel` takes a subscriber function
// the subscriber function takes an `emit` argument to put messages onto the channel
return eventChannel(emit => {
const pingHandler = (event) => {
// puts event payload into the channel
// this allows a Saga to take this payload from the returned channel
emit(event.payload)
}
// setup the subscription
socket.on('ping', pingHandler)
// the subscriber must return an unsubscribe function
// this will be invoked when the saga calls `channel.close` method
const unsubscribe = () => {
socket.off('ping', pingHandler)
}
return unsubscribe
})
}
// reply with a `pong` message by invoking `socket.emit('pong')`
function* pong(socket) {
yield call(delay, 5000)
yield apply(socket, socket.emit, ['pong']) // call `emit` as a method with `socket` as context
}
export function* watchOnPings() {
const socket = yield call(createWebSocketConnection)
const socketChannel = yield call(createSocketChannel, socket)
while (true) {
const payload = yield take(socketChannel)
yield put({ type: INCOMING_PONG_PAYLOAD, payload })
yield fork(pong, socket)
}
}
주의: eventChannel의 메시지는 기본적으로 버퍼링되지 않는다. 채널의 버퍼링 전략을 지정하려면 eventChannel 팩토리에 버퍼를 인수로 넣어줘야 한다 (예: eventChannel(subscriber, buffer)).
기본적으로 어떤 소스에도 연결되지 않은 채널을 직접 생성할 수 있다. 그런 다음 채널에 수동으로 put
할 수 있다. 이는 사가 간에 통신을 하기 위해 채널을 사용할 때 유용하다.
root Saga는 sagaMiddleware가 실행될 수 있도록 여러 Sagas를 단일 entry point으로 합친다. 초급 튜토리얼에서 루트 사가는 다음과 같이 보일 것입니다.
export default function* rootSaga() {
yield all([
helloSaga(),
watchIncrementAsync()
])
// code after all-effect
}
이것은 루트를 구현하는 몇 가지 방법 중 하나입니다. 여기서 all
effect는 배열과 함께 사용되며 sagas는 병렬로 실행됩니다. 다른 루트 구현은 오류 및 더 복잡한 데이터 흐름을 더 잘 처리하는 데 도움이 될 수 있습니다.
contributor @slorber가 issue #760에서 몇 가지 다른 일반적인 루트 구현을 언급했습니다. 시작하려면 위 예제와 유사하게 작동하는 인기있는 구현이 하나 있습니다.
export default function* rootSaga() {
yield fork(saga1)
yield fork(saga2)
yield fork(saga3)
// code after fork-effect
}
세 개의 고유한 yield fork
를 사용하면 task descriptor가 세 번 반환됩니다. 앱의 결과 동작은 모든 하위 사가가 동일한 순서로 시작되고 실행된다는 것입니다. 포크가 차단되지 않기 때문에 rootSaga는 자식 무용담이 계속 실행되고 내부 효과에 의해 차단되는 동안 완료 될 수 있습니다.
하나의 큰 모든 effect와 여러 개의 포크 effect의 차이점은 all
effect가 blocking이라는 것이다. 따라서 all-effect 이후의 코드는 모든 자식 sagas가 완료 될 때 실행되는 반면, 포크 효과는 차단되지 않으므로 이후 코드 포크 효과는 포크 효과를 산출 한 직후에 실행됩니다. 또 다른 차이점은 포크 효과를 사용할 때 작업 설명자를 얻을 수 있다는 것입니다. 따라서 후속 코드에서 작업 설명자를 통해 분기 된 작업을 취소/결합 할 수 있습니다.
const [task1, task2, task3] = yield all([ fork(saga1), fork(saga2), fork(saga3) ])
루트 사가를 디자인 할 때 또 다른 인기있는 패턴이 있습니다. 모든 효과의 중첩 포크 효과입니다. 이렇게하면 작업 설명 자의 배열을 얻을 수 있으며 각 포크 효과가 non-blocking이고 동기적으로 작업 설명자를 반환하기 때문에 모든 effect 이후의 코드가 즉시 실행됩니다.
fork 효과는 all 효과에 중첩되지만 항상 기본 forkQueue를 통해 상위 작업에 연결됩니다. 분기 된 작업에서 포착되지 않은 오류는 상위 작업으로 버블링되므로 중단(및 모든 하위 작업)-상위 작업에서 포착 할 수 없습니다.
// DO NOT DO THIS. The fork effect always win the race immediately.
yield race([
fork(someSaga),
take('SOME-ACTION'),
somePromise,
])
반면에 레이스 효과의 포크 효과는 버그 일 가능성이 높습니다. 위의 코드에서 포크 효과는 차단되지 않기 때문에 항상 즉시 레이스에서 승리합니다.
실제로 rootSaga가 개별 하위 효과 또는 saga의 첫 번째 오류에서 종료되고 전체 앱이 충돌하므로 이러한 구현은 그다지 실용적이지 않습니다! 특히 Ajax 요청은 앱이 요청하는 모든 엔드 포인트의 상태에 따라 앱을 결정합니다.
spawn은 부모와 자식 사가의 연결을 끊는 효과로 부모와 충돌하지 않고 실패 할 수 있습니다. 분명히 이것은 오류가 발생할 때에도 여전히 처리해야하는 개발자로서의 우리의 책임에서 벗어나지 않습니다. 실제로 이로 인해 개발자의 관점에서 특정 실패가 가려지고 향후 문제가 발생할 수 있습니다.
스폰 효과는 React의 Error Boundaries와 유사한 것으로 간주 될 수 있는데, 이는 saga 트리의 특정 수준에서 추가 안전 조치로 사용되어 실패한 기능을 차단하고 전체 앱이 충돌하지 않도록 할 수 있다는 점입니다. 차이점은 React Error Boundaries에 대해 존재하는 componentDidCatch와 같은 특별한 구문이 없다는 것입니다. 여전히 자체 오류 처리 및 복구 코드를 작성해야합니다.
export default function* rootSaga() {
yield spawn(saga1)
yield spawn(saga2)
yield spawn(saga3)
}
이 구현에서는 한 사가가 실패하더라도 rootSaga와 다른 사가가 죽지 않습니다. 그러나 실패한 사가는 앱의 수명 동안 사용할 수 없기 때문에 문제가 될 수도 있습니다.
어떤 경우에는 실패시 sagas를 다시 시작할 수있는 것이 바람직 할 수 있습니다. 장점은 앱과 sagas가 실패한 후에도 계속 작동 할 수 있다는 것입니다 (예 : takeEvery (myActionType)을 생성하는 saga). 그러나 우리는 이것을 모든 사가를 살리기위한 포괄적 인 해결책으로 권장하지 않습니다. 무용담이 정확하고 예측 가능하게 실패하고 오류를 처리 / 기록하는 것이 더 합리적 일 가능성이 높습니다.
예를 들어 @ajwhite는이 시나리오를 사가를 살아있게 유지하면 해결하는 것보다 더 많은 문제가 발생하는 경우를 제공했습니다.
function* sagaThatMayCrash () {
// wait for something that happens _during app startup_
yield take(APP_INITIALIZED)
// assume it dies here
yield call(doSomethingThatMayCrash)
}
sagaThatMayCrash
가 다시 시작되면 다시 시작되고 응용 프로그램이 시작될 때 한 번만 발생하는 작업을 기다립니다. 이 시나리오에서는 다시 시작되지만 복구되지 않습니다.
그러나 시작으로 이익을 얻을 수있는 특정 상황에 대해 user @granmoe는 issue #570에서 다음과 같은 구현을 제안했습니다.
function* rootSaga () {
const sagas = [
saga1,
saga2,
saga3,
];
yield all(sagas.map(saga =>
spawn(function* () {
while (true) {
try {
yield call(saga)
break
} catch (e) {
console.log(e)
}
}
}))
);
}
이 전략은 우리의 sagas를 try 블록의 하위 작업으로 시작하는 생성 된 생성기 (루트 부모에서 분리)에 자식 sagas를 매핑합니다. 우리의 saga는 종료 될 때까지 실행 된 다음 자동으로 다시 시작됩니다. catch 블록은 우리의 사가에서 던지고 종료되었을 수있는 모든 오류를 무해하게 처리합니다.