redux + redux-saga 로 Async 다루기

HyeonSeok Yang
10 min readFeb 9, 2018

--

이제 아는 사람은 다 알고 쓰는 사람은 다 쓰는 그것들

Redux를 쓰기로 결정한 후 구조를 어떻게 잡을지 고민하다가, ducks pattern을 보고 이 구조를 따라가기로 했다. 이 패턴의 포인트는 string으로 선언된 Action type이 알고보면 대부분 action creator와 reducer에서만 쓰일텐데, 굳이 파일을 쪼개어 import/export하는 작업을 해야 하냐는 관점이다. 실제로 connect된 컴포넌트에서도 액션 크리에이터를 사용해 액션을 생성하지, 액션의 타입을 직접 알 필요는 없다. 때문에 Ducks 패턴을 사용한 모듈들을 무척 단순하게 작성할 수 있었다.

하지만 지금은 이 패턴을 더이상 쓰고 있지 않다. 모듈 하나 하나의 크기가 꽤 커져서, 코드를 한눈에 보기 어려워졌기 때문이다. ducks 모듈은 액션, 액션 크리에이터, 리듀서, 사가로 나누어졌고, index.ts 파일에서 import/export되었다. 모듈의 크기가 커진 데에 가장 큰 이유는 async한 액션을 다루기 시작하면서 액션과 사가가 단순하지 않아졌기 때문이다.

아마도 최근에 작업한 코드라, 모든게 머릿속에 남아있어 당연한 것처럼 넘어가버린 설명이 있을 수도 있다. (이래서 글은 좀 묵혀뒀다가 써야 한다) 댓글을 남겨주시면 조금 더 친절한 설명을 하도록 노력해보겠다.

Async Action 처리하기

첫째로, 액션 자체가 조금 복잡해졌다. Async한 액션을 요청 보냄, 성공, 실패로 구분하기 위해서이다.

function makeAsyncActions(actionName) {
const prefix = actionName
return keyMirror(prefix, {
INDEX: null,
REQUEST: null,
SUCCESS: null,
FAIL: null,
})
}
export const actionTypes = {
FETCH_NOTICES: makeAsyncActions('app/notices/FETCH_NOTICES'),
NORMAL_ACTION: 'app/example/NORMAL_ACTION',
}

이렇게 작성해두면, 추후에 액션 타입을 지정할 때 FETCH_NOTICES.INDEX , FETCH_NOTICES.REQUEST , FETCH_NOTICES.SUCCESS , FETCH_NOTICES.FAIL 로 타입을 지정할 수 있다.

두번째로, 이런 액션을 처리하기 위한 액션 크리에이터도 조금 복잡해진다. AsyncAction의 규칙에 대응되는 액션 크리에이터를 만들기 위해서이다.

function makeActionCreator(actionType) {
return payload => ({ type: actionType, payload })
}
function makeAsyncActionCreator(actions) {
let actionCreator = makeActionCreator(actions.INDEX)
actionCreator.request = makeActionCreator(actions.REQUEST)
actionCreator.success = makeActionCreator(actions.SUCCESS)
actionCreator.fail = makeActionCreator(actions.FAIL)
return actionCreator
}
export const fetchNotices =
makeAsyncActionCreator(actionTypes.FETCH_NOTICES)
export const normalAction =
makeActionCreator(actionTypes.NORMAL_ACTION)

이렇게 만들어진 fetchNotices는 다음과 같은 코드로 실행된다.

fetchNotices(payload)         // { type: FETCH_NOTICES.INDEX }
fetchNotices.request(payload) // { type: FETCH_NOTICES.REQUEST }
fetchNotices.success(payload) // { type: FETCH_NOTICES.SUCCESS }
fetchNotices.fail(payload) // { type: FETCH_NOTICES.FAIL }

Async한 Action의 명명 규칙을 넘어 일정한 형태로 자리잡게 되면, 조금 아래에서 설명할, 반복되는 코드를 추출하는데에 꽤 도움이 된다.

이런 액션을 처리하는 Saga를 보자. INDEX로 된 액션을 take하고, REQUEST, SUCCESS, FAIL 액션을 put하는 형태이다.

export function* fetchNoticesSaga(action) {
yield put(fetchNotices.request())
try {
const notices = yield call(NoticesAPI.create, action.payload)
yield put(fetchNotices.success({ notices }))
} catch (error) {
yield put(fetchNotices.fail({ error }))
}
}
export function* watcher() {
yield all([
takeLatest(actionTypes.FETCH_NOTICES.INDEX, fetchNoticesSaga)
])
}

API 호출 패턴은 앱의 여러 부분에 걸쳐 계속 반복되었기에 apiSaga로 뽑아냈으며, 세션 에러와 같은 공통 에러를 잡아내기 위해 에러 핸들러를 더했다.

function* apiSaga(api, asyncAction, options) {
yield put(asyncAction.request())
try {
const payload = options && options.apiPayload
const result = yield call(api, payload)
yield put(asyncAction.success({ result }))
} catch (error) {
const failAction = asyncAction.fail({ error })
yield call(errorHandler, failAction)
yield put(failAction)
}
}
export function* fetchNoticesSaga(action) {
yield call(apiSaga, NoticesAPI.getNotices, fetchNotices, {
apiPayload: action.payload,
})
}

Async한 액션이 발생하는 팝업

팝업은 이후 팝업 관리에 대해 적는 글에서 자세히 다룰 예정이지만, 여기에서도 조금 같이 다뤄볼까 한다. 기존 팝업 구조는 1년 전쯤 썼던 글에서와 같이 컴포넌트에서 팝업을 지정하여 열고 있었다. 달라진 점이 있다면, openPopup 액션이 App의 컨텍스트 안에 존재해, context를 통해 어디서든 팝업을 열 수 있었다는 것.

onClick = () => {
this.context.openPopup(POPUP.POST_EDIT, { postId })
}

Flux에서는 컴포넌트가 로직을 들고 있는 방식으로 코드가 구성되어 컴포넌트가 팝업을 여는 코드가 어색하지 않았지만, 리덕스와 사가를 사용하게 되면서 이런 코드는 사가에서 처리하는게 맞겠다는 생각이 들었다.

// at Component
onClick = () => {
this.props.openPostEditPopup({ postId })
}
// at Saga
function* openPostEditPopupSaga(action) {
const { postId } = action.payload
const popupId = POPUP.POST_EDIT
yield put(openPopup({ id: popupId, props: { postId } }))
}

이런 방식으로 변경하면서, 팝업에서 수행되는 어떠한 기능 — 이 경우에는 포스트 수정 요청을 보내는 — 이 성공했는지, 실패했는지, 아니면 그냥 아무것도 하지 않고 유저가 팝업을 닫아버렸는지 같은 상황을 알아야 했다. 이전에는 그러한 행동들을 열린 팝업 컴포넌트에서 해결했었지만, 이젠 그러한 로직들도 사가로 옮기는 것이 적절하다고 보았기 때문이다. 그러니까 이 사가는 팝업을 열고 끝나는 것이 아니라, 팝업을 열고 팝업에서 일어나는 액션을 관찰해야 한다.

function* openPostEditPopupSaga(action) {
const { postId } = action.payload
const popupId = POPUP.POST_EDIT
yield put(openPopup({ id: popupId, props: { postId } }))
yield fork(watchAsyncActionPopupSaga, actionTypes.POST_UPDATE, popupId)
}

사실 관찰이 필요한 팝업이란건, 팝업의 확인 버튼을 눌렀을 때, 내부에서 어떠한 AsyncAction이 일어나는 팝업을 말한다. 그렇지 않다면, 확인 버튼이란 것이 닫기 버튼과 별다른 차이가 없는 것이다. 다행히도 이 프로젝트에서는 하나의 팝업에서 두가지 이상의 일을 하는 팝업이 없었으므로, 아래와 같은 사가를 만들어 대응했다.

export function* watchAsyncActionPopupSaga(asyncActionType, popupId) {
// watch request action
const watchAsyncAction = yield fork(
watchAsyncActionSaga,
asyncActionType,
popupId
)
// if async action success or user close popup,
const { success } = yield race({
success: take(asyncActionType.SUCCESS),
close: take(popupActionTypes.CLOSE),
})
// then stop watching
yield cancel(watchAsyncAction)
// on success, auto-close popup
if (success) {
yield put(closePopup({ id: popupId }))
}
}
// when request start: set isLoading = true,
// and request end: set isLoading = false
function* watchAsyncActionSaga(asyncActionType, popupId) {
while (true) {
yield take(asyncActionType.REQUEST)
yield put(updatePopup({ id: popupId, props: { isLoading: true } }))
yield race({
success: take(asyncActionType.SUCCESS),
fail: take(asyncActionType.FAIL),
})
yield put(updatePopup({ id: popupId, props: { isLoading: false } }))
}
}

위 코드에서 설정한 isLoading 값은 팝업에서 props로 받아간 뒤, 확인 버튼에 loading indicator를 보여주느냐 마느냐를 구분하는데에 사용했다.

이렇게 Saga에 API 호출과 결과값 처리에 관련된 모든 동작과, 팝업 및 팝업에서 일어나는 일을 감지하고 변화를 일으키는 동작을 옮기고 나니 대부분의 컴포넌트가 꽤 말끔해졌다. 더이상 복잡한 로직을 담지 않고 렌더링을 하거나, 액션을 dispatch하는 일 밖에 하지 않는다. 이정도까지 바꿔놓고 나니, ‘아 이게 Redux구나’ 하는 생각이 든다.

--

--

HyeonSeok Yang
HyeonSeok Yang

Written by HyeonSeok Yang

Frontend Developer, mainly using React.js

Responses (2)