아래 영문 기사, 포스트를 참고하여 작성한 글입니다.
Getting Started with Redux Saga
Why You Should Use Redux Saga
리액트도 잘 몰라서 이제 기초 강의 듣는 나에게 팀원들이 Redux Saga
와 TypeScript
로 프로젝트를 진행한다고 해서... 이번 주 주제를 이렇게 잡았습니다. ㅋㅋ
우선 Redux Saga
를 이해하려면 Redux
를 알아야 한다고 생각했습니다. 저는 리덕스도 잘 몰랐기에 리덕스에 대해 먼저 찾아봤었는데요, 제가 리덕스를 이해하는데 많은 도움이 되었던 영상을 남깁니다.
개인적인 감상으로는 vuex
와 크게 다르지않군!이었습니다.
Redux를 쓰는 이유
1. props 쓰기 귀찮아서
2. state 변경 및 관리하기 좋아서
Saga(사가) 디자인 패턴은 분산 트랜잭션에서 마이크로 서비스 간 데이터 일관성을 관리하는 방법입니다.
Saga사가는 각 서비스를 업데이트하고 메시지 또는 이벤트를 게시(publish)하여 다음 단계의 트랜잭션이 시작될 수 있게 하는 트랜잭션 시퀀스입니다.
-Microosft Azure Documentations-
트랜잭션은 더이상 쪼갤 수 없는 업무의 최소 단위이자 데이터 상태에 변화를 주기 위해 수행하는 작업 단위라고 보시면 됩니다.
마이크로 서비스란 작은 팀이 관리하고, 유지보수하기 쉽고, 독립적으로 사용할 수 있는 등의 특징을 가진 애플리케이션 아키텍쳐 스타일을 말합니다.
saga는 트랜잭션을 구현하는데 도움이 되고, saga pattern은 애플리케이션 내의 문제(side effects)를 처리하는 방식으로 설계되었습니다.
그래서인지, Redux Saga의 공식 홈페이지에서는 이 프레임워크를 다음과 같이 설명하고 있습니다.
사가 리덕스는 애플리케이션의 부작용(side effects)를 쉽게 관리하고, 효율적으로 실행하고 테스트하며 오류(failures) 처리를 편하게 만들어줍니다.
계속해서 사이드 이펙트 이야기를 하는 이유는 간단합니다. Redux 내에는 이런 사이드 이펙트를 관리하는 도구가 없기 때문입니다. 이를 해결하기 위해 미들웨어가 필요하고, 가장 유명하고 널리 쓰이는 것이 thunk와 saga 입니다.
대부분의 프로젝트들은 Redux Thunk를 사용해서 사이드 이펙트를 관리하고 있습니다. thunk 덕분에 코드를 읽고 테스트하기 쉬워졌죠. 하지만 어떤 함수들에서는 코드가 더 읽기 어려워졌고 그만큼 테스트도 어려워졌습니다.
이런 상황에서 등장한 것이 Redux Saga입니다. Redux Thunk에 의해 처리 되는 함수를 디스패치하는 것이 아니라 saga를 만들고 거기에 모든 로직을 작성해둡니다. thunk는 디스패치 할 때 호출이 된다면, 사가는 앱이 시작되는 순간 늘 뒤에서 돌아가고 있습니다. (5분 대기조처럼요)
그래서 Redux Thunk와 달리 콜백 지옥에 빠지지 않고 비동기 흐름을 더 쉽게 테스트할 수 있습니다.
이에, 사가의 장점을 다음과 같이 말할 수 있겠죠.
더 잘 이해하기 위해서 음식 주문 애플리케이션을 만드는 상황을 예시로 들어보겠습니다.
아마 다음과 같은 것들을 고려해야 할 거예요.
Redux 내 reducer들을 root reducer에서 모아두듯이 사가에서는 다른 모든 saga들을 모아두는 root saga가 있습니다.
function* rootSaga() {
yield all([
menuSaga(),
checkoutSaga(),
userSaga()
])
}
rootsaga
는 가장 기본이 되는 부분입니다. sagaMiddleware.run(rootSaga)로 전달되는 사가기도 하고요. menuSaga
, checkoutSaga
, 그리고 userSaga
는 slice sagas라고 불립니다. 각자 사가 구조(saga tree)에서 하나의 섹션 또는 슬라이스를 다룹니다.
all()
은 redux-saga
에서 effect creater라고 말할 수 있습니다. 이들은 사가를 만들기 위해서 사용하는 필수적인 함수입니다. 각각의 effect creator는 객체(effect)를 반환하고, 이들은 redux-saga
미들웨어에서 사용됩니다. 그래서 Redux actions와 action creators의 이름을 비슷하게 지어야 합니다.
effect creators에는 아주 많은 종류가 있지만, 이번에는 all()
에 대해서만 다뤄보겠습니다. all()
은 effect creator로, 사가에게 모든 사가들을 실행시키고, 완료될 때까지 기다리라고 전달합니다.
서브 사가 중 하나의 기본 구조를 살펴보겠습니다.
import { put, takeLatest } from 'redux-saga/effects'
function* fetchMenuHandler() {
try {
// Logic to fetch menu from API
} catch (error) {
yield put(logError(error))
}
}
function* menuSaga() {
yield takeLatest('FETCH_MENU_REQUESTED', fetchMenuHandler)
}
우리의 슬라이스 사가 중 하나인 menuSaga를 살펴보겠습니다. 스토어에 dispatch하는 여러가지 액션 타입들을 지켜보고 있습니다. 예를 들어서, 우리가 API를 통해서 메뉴 하나를 가져오려고(fetch) 합니다. 그럼 우리 애플리케이션은 어딘가에서, action이 FETCH_MENU_REQUESTED
과 함께 dispatch 될 거예요. menuSaga는 기다리고 있다가 takeLatest
라는 action이 사용되는 걸 감지하고 그걸 본 순 간 fetchmenuHandelr
함수를 실행할 겁니다. 이 때문에 이러한 타입의 saga를 watcher saga라고 부르는 거예요.
요약해서 다시 말하면, watcher saga는 액션이 발생하는지 지켜보고 있다가, handler saga를 실행시킨다는 거예요.
우리가 handeler 함수를 사용할 때, try/catch를 사용해서 성공할 때와 실패할 때에 대한 응답을 다르게 처리한 다는 걸 알 겁니다.
그동안은 아마 이렇게, 에러는 따로 처리했을 거예요.
const logError = error => ({
type: 'LOG_ERROR',
payload: { error }
})
fetchMenuHandler
에 약간의 로직을 더해보겠습니다.
function* fetchMenuHandler() {
try {
const menu = yield call(myApi.fetchMenu)
yield put({ type: 'MENU_FETCH_SUCCEEDED', payload: { menu } ))
} catch (error) {
yield put(logError(error))
}
}
우리가 menu data API를 요청할 때, HTTP 클라이언트를 사용하게 됩니다. 이 때 action이 아닌 비동기 함수를 call하므로 call()
을 사용하게 되고요. 만약 전달할 인자가 있다면, call()
의 두 번째 자리에 넣게 됩니다. 예를 들면 call(myApi.fetchMenu, authToken)
이런 식이죠.
우리의 generator 함수 fetchMenuHandler
는 yield를 사용해 스스로를 잠시 멈추고, myApi.fetchMenu
를 통해 응답을 받을 때까지 기다립니다. 그러고 나서 put()
을 사용해 다른 action을 dispatch해서, 메뉴를 유저에게 렌더링해서 보여주게 됩니다.
이를 그대로 다른 sub-saga인 checkoutSaga
에 적용해보겠습니다.
import { put, select, takeLatest } from 'redux-saga/effects'
function* itemAddedToBasketHandler(action) {
try {
const { item } = action.payload
const onSaleItems = yield select(onSaleItemsSelector)
const totalPrice = yield select(totalPriceSelector)
if (onSaleItems.includes(item)) {
yield put({ type: 'SALE_REACHED' })
}
if ((totalPrice + item.price) >= minimumOrderValue) {
yield put({ type: 'MINIMUM_ORDER_VALUE_REACHED' })
}
} catch (error) {
yield put(logError(error))
}
}
function* checkoutSaga() {
yield takeLatest('ITEM_ADDED_TO_BASKET', itemAddedToBasketHandler)
}
어떤 음식이 장바구니에 추가 됐을 때, 우리는 여러가지 변수나 상황들을 확인해야 합니다. 유저가 담은 음식이 현재 구매 가능한 지(판매자 입장에서는 판매 가능한 지), 주문에 필요한 최소 주문 값에 도달했는지 등 말이죠.
다시 말하지만, Redux Saga는 사이드 이펙트를 다루기 위한 도구입니다. 꼭 상품을 장바구니에 추가할 때 꼭! saga를 사용해야한다는 뜻은 아니란 겁니다. 이런 상황에서는 reducer를 사용하는 것이 더 낫습니다. 단순한 reducer 패턴이 더 현재 상황에 잘 맞기 때문입니다.
여기서는 새로은 effect인 select()
를 사용하면 됩니다. 이는 selector를 전달하고, Redux store에 있는 일부를 갱신합니다.
saga를 통해서 우리는 store에 있는 그 무엇이든 변경할 수 있으며, 다양한 context들이 하나의 saga와 연결되어 있을 때 이 방식은 매우 유용합니다.
selector는 우리가 함수를 통해서 state를 전달하고 해당 state의 일부분을 단순하게 반환할 때 사용하는, Redux에 만들어져 있는 디자인 패턴입니다.
예를 들면 다음과 같습니다.
const onSaleItemsSelector = state => state.onSaleItems
const basketSelector = state => state.basket
const totalPriceSelector = state => basketSelector(state).totalPrice
Selector는 전역 state의 일부에 접근할 수 있는, 신뢰 가능하고 일관된 방법입니다.