이번 포스팅에서는 redux middleware 중의 하나인 redux-saga에 대해 알아보도록 하겠다. 혹시 redux middleware에 대해 잘 모르고 있다면 아래 글을 참고하길 바란다.
2022.05.07 - [React] - Redux - redux middleware
< 목차 >
- Generator 함수
- redux-saga
1. Generator 함수
redux-saga에 대해 알아보기 전에 제너레이터(Generator) 함수에 대해 짚고 넘어가야할 필요성이 있다. redux-saga에서 제너레이터 함수를 사용하고 있기 때문이다.
제너레이터 함수의 핵심 기능은 함수를 특정 구간에 멈춰 놓을 수도 있고 원할 때 다시 돌아가게 할 수도 있다는 점이다. 뿐만 아니라 제너레이터 함수를 사용하면 결과값을 여러번 반환하는 것 역시 가능하다.
예를 들어 아래와 같은 함수가 있다고 가정해보자.
function foo() {
return 1;
return 2;
return 3;
return 4;
return 5;
}
함수에서 여러번에 걸쳐 값을 반환하는 것은 불가능하다. 위에 있는 foo( ) 함수를 호출할 경우 항상 1을 return하게 될 것이다. 하지만 제너레이터 함수를 사용하면 함수에서 값을 순차적으로 return 할 수 있다. 더 나아가 함수의 실행을 도중에 멈춰 놓았다가 나중에 이어서 진행할 수도 있다. 위의 함수를 다음과 같이 작성해보도록 하자.
// 제너레이터 함수
function* generatorFunction() {
console.log('hello')
yield 1
console.log('generator function')
yield 2
console.log('function*')
yield 3
return 4
}
// 제너레이터
const generator = generatorFunction()
제너레이터 함수를 만들 때 주의해야 할 것은 function* 이라는 키워드를 이용해 함수를 만들어야 한다는 점이다. 그리고 제너레이터 함수를 호출했을 때 한 객체가 반환되는데 이 때 반환되는 객체가 "제너레이터"이다.
제너레이터 함수는 해당 함수를 호출했다고 해서 함수 안의 코드들이 바로 실행되지 않는다. 함수 호출 후 반환 받은 객체 안의 next( ) 메소드를 이용해 함수를 실행시킨다. 위의 코드에서는 generator.next( )와 같은 형태로 함수를 실행시키게 된다.
const generator = generateFunction()
// 제너레이터 함수 실행시키기
generator.next()
generator.next( ) 를 호출해야만 코드가 실행되며, yield 를 한 값을 반환하고 코드의 흐름을 멈추게 된다.
그리고 generator.next( ) 의 return 값은 { value: ' ', done: boolean } 형태가 된다. 아래의 결과는 크롬 개발자 도구의 콘솔에서 generator.next( )를 호출해 본 결과이다.
위와같이 제너레이터 함수는 yield 를 기준으로 해서 다음 진행 여부 혹은 진행 시점을 제어할 수 있다. 그리고 yield 뒤에 위치한 값이 return 된 객체의 value값으로 들어가는 것을 확인할 수 있는데 이러한 yield의 성질을 이용해서 다음과 같은 작업 역시 가능하다.
function* multiplyGenerator() {
console.log('start')
let a = yield
console.log('a is defined')
let b = yield
console.log('b is defined')
yield a * b
}
const generator = multiplyGenerator()
제너레이터에서 next( )를 호출할 때 인자값을 전달할 수 있는데 이때 전달된 인자값이 yield로 치환된다. 제너레이터 함수 안의 let a = yield , let b = yield 에 의해 generator.next( ) 의 인자값으로 전달한 2와 3이 각각 a, b에 들어가게 되고 마지막으로 generatort.next( ) 를 호출했을 때 a * b가 반환된 객체 안의 value로 들어가게 되는 것이다. 이처럼 next( )를 호출할 때 인자값을 전달하면 이를 제너레이터 함수 내부에서 사용하는 것이 가능하다.
redux-saga에서는 위와 같은 제너레이터 함수를 사용해서 action을 모니터링 하게 된다. 아래의 예시 코드를 살펴보면서 제너레이터를 통해 어떠한 방식으로 모니터링이 이루어지고 있는지 알아보도록 하자.
function* watchGenerator() {
console.log('start monitoring')
while (true) {
const action = yield
if (action.type === 'HELLO') {
console.log('hihi')
}
if (action.type === 'BYE') {
console.log('byebye')
}
}
}
const generator = watchGenerator()
redux-saga는 이러한 원리로 action을 모니터링 하고 특정 action이 발생했을 때 우리가 원하는 JavaScript 코드를 실행시켜 준다.
2. redux-saga
이제 본격적으로 redux-saga를 어떻게 사용하는지 알아보도록 하자. 기존에 만들었던 카운터 버튼에서 setTimeout을 이용해 강제로 비동기적 처리를 해준 다음 redux-saga가 어떤식으로 비동기 처리를 하는지 살펴보도록 하겠다.
우선 src 디렉토리 구조는 다음과 같다.
아래는 useStore.jsx 파일이다. 해당 파일 안에서는 전역 상태를 관리할 store를 만들어 준 다음 enhancer 를 createStore의 인자값으로 전달해 middleware를 넣어주었다.
// useStore.jsx 파일
import { createStore, compose, applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import createSagaMiddleware from 'redux-saga'
import rootReducer from '../reducers'
import rootSaga from '../sagas'
const sagaMiddleware = createSagaMiddleware()
const middleware = [sagaMiddleware]
const enhancer = process.env.NODE_ENV === 'production'
? compose(applyMiddleware(...middleware))
: composeWithDevTools(applyMiddleware(...middleware))
const store = createStore(rootReducer, enhancer)
sagaMiddleware.run(rootSaga)
const Store = ({children}) => {
return (
<Provider store={store}>
{children}
</Provider>
)
}
export default Store;
- redux-saga 라이브러리에서 import 해 온 createSagaMiddleware를 이용해 sagaMiddleware를 만들어준다.
- compose 함수와 applyMiddleware 함수를 이용해 sagaMiddleware를 적용해준 다음 enhancer 변수에 담는다.
- enhancer를 만들 때 "production" 모드가 아닐 시 composeWithDevTools( )를 사용해 redux 개발자 도구를 사용할 수 있도록 해주었다.
- sagaMiddleware.run( ) 메소드의 인자값으로 import 해 온 rootSaga를 넣어준다.
redux-saga는 action을 dispatch 했을 때 제너레이터 함수들을 전부 실행시키는 구조로 돌아가기 때문에 sagaMiddleware.run( )의 인자값으로 실행시킬 함수들을 넣어줘야 한다.
이제 sagas 디렉토리 안에 있는 index.js 파일과 counterSaga.js 파일을 살펴보자.
// sagas/index.js 파일
import { all } from 'redux-saga/effects'
import watchCounter from './counterSaga.js'
export default function* rootSaga() {
yield all([
watchCounter()
])
}
index.js 파일을 살펴보면 rootSaga 함수가 제너레이터 함수로 만들어져 있는 것을 확인할 수 있다. saga는 제너레이터 함수를 이용해 action을 모니터링 하기 때문에 rootSaga는 제너레이터 함수가 된다. 또한 redux-saga/effects에서 all 이라는 이펙트 함수를 import 해온 다음 rootSaga 함수 안에서 사용되고 있는 것을 볼 수 있다.
redux-saga에는 saga의 활용을 돕기 위한 다양한 effects들이 존재한다. saga는 제너레이터 함수로부터 JavaScipt 객체를 yield하는데 이 객체들을 effects라고 부른다. 이 effects들은 middleware에서 활용할 수 있는 정보들을 담고 있는 JavaScript 객체의 일종으로 effects들을 사용하면 saga를 보다 효과적으로 사용할 수 있게 된다. 다음은 redux-saga에서 자주 사용되는 대표적인 effects들이다.
👉 all
all effect는 제너레이터 함수들이 들어있는 배열을 인자값으로 받는다. 이렇게 들어온 제너레이터 함수들은 all effect 안에서 병렬적으로 기능을 수행하며 이 함수들이 모두 resolve 될 때까지 기다린다.
👉 call
call effect는 함수를 실행시키는 effect이다. call( )의 첫번째 인자값으로는 실행시킬 함수를 넣고 optional로 나머지 인자에 실행시킬 함수에 넣을 인자를 넣을 수 있다.
👉 fork
fork effect 역시 call 과 마찬가지로 함수를 실행시키는 effect이다. call 과 fork의 차이점은 fork의 경우에는 함수를 비동기적으로 실행하며 call의 경우에는 함수를 동기적으로 실행한다는 점이다. 따라서 순차적으로 함수가 실행되어야 하는 API 요청 함수 등의 경우에는 call을 사용하며 그 외의 비동기 로직에는 fork를 사용한다.
👉 put
put effect는 특정 action을 dispatch 하는 effect이다. put( )을 사용해서 제너레이터 함수 내부에서 특정 action을 dispatch 할 수 있다.
👉 takeEvery / takeLatest
takeEvery 와 takeLatest 는 인자로 들어온 action에 대해 특정 로직을 실행시켜주는 effect이다. takeEvery 와 takeLatest 의 차이는 takeEvery의 경우 인자로 들어오는 모든 action에 대해 로직을 실행시켜주는 반면, takeLatest는 기존에 실행 중이던 작업이 있을 경우 이를 취소하고 가장 마지막으로 실행된 작업만을 실행시켜준다는 점이다.
// sagas/counterSaga.js 파일
import { takeLatest, takeEvery, call, put } from 'redux-saga/effects'
async function upAPI(payload) {
// console.log(payload)
// axios
return new Promise((resolve, reject)=>{
setTimeout(()=>{
resolve(true)
}, 1000)
})
}
async function downAPI(payload) {
// console.log(payload)
// axios
return new Promise((resolve, reject)=>{
setTimeout(()=>{
resolve(true)
}, 1000)
})
}
function* counterDown(action) {
try {
const result = yield call(downAPI, action.payload)
yield put({
type: 'COUNTER/DOWN_SUCCESS'
})
} catch (err) {
yield put({
type: 'COUNTER/DOWN_FAILURE'
})
}
}
function* counterUp(action) {
try {
const result = yield call(upAPI, action.payload)
yield put({
type: 'COUNTER/UP_SUCCESS'
})
} catch (err) {
yield put({
type: 'COUNTER/UP_FAILURE'
})
}
}
export default function* watchCounter() {
yield takeLatest('COUNTER/UP_REQUEST', counterUp)
yield takeLatest('COUNTER/DOWN_REQUEST', counterDown)
}
추가적으로 설명을 덧붙이자면 action을 dispatch 했을 때 index.js 파일의 function* rootSaga( ) { } 안에 있는 all effect로 묶인 watchCounter( ) 함수가 실행된다. 실행되는 watchCounter 함수 역시 제너레이터 함수이며 yield takeLatest( )로 action을 확인해서 특정 로직을 실행시켜준다.
takeLatest( ) 함수의 첫번째 인자값으로는 action 객체의 type 내용을 넣어주고 두번째 인자값으로는 해당 action type 내용이 일치할 경우 호출할 함수를 넣어주면 된다. 이 때 호출하는 함수 역시 제너레이터 함수가 된다. 정리하면, 첫번째 인자값으로 전달한 type 내용이 일치했을 때 두번째 인자값으로 들어온 함수가 호출되고 이 함수는 action을 전달받게 된다.
위의 예시 코드에서는 action type 내용이 "COUNTER/UP_REQUEST" 일 때 counterUp 함수가 호출되고 counterUp 함수는 인자값으로 action을 받는다. counterUp 함수 안에서는 call effect를 사용해 함수를 실행시킬 수 있는데 이때 call( )의 첫번째 인자값으로 실행시킬 함수를 넣어주고 나머지 인자에는 실행시킬 함수에 들어갈 인자값을 넣어준다. 예시 코드에서 작성된 call( upAPI, action.payload ) 는 upAPI 함수를 호출하고 upAPI 함수의 인자값으로 action.payload를 전달한다.
이제 upAPI 함수 안에서 axios를 사용해 백엔드 API로부터 데이터를 가져오면 된다. 예시 코드에서는 Promise 와 setTimeout을 사용해 1초 후에 resolve 되도록 처리하여 백엔드 API로부터 데이터를 가져오는 비동기 작업을 대체하였다.
upAPI에서 비동기 처리가 성공적으로 완료되었다면 counterUp 함수에서 yield call( ) 아래에 작성된 yield put( )이 실행된다. put effect를 사용하면 action을 dispatch 할 수 있는데 제너레이터 함수 안에서 action을 dispatch 할 때는 put effect 를 사용한다는 것에 유의하도록 하자.
마지막으로 reducers 디렉토리 안에 있는 index.js 파일과 counter.js 파일, 그리고 pages 디렉토리 안에 있는 Counter.jsx 파일을 살펴보자.
// reducers/index.js
import { combineReducers } from "redux";
import counter from './counter.js'
const rootReducer = combineReducers({
counter
})
export default rootReducer;
// reducers/counter.js
const initialState = {
number: 0,
loading: false,
error: null
}
const UP = 'COUNTER/UP_REQUEST'
const DOWN = 'COUNTER/DOWN_REQUEST'
export const up = (payload) => ({type: UP, payload})
export const down = (payload) => ({type: DOWN, payload})
const counter = (state = initialState, action) => {
switch (action.type) {
case 'COUNTER/UP_REQUEST' :
return {
...state,
loading: true,
error: null
}
case 'COUNTER/UP_SUCCESS' :
return {
...state,
loading: false,
number: state.number + 1
}
case 'COUNTER/UP_FAILURE' :
return {
...state,
loading: false,
error: '에러 발생'
}
case 'COUNTER/DOWN_REQUEST' :
return {
...state,
loading: true,
error: null
}
case 'COUNTER/DOWN_SUCCESS' :
return {
...state,
loading: false,
number: state.number - 1
}
case 'COUNTER/DOWN_FAILURE' :
return {
...state,
loading: false,
error: '에러 발생'
}
default :
return state
}
}
export default counter;
// pages/Counter.jsx
import { useCallback } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { up, down } from '../reducers/counter.js'
const Counter = () => {
const dispatch = useDispatch()
const counter = useSelector( (state) => state.counter )
const onUp = useCallback(()=>{ dispatch(up('카운터 업')) }, [dispatch])
const onDown = useCallback(()=>{ dispatch(down()) }, [dispatch])
return (
<>
<h1>Counter : {counter.number}</h1>
<p></p>
{
counter.loading
? '로딩중 입니다.'
: <>
<button onClick={onUp}>+1</button>
<button onClick={onDown}>-1</button>
</>
}
</>
)
}
export default Counter;
(+1) 버튼을 눌렀을 때 { type: "COUNTER/UP_REQUEST", payload } 의 action이 dispatch 되면서 sagaMiddleware가 실행된다. sagaMiddleware 안에서 백엔드 API 와 비동기 통신을 시작하며 이와 동시에 reducer가 실행되고 action type 내용이 "COUNTER/UP_REQUEST" 일 때에 알맞은 상태로 변경된다. 백엔드 API와의 비동기 통신이 성공적으로 완료되면 sagaMiddleware에서 put effect로 다시 { type: "COUNTER/UP_SUCCESS" }의 action을 dispatch 하게 되고 다시 reducer가 실행되어 action type에 알맞게 상태를 변경해준다. 결국, middleware를 사용할 경우 두번에 걸쳐 action이 dispatch 된다는 사실을 인지하고 있도록 하자.
실제 카운터 버튼이 동작되는 것을 redux 개발자 도구를 이용해 살펴보면 type 내용이 "COUNT/UP_REQUEST"인 action이 dispatch 되고 나서 1초 후에 type이 "COUNT/UP_SUCCESS"인 action이 dispatch되는 것을 확인할 수 있다.
'React' 카테고리의 다른 글
Redux - redux-persist 세팅 (0) | 2022.05.10 |
---|---|
Redux - redux-actions / immer (0) | 2022.05.10 |
Redux - redux middleware (0) | 2022.05.07 |
React - react-redux (0) | 2022.05.03 |
React - react-router-dom (0) | 2022.05.02 |