Redux Thunk vs Redux Saga

주로 redux saga에 대한 이야기 입니다. Thunk는 비교군으로 나오는 문서 입니다. Redux사가에 대한 간단한 설명 및 예제가 포함되어 있습니다.

Thunk VS Saga

thunk와 saga의 공통점은, asynchronous하게 flow가 일어나는 경우를 처리할 수 있게 도와준다이고,
thunk
장점은? → 매우 간단하다!
단점은? → 초보가 작업을 하게 되면 Action creator에서 콜백 hell에 빠질 수 있다
unit test시에 불편함이 있다. 연결이 다 되어 있다보니, 잘라서 테스트가 쉽지 않다.
saga에서 제공해주는 것들이 없다 (단점이 아닐 수도 있지만, saga의 편의성이 있기때문에 적어보았습니다)
saga
장점은? → Action이 매우 깔끔하게 디자인할 수 있습니다. (정말 크죠) && 테스트에 매우 용이 합니다.
단점은? → 처음에 셋업할게 좀 있으며, generator등의 새로운 개념도 숙지 하셔야 합니다. (공부할게 추가!) → 하지만, 한 번 셋업을 해두면, 어렵지 않습니다.
유닛테스트를 많이 안하시는 분이고 빠르게 간단한 서비스를 런칭을 한다면 thunk가 나쁘지 않습니다. 하지만 서비스가 진지해져야 한다면 (진지 = 복잡) saga를 시도 하시는 것을 추천 합니다.
Thunk나 Saga를 미들 웨어라고 하는데, middleware는 뭘까요?

What is Redux Middleware?

보통 미들웨어는 원래 기본 구조에서 추가 extension을 통해, 원래 할 수 없었던 일들을 추가적으로 할 수 있게 해주는 layer 혹은 stage라고 생각하시면 됩니다. Redux Middleware는 action과 리듀서에 들어가는 그 순간까지를 담당 해주는 친구죠. 주로 asnchronous API, 혹은 테스트, 에러 크레시 레포트등을 이곳에서 담당합니다.

Thunk

바로 한 번 코드를 볼까요?
const thunkExample = () => { return async function (dispatch) => { const items = await fetch("https://www.~~~~~") if (items) { dispatch({ type: "ADD", payload: items.json() }) } } }
JavaScript
우리는 컴포넌트에서 thunkExample이라는 함수를 호출을 할겁니다. 그렇게 되면, thunk가 async하게 API콜도 하고, 그 후에, 최종적으로는 synchronous 한 action을 dispatch해주게 합니다.
{type: "ADD_STATE", payload: items.json()} //-> 요것이 액션!
JavaScript
Thunk활용 법은, 정식 라이브러리를 깃에서 확인해보시면 됩니다.

Saga

사가또한 thunk처럼 asynchronous하게 들어오는 요청을 잘 처리 하는 것이 주목표인 미들웨어 입니다. 여기서 javascript ES6의 개념중 하나인 generators를 활용합니다.
generators의 가장큰 장점은, 중간에 엑스큐션을 멈췄다가, 끝낼 수도 있고, 다시 시작할 수 도 있습니다. (오호!) 실전에서 이게 왜 필요할까요? (실전에서 이 일들이 발생하는 사례 케이스는 다음에 한 번 만들어 보겠습니다)
generator가 궁금하신 분은 간단히 아래 링크를 참고하시면 됩니다.
generator를 직접적으로 우리가 사용을 할일은 없지만 용법은 아래와 같습니다.
function* someGeneratorFunction() { yield('good') yield('better') } let itr = someGeneratorFunction(); console.log(itr.next()) // {value: "good", done: false} console.log(itr.next()) // {value: "better", done: false} console.log(itr.next()) // {value: undefined, done: true}
JavaScript
저희가 saga를 generator를 직접 콜을 해서 .next()를 실행할 일은 없습니다. generator를 설명하고나 직접 실행했을때를 보여주는 용도로 작성된 코드 입니다
실제로 사용법의 예시 하나를 코드로 가져와보았습니다.
import {call, put, takeEvery, all,} from 'redux-saga/effect'; import Api from '...' //<-- 여기는 각자마다 경로가 다르겠죠! function* fetchUser(action) { try{ const userData = yield call(Api.fetchUser, action.payload.userId); yield put({type: 'USER_FETCH_SUCCEEDED', payload: userData.json()}); } catch (e) { yield put({type: "USER_FETCH_FAILED", message: e.message.}); } } function* mySaga() { yield takeEvery("USER_FETCH_REQUESTED", fetchUser); } export default function* rootSaga() { yield all([ mySaga(), //다른 사가도 여기에 추가 하기! ]) }
JavaScript
오우, 이 무슨 코드인가! 라고 생각하실 분들을 위해, 한 번 천천히 보시죠.
일단 동작 사이클 부터 볼까요?
1.
일단 synchronous action을 하나 요청합니다.
예를 들어, 내 정보를 불러와야 하는 페이지에서 다음과 같은 함수를 실행해봅시다.
dispatch({type: 'USER_FETCH_REQUESTED', payload: {userId}})
JavaScript
항상 하던대로, dispatch안에는 plain javascript object가 들어옵니다. 페이로드에 userId를 가지고 있습니다.
2.
자 이제 우리의 Saga에 mySaga라는 이름의 포인트 함수로 만들어 놓은 곳을 잘 보면, takeEvery라는게 있습니다. 뜻하는게 뭐냐면, USER_FETCH_REQUESTED가 dispatch되면, 항상 fetchUser를 실행하라는 말 입니다. 요 함수는 바로 위에 작성을 해두었습니다.
3.
최종적으로 결과가 나오게 되면 USER_FETCH_SUCCEEDED나 USER_FETCH_FAILED 둘중에 하나의 액션이 크리에잇되고, 리듀서에서 이값을 처리 합니다.
call, yield, takeEvery, put등을 이렇게 영접하다니.. 차근차근 한 번 보실까요?
put: creates the dispatch effect.
TakeEvery: 첫번째 인자에 들어온 type이 실행이 될 때마다, 두번째 인자의 generator가 실행 되게 합니다.
yield: 형성이라고 생각하시면 됩니다.
문법 자체를 이해 할 필요까지는 없습니다. 한 번 쭉 보시고 추후엔 기계적으로 하면 됩니다.
사가에서는 Generator에서 yield를 하게 되면 plain javascript objects인 이 object를 우리는 effects라고 합니다. 이 effect는 사가 미들웨어에서 이해할 수 있는 정보를 담고 있습니다. effect를 미들웨어가 이해할 수 있는 명령어정도로 생각하시면 됩니다. (예를 들어, 비동기 함수 하나를 실행해서, 그 결과에 맞춰 action을 하나 만들어서, 넘겨버렷) 주로, promise가 effect의 form인 경우가 많습니다.
call은 call(fn, ...args) 함수인데, 이것이 해주는 역할은, effect의 상세주문서를 만들어주는 것 입니다. 이 상세 주문서를 넣으면, 사가 미들웨어에서 처리해서 response값을 얻게 됩니다.
핵심은, generator함수내에서, dispatch를 직접하지말고, api콜을 직접하지도 말자 (예를 들어 axios를 이런곳에 넣지않는 것이죠 → 넣게 되는 순간 saga를 제대로 활용하지 않는다고 보시면 됩니다.)
그래서 dispatch 대신에 put 을 yield해서, dispatch도 모두 saga에서 처리하게 하도록 합니다. dispatch는 미들웨어에서 합니다.
import { createStore, applyMiddleware } from 'redux' import createSagaMiddleware from 'redux-saga' import reducer from './reducers' import rootSaga from './sagas' // Create the saga middleware const sagaMiddleware = createSagaMiddleware() // Mount it on the Store const store = createStore( reducer, applyMiddleware(sagaMiddleware) ) // Then run the saga sagaMiddleware.run(rootSaga)
JavaScript
사가 미들웨어의 장점은 테스터빌리티에 있는데, 이는 다른 문서에서 한 번 다뤄보겠습니다. 일단 한줄 평을 하면, saga의 경우는 모든 것이 plain object로 형성이 되어 있기 때문에, 테스트 할 때도 코드의 일관성덕분에 매우 용이합니다. 반면 thunk는 테스트환경을 조성하는게 만만치 않죠.
이상, 스터디를 하다가 누군가가 thunk와 saga의 차이점좀 알려주세요 라고 해서,, 어쩌다보니 작성하게된 글이었습니다.
Thunk는 따로 예시를 준비하지 않겠습니다. Saga의 예시만 확인해주세요.

Simple Saga Example

예제에서 asynchronous 한 상황은, 유저가 로긴을 하는 때에, 유저의 정보를 테이블에서 가져와서 리스판스가 있을 때에 값을 json형태로 state에 업데이트 해주는 상황 입니다. 이를 아래처럼 간단히 구현해볼 수 있습니다.
아래의 dispatch를 실행을 컴포넌트나 useEffect등에서 실행합니다.
dispatch({type: 'USER_FETCH_REQUESTED', payload: {uid, history}})
JavaScript
이후, saga가 잘 run하고 있다면 mySaga는 "USER_FETCH_REQUESTED" dispatch가 될 때마다, fetchUser task를 형성 합니다. mySaga 같은 것을 watcher saga라고 하고, fetchUser를 worker saga라고 합니다. Saga는 generator function으로 구성되는데, 이는 object를 yield 하여 redux-saga 미들웨어에 보내줍니다. 만약 promise가 미들웨어에 yielded된다면, saga는 프로미스가 끝날 때까지 기다렸다가 그다음이 실행이 됩니다. 예를 들어, call(firebaseLogin, action.payload.uid) 가 프로미스이고, 이 작업이 끝나고 난 다음에 그다음 라인인 put({..}) 이 실행이 됩니다.
src/sagas/index.js
import { call, put, takeEvery } from 'redux-saga/effects' import {firebaseLogin} from './functions'; function* fetchUser(action) { try { const user = yield call(firebaseLogin, action.payload.uid); yield put({type: "SET_USER_PROFILE", payload: user.data()}); action.payload.history.push("/createChat"); } catch (e) { yield put({type: "USER_FETCH_FAILED", message: e.message}); } } function* mySaga() { yield takeEvery("USER_FETCH_REQUESTED", fetchUser); } export default mySaga;
JavaScript
여기서 yield는 call, put등의 함수 앞에 항상 붙입니다. call의 input 인자는 첫번째는 함수, 두번째부터는 첫번째 함수를 위한 인풋 입니다. 저희는 firebaseLogin이라는 함수의 input은 하나 취하고 있습니다. (아래의 자세한 함수가 작성되어 있습니다) yield call이 끝날 때까지 기다렸다가 const user에 저장이되고, 해당값을 put합니다. 최종적으로 리듀서에 전달이 되서 userProfile이 업데이트가 됩니다. 참고로 put도 plain javascript object의 일종입니다. (promise도 당연히 plain javascript object입니다) call도 마찬가지 입니다.
src/sagas/functions.js
import {db} from '../firebase'; export const firebaseLogin = async (uid) => { return db.collection('user').doc(uid).get() } export const getLandingCourses = async () => { const res = await fetch('https://api.ringleplus.com/api/v4/student/landing/course?locale=en'); const data = res.json() return data }
JavaScript
이곳에 바로 더 다양한 promise를 추가 하면 됩니다. 일반적으로 API request를 추가 할텐데, 저희는 해당 예제에서는 firebase를 활용하고 있기에, firebase에서 제공하는 promise를 구현하였습니다.
getLandingCourses는 링글 랜딩 api를 fetch하여 데이터를 받아오게 하는 함수 입니다. async로 선언을 하고 함수 내부는 await를 써서 return data가 될 때에 우리가 원하는 json형태의 데이터가 되도록 합니다.
src/firebase.js
import firebase from "firebase"; const firebaseApp = firebase.initializeApp({ apiKey: process.env.REACT_APP_FIREBASE_KEY, authDomain: process.env.REACT_APP_authDomain, projectId: process.env.REACT_APP_projectId, storageBucket: process.env.REACT_APP_storageBucket, messagingSenderId: process.env.REACT_APP_messagingSenderId, appId: process.env.REACT_APP_appId }); const db = firebaseApp.firestore(); export { db, firebaseApp, firebase};
JavaScript
saga는 한개만 등록하는게 아니라 여러개 할 수 있습니다.
import { call, put, takeEvery, all} from 'redux-saga/effects' import {firebaseLogin, someGetExample, getLandingCourses} from './functions'; function* fetchUser(action) { try { const user = yield call(firebaseLogin, action.payload.uid); yield put({type: "SET_USER_PROFILE", payload: user.data()}); action.payload.history.push("/createChat"); } catch (e) { yield put({type: "USER_FETCH_FAILED", message: e.message}); } } function* someOtherWorkerSaga(action) { try{ const result = yield call(someGetExample, action.payload); yield put({type: "SECOND_SAGA", payload: 'Good'}); }catch(e){ yield put({type: "SECOND_SAGA", payload: 'error'}); } } function* fetchLandingCourses() { try { const data = yield call(getLandingCourses, {}); console.log(data.landing_courses); yield put({type: "SET_LANDING_COURSES", payload: data.landing_courses}); } catch (e) { } } function* mySaga() { yield takeEvery("USER_FETCH_REQUESTED", fetchUser); yield takeEvery("FETCH_LANDING_COURSES", fetchLandingCourses); } function* mySecondSaga(){ yield takeEvery("API_ASYNC", someOtherWorkerSaga); } function *rootSaga() { yield all([ mySaga(), mySecondSaga() ]) } export default rootSaga;
JavaScript
아래에 yield all 명령어 다음에는 여러개의 saga를 등록할 수 있습니다. 현재 mySaga, mySecondSaga두개가 등록이 되어 있습니다. mySaga의 경우 "USER_FETCH_REQUESTED", "FETCH_LANDING_COURSES" 가 dispatch되는 순간 동작합니다.
동작시키는 방식은
버튼을 누를 때 액션을 dispatch를 할 수도 있고
useEffect + no dependency에서 할 수도 있고
기타 등등, 상황에 맞는 때에 trigger할 수 있습니다.

예제

const App = () => { const dispatch = useDispatch(); useEffect(() => { dispath({type: "FETCH_LANDING_COURSES"}); }, []); const onClick = () => { dispath({type: "USER_FETCH_REQUESTED"}); } return <div onClick={onClick}>request user fetch</div> }
JavaScript
useEffect에서 dispatch, onclick에서 dispatch등을 할 수 있습니다.
추가적으로 Saga에선 각 상황에 맞는 서비스들을 제공해주고 있는데,
1.
Racing Effects
2.
Task Cancellation
3.
Channel을 활용한, task queing
등, 을 제공합니다. 자세한건 상황에 따라 공식 문서를 확인해보세요!

Case

예를 들어, 유저가 지속적으로 어떤 상품을 사려고 클릭을 하는데, 아직 리스판스가 떨어지기 전인데도 불구 하고 여러번 클릭을 했다고 하면, 우리는 가장 먼저들어온 액션이 아니라, 가장 나중에 들어온 액션에 대해서 처리를 해주면 됩니다 (물론, 서버는 콜을 모두 받았고, 서버팀은 버튼을 클릭을 하면 전체 화면에서 버튼을 삭제 해달라고 요청을 할 것 같긴 하지만요 )
이런 경우는 takeLatest를 해주면 됩니다.

HW

사가를 활용하여 간단한 서비스를 만들어 보세요. 레덕스 툴킷에서 createSlice를 활용하세요. createAPI는 사용하지 마세요.
1.
페이지를 두 개 구성하세요.
a.
로그인 페이지
b.
교재 페이지
2.
교재 페이지에서,
a.
dispatch 를 하여 교재 api를 콜하여 나온 데이터로 스테이트를 업데이트 하세요.
b.
렌더링 된 교재를 클릭을 하면 삭제 되도록 해보세요.
3.
로그인 페이지에서,
a.
email, password를 입력받는 인풋을 만드세요.
b.
링글 로그인을 할 수 있는 api를 사용하세요.
body: { email, locale: "en", password, session_role: "student" }
JavaScript
c. api선언은 firebase 예시처럼, saga function에 선언하세요.
d. api 콜의 경우 axios 라이브러리를 활용하여 구현하세요
e. 최종적으로 return된 jwtToken도 redux state에 저장하세요.
4.
교재 랜더링을 위한 api를 호출을 할 때에 jwtToken을 bearer방식으로 하도록 header를 디자인 하세요.
아래 참조
5.
then, await 버전으로 둘 다 만들어 보세요.

Axios

Example
payload = { email, password } const res = await axios.post("https://doc-80.ringleplus.com/api/auth/signin", payload) if(res.data){ if(res.data.success){ }else{ alert('something went wrong') setLoading(false); }
JavaScript
Example
const url = `https://doc-80.ringleplus.com/api/document/abstract/${uuid}` const options = { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, url, }; const res = await axios(options); if (!res.data.success){ alert(res.data.message); window.location = `/docs/dashboard` }
JavaScript
Example
const config = { headers: { Authorization: `Bearer ${token}` } }; const bodyParameters = { key: "value" }; axios.post( 'http://localhost:8000/api/v1/get_token_payloads', bodyParameters, config ).then(console.log).catch(console.log);
JavaScript