Redux 와 부수효과
Redux 는 상태 관리를 도와주는 간단한 라이브러리이다. 실제로도 소스코드 용량은 매우 작고, 해주는 일도 매우 단순하다.
Redux 복습
Redux 를 다들 잘 알겠지만 복습해보자.
Redux 는 자신이 관리하는 데이터 모음인 상태(state)
를 스토어(Store)
라는 저장소에 두고 이 상태를 변경할 수 있는 것은 액션(action)
으로 제한한다.
액션은 단순한 문자열이며 이 액션으로 상태를 변경하기 위해서는 스토어(Store)
에 디스패치(dispatch)
하는 행위가 필요하다.
디스패치(dispatch)
할 때 전달할 정보는 다음과 같은 인터페이스를 가지는 일반 자바스크립트 객체이다.
1 2 3 4
| interface ReduxDispatchAction { type: string, [prop:any]?: any }
|
디스패치 함수는 스토어가 가지고 있고, 시그니쳐는 다음과 같다
1 2 3 4 5 6
| interface ReduxStore {
dispatch(action: ReduxDispatchAction) => void }
|
실제 사용 코드는 다음과 같다.
doAmazingShow
라는 액션을 payload 속성과 같이 디스패치하는 코드다.
1 2 3 4 5 6 7 8 9
| const doAmazingShow = 'doAmazingShow'
store.dispatch({ action: doAmazingShow, payload: { invited: [ 'Cool', 'Hot' ] } });
|
디스패치의 결과로 reducer
가 실행된다. reducer
는 모든 액션이 디스패치 될 때마다 액션과 현재 상태를 받는 단순한 함수다.
reducer 의 시그니처는 다음과 같다.
1 2 3 4 5 6 7 8 9
| interface Reducer {
(currentState): object, action: ReduxDispatchAction) => object }
|
이 흐름은 한번의 실행 스택으로 수행되는데, 이 뜻은 다수의 액션 수행을 해도 그 순서를 보장한다는 뜻이다. 스크립트의 동작이 원래 그렇듯이 말이다.
여기까지가 Redux 의 간단한 흐름이다. 더 자세한 설명을 원하면 공식 사이트를 보자.
Reducer 라는 네이밍은 Redux 제작자의 네이밍인데, 개인적으로는 액션처리기 같은 직관적 네이밍이 어땠을까 한다. 그럼 Redux 가 아니라 Execer 가 되었을지도 모르겠다. 그렇지만 액션을 누적해 하나의 상태로 처리하는 reduce
측면에서는 원래 이름인 Redux 가 더 어울린다.
Side Effect
실무에서 Redux 를 쓰다보면 액션이 동시다발적으로 발생되며, 액션 중간에 실제 Redux 액션이 아닌 일반 로직이 수행되거나 Ajax Call 등의 서버 리퀘스트도 발생한다. 그 와중에 여러 액션의 실행 보장도 해줘야 하는데, 자칫 코드가 상당히 난해해질 수 있다.
이럴때 사용을 고려해볼만한 라이브러리들이 몇개 있는대 대표적으로 Redux-Saga, Rx-Observable, MobX 등이다.
Redux-Saga 나 Rx-Observable 등을 Redux 와 같이 사용할때 이점으로 보통 비동기 처리가 손쉽다…라는 문구로 광고가 보통 되지만, 구조화된 Redux 설계를 했다면 비동기 처리도 그렇게 더러워지진 않는다. (다시 말하면 설계가 좋지 않다면 유지보수가 힘든 스파게티가 나온다는 뜻이다)
사실 단순 비동기 처리보다 더 큰 어려움은 액션이 여러 의미를 가지게 되고 그에 맞춰 기능이 확장되면서 액션이 다른 액션과 체이닝되기 시작할 때이다.
이런 기능들을 기존의 Redux 로 일일해 대응하다보면 코드가 순식간에 누더기가 된다. 순진하게 액션 처리 후 다른액션, 그리고 그 액션 성공 후 다른 액션… 으로 이어지는 코드는 대부분 스파게티맛을 맛본다.
예를 들어 회원 정보 페이지가 있다고 해보자. 다음은 액션을 디스패치하는 코드이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const loadUser = async ({ userId }) => { try { store.dispatch({ type: 'START_USER_LOADING' }) const user = await Users.loadUser(userId) store.dispatch({ type: 'END_USER_LOADING', payload: user }) } catch(error) { store.dispatch({ type: 'FAIL_USER_LOADING', payload: error }) } }
|
하지만 앱에 새로운 기능이 추가되어 유저 로딩 후 사용자의 팔로워를 같이 로딩해야 한다고 해보자.
코드는 다음과 같이 변경할 수 있다
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| const loadFollowersFrom = async ({ userId }) => { try { store.dispatch({ type: 'START_FOLLOWER_LOADING' }) const followers = await Users.loadFollowersFrom(userId); store.dispatch({ type: 'END_FOLLOWER_LOADING', payload: user }) } catch(error) { store.dispatch({ type: 'FAIL_FOLLOWER_LOADING', payload: error }) } }
const loadUser = async ({ userId }) => { try { store.dispatch({ type: 'START_USER_LOADING' }) const user = await Users.loadUser(userId) store.dispatch({ type: 'END_USER_LOADING', payload: user })
loadFollowersFrom() } catch(error) { store.dispatch({ type: 'FAIL_USER_LOADING', payload: error }) } }
|
별로 나빠보이지 않는다. 그러나 이 코드는 앞으로의 코드 변경에 꽤나 힘들어질 수 있는 스타트를 끊은 코드다.
지금은 유저 정보 로딩 후 팔로워 로딩만 추가했지만 앞으로 이후 수많은 유저 관련 정보가 로딩될 수 있다.
예를 들면 추가적으로 유저 정보 로딩 후, 그 정보의 유무에 따라 현금성 결제 포인트와 이 유저를 방문한 유저를 로딩해야 할 수 있다. 그리고 사용성 트래킹을 위해 로그를 서버에 전송할 수도 있다.
그 호출 책임은 전부 loadUser
라는 함수가 담당하고 있다.
원래의 목적은 유저를 로딩한다는 목적으로 만들었지만, 이제는 유저도 로딩하고, 포인트도 로딩하고, 팔로워도 로딩하고 … 하는 함수가 되었다. 이쯤되면 이름을 loadUserThenFollowers
같은 이름으로 바꿔야 할지도 모르겠다.
더욱 힘들게 하는 건 만일 유저 정보 로딩 후 실행되는 부수 액션들(팔로워, 포인트…) 중 하나가 오류가 났을 때 각 부수 액션들끼리도 서로 영향을 줄 수 있다. 만일 비즈니스적으로 어떤 액션은 주변의 오류와 상관없이 진행해야 할 수도 있고 중단해야 할 수도 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const loadUser = async ({ userId }) => { try { store.dispatch({ type: 'START_USER_LOADING' }) const user = await Users.loadUser(userId) store.dispatch({ type: 'END_USER_LOADING', payload: user })
await loadFollowersFrom(userId) await loadPoint(userId) } catch(error) { store.dispatch({ type: 'FAIL_USER_LOADING', payload: error }) } finally { await writeUserActionLogging(userId) } }
|
이러한 본래 액션 말고도 그 액션에 따라 다른 액션이나 이벤트가 파생되는건 꽤나 흔한 일이다.
이런 일을 부수효과 (Side-Effect)
라고 한다.
- Ajax 콜
- 비동기 타이머
- 애니메이션 후 콜백
- 요청 중 취소
- 스로틀링
- 디바운싱
- 페이지 이동
이러한 것은 일반적인 Redux의 액션 흐름으로는 나타내기가 조금 어렵고, 비동기 수행시에는 어디엔가 dispatch 함수의 레퍼런스를 가지고 있다가 필요할때에 호출하면서 수행해야 한다.
이러한 부수 효과들은 Redux-Saga 를 쓴다면 꽤 단순하고 직관적으로 풀어낼 수 있다.
Redux Saga 적용
다음은 Redux-Saga 로 위의 문제를 다시 작성해본 코드이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
|
const loadPoint = function* ({ userId }) { try { const followers = yield call(Point.load, userId); yield put({ type: 'END_USER_POINT_LOADING', payload: user }) } catch(error) { yield put({ type: 'FAIL_USER_POINT_LOADING', payload: error }) }) }
const loadFollowers = function* ({ userId }) { try { const followers = yield call(Users.loadFollowersFrom, userId); yield put({ type: 'END_FOLLOWER_LOADING', payload: user }) } catch(error) { yield put({ type: 'FAIL_FOLLOWER_LOADING', payload: error }) }) }
const loadUser = function* ({ userId }) { try { const user = yield call(Users.loadUser, userId) yield put(({ type: 'END_USER_LOADING', payload: user }) } catch(error) { yield put(({ type: 'FAIL_USER_LOADING', payload: error }) } }
const watcher = function* () { yield takeEvery('START_USER_LOADING', loadUser); yield takeEvery('END_USER_LOADING', loadFollowers); yield takeEvery('END_USER_LOADING', loadPoint); }
saga.runSaga(watcher)
|
Generator를 모르는 사람은 문법에 어지러울지 모르겠다. Generator가 중요한 부분이 아니니 실행 흐름에 거쳐가는 키워드로 보자.
Saga 는 액션을 구독하는 Watcher 와 실제 작업을 수행하는 Worker 의 구성을 따른다
- Watcher
- Worker
- loadUser
- loadFollowers
- loadPoint
먼저 액션을 처리할 워커 함수를 전부 정의한다. loadUser, loadFollowers, loadPoint 셋이 있다. 그리고 매니저가 될 와쳐 함수를 정의하고 그 함수에서 실행을 정의하면 끝이다.
이후에 좀 더 설명하겠지만 takeXXX 류의 함수는 특정 액션(들) 을 감시하는 함수이고, put 은 실제 액션을 dispatch 하는 함수이다. Redux 의 Dispatch 함수와 동일하다. (이것들을 Saga 에서는 Saga-Effect 라고 부른다. 이후에 설명한다.)
위 예제에서는 loadUser
는 START_USER_LOADING
가 디스패치될 경우 매번 loadUser 를 실행하게 되어 있다. 그 아래 두개의 함수도 마찬가지로 END_USER_LOADING
가 디스패치 될 경우 각각의 두번째 인자의 함수를 실행한다.
코드량이 약간 줄은 것 외에는 더 복잡해졌다고 생각할 수 있다.
하지만 각 함수들이 자신만의 일에 집중하는 구조로 바뀌었으며 실행 시점을 알기 편해졌다. 자신 외에 별도 부수효과에 신경쓸 필요가 없다.
만일 여기서 팔로워나 포인트를 유저 정보 로딩 후가 아닌 다른 타이밍에 호출하려는걸 추가한다면 다음과 같이 하면 된다. 실제 loadXXX 류의 작업 함수는 건드릴 필요가 없다.
다음과 같이 watcher 함수에 watching 할 액션만 추가로 넣어주면 된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const watcher = function* () {
yield takeEvery('START_USER_LOADING', loadUser);
yield takeEvery([ 'END_USER_LOADING', 'START_FOLLOWER_LOADING', ], loadFollowersFrom);
yield takeEvery([ 'END_USER_LOADING', 'START_POINT_LOADING', ], loadPointFrom); }
|
각 액션에 대해 로깅을 추가한다고 하면 다음 구문만 추가하면 된다.
1 2 3 4 5
| yield takeEvery([ 'END_USER_LOADING', 'END_USER_POINT_LOADING', 'END_FOLLOWER_LOADING', ], writeUserActionLogging);
|
이렇게 액션의 감시와 해당 부수효과들을 아예 분리해서 각자의 일만 하게 두었다. 이런 방식으로는 각 액션별로 서로 영향을 주는 표현을 액션만으로 쉽게 나타낼 수 있게 된다. 실제 디스패치 하는 측에서도 비동기의 성공 여부를 고민할 것 없이 동기적 디스패치를 쓰는 것만으로 충분하다.
실제 작업은 Saga 내부적으로 처리되며 디스패치 된다.
Saga-Effect
Saga 는 이러한 부수효과를 처리하는 이펙트들을 지원한다. 앞의 코드에서는 put 과 takeEvery 가 나왔었다.
공식 문서의 Effect 들 https://redux-saga.js.org/docs/api/#effect-creators
모든 effect 들은 반드시 yield keyword 와 함께 사용해야 한다
take
take
는 특정 액션을 감시하는 용도로 쓰인다.
다음 코드는 REQUEST_ORDER 액션이 디스패치될 때까지 기다린 후 Api.requestOrder 를 호출하는 예제이다.
1 2 3 4 5
| function* watchOrderRequest() { const action = yield take('REQUEST_ORDER'); const result = yield call(Api.requestOrder, action.orderId); }
|
블럭된다는 성질을 이용해서 다음과 같이 매번 액션에 대해 반응하는 saga 를 만들 수 있다
1 2 3 4 5 6 7 8 9
| function* watchOrderRequest() { while(true) { const action = yield take('REQUEST_ORDER'); const result = yield call(Api.requestOrder, action.orderId); } }
|
이런 saga 를 만들일이 많으므로 공식적으로 이런 동작의 헬퍼인 takeEvery, takeLatest, takeLeading 등을 제공하고 있다
put
put effect 는 단순하다.
redux의 dispatch 함수와 완전히 동일하다. 이 effect 는 블럭되지 않기에 조심해야 한다.
1 2 3 4 5 6 7 8 9 10 11
| function* watchOrderRequest() { while(true) { const action = yield take('REQUEST_ORDER'); const result = yield call(Api.requestOrder, action.orderId); yield put({ type: 'RESPONSE_ORDER', result }); } }
|
fork
새로운 하위 saga 태스크를 생성하는 effect 이다.
fork 는 블럭되지 않으며 호출 시점에 호출자는 부모 task 가 되고 fork 된 saga 는 자식 task 가 된다. 부모 task 가 취소되면 자식 task 도 취소된다.
명시적으로 특정 자식 태스크만 취소시킬수도 있다.
아래에 예제가 있다.
1 2 3 4 5 6 7 8 9 10 11
| function* parentTask() { const task1 = yield fork(childTask1); const task2 = yield fork(childTask2); if(task2 && task2.isRunning()) { task2.cancel(); } }
|
call
call 은 블럭되는 fork 라고 보면 된다. 인자로 함수나 saga task 를 받을 수 있다.
두번째부터는 실행될 함수나 사가의 인자로 들어간다.
보통 Promise 등의 실행 (보통은 Ajax Call) 에 쓰이며 Promise 가 resolve 될 때까지 블럭된다.
예제는 위에 이미 있으므로 생략한다.
select
redux 의 state 에서 특정 상태를 가져올때 사용하는 effect 이다.
redux-thunk 의 getState 와 비슷하지만, 인자로 셀렉터를 줄 수 있다.
블럭 effect 이다.
아래 예제는 활성 유저를 redux state 에서 찾은 뒤 그 아이디로 유저 정보를 Ajax call 하는 예제이다.
1 2 3 4 5 6 7 8 9 10 11 12 13
| const activeUserSelector = state => { return state.user.activeUser; }; const getUserData = userId => ajax(`/user/data/${userId}`); function* parentTask() { const activeUser = yield select(activeUserSelector); const activeUserData = yield call(getUserData, activeUser.userId); }
|