React + Redux saga 기반 제품 SPA 전환 후기
먼저, 이 글은 SPA 및 React, Redux 대한 배경 지식을 전제로 작성된 글 입니다.
모든 애플리케이션을 만족시키는 은탄환(Silver Bullet)이란 없듯, SPA 또한 획기적인 사용자 경험(UX) 향상이라는 이면에, 검색 엔진 최적화 문제, 느린 초기 구동 속도 등등의 구조적인 단점을 가지고 있다. 하지만 이번 글의 대상이 되는 제품은 기업 내부 사용자용 대시보드이고 B2B 성격이 짙기에 상기 단점 들에 제약을 받지 않고 비교적 자유롭게 SPA 전환을 시도할 수 있었다.
들어가기 앞서
우선 처음부터 SPA로 만들지 않은 것은 밑바닥 부터 시작한 새 프로젝트가 아니라 기존 Legacy를 부분부분 순차적으로 React로 porting 하는것이 주된 목적 이였기에 SPA 고려는 최우선순위가 아니었다. 그렇게 시간이 흐르고 새롭게 만들어진 우리 대시보드가 어느정도 자리를 잡았을 때(사용자가 꽤 늘었을 때), SPA 전환을 시도 했다.
개발 관점에서 바라보았을 때, 이미 React + Redux Saga 를 기반으로 제품이 구현이 되어 있어서 사이드 이펙트 관리를 제외하면 기술적으로 큰 허들은 없었다. 하지만 실제 서비스 되고 있는 제품 측면에서 바라볼 때, 비정상적인 유저의 행동에도 문제없이 깔끔하게 동작하는가? 라는 것을 충족 시키기 위해서는 적지 않은 고민이 필요했다. 불안요소들을 살펴보기 앞서 간단하게 우리 제품인 광고 대시보드를 살펴보자.
위 GIF를 살펴보면 알 수 있듯이 Rending Page(리스트) — Editor Page로 구성되어 있다. Editor Page를 더 자세히 살펴보면 상호 유기적으로 연결되어 있는 여러 필드들이 존재한다. 이는 어떤 필드값의 변경이 다른 필드 들의 값을 변경 시킬 수 있고, 각 필드값은 실시간으로 validation-check가 필요했다. 이를 위해 전체 필드값들을 리스닝하는 watcher를 saga를 통해 구현 했었다. 그리고 이 watcher를 에디터 페이지에 들어올 때 마다 데이터 펫칭이 끝난 후 켜준다. (여러 종류의 Editor Page와 상응하는 watcher 존재)
FYI, watcher는 validation 및 dirty form check 등을 수행하기에 데이터 펫칭이 끝나고 모든 form이 받아온 데이터로 채워지고 난 후 실행시킨다.
비정상적인 유저의 행동
적정한 글의 길이를 위해, 유저의 비정상적인 행동 케이스 들 중 하나의 시나리오를 살펴보자.
- 에디터 페이지로 이동
- 로딩이 채 끝나기 전에 페이지 빠져 나옴
- 기존에 실행되고 있는 saga 들은 어떻게?
위에서 언급했다 시피 에디터 페이지로 들어왔을 때 데이터 펫칭이 끝난 후 run watcher을 실행시키는 saga 함수를 handleFetchEditorData 라고 가정해보자. 이 때 로딩중에 페이지를 떠나도 남아있던 handleFetchEditorData는 그 사실도 모른체 자신의 임무를 다한다.
페이지를 떠날 때(component unmount) stop watcher를 한다고해도 그 후에 handleFetchEditorData가 본연의 임무를 수행하면서 run watcher를 다시 실행하기에 문제가 있다.
나아가 만일 해당 saga가 다수의 api 통신을 한다면, 페이지를 떠나는 시점에 남아있는 불필요한 api 통신까지 전혀 상관 없는 페이지 임에도 불구하고 뒤에서 실행하기에 문제가 있다.(리소스 낭비)
이를 해결 하기 위해 페이지 이동 시 saga가 제공하는 api를 통해 handleFetchEditorData를 직접적으로 중단 시킬 수 도 있다. 하지만 근본적으로 모든 곳에서 이러한 문제가 발생할 수 있기에 saga 함수 마다 처리를 해주어야하는데, 추후 새로운 saga를 작성할 때 관련 코드를 빠뜨릴 수 있는 여지가 많아보였다. 그리하여 결론적으로 페이지 이동 시 root saga를 재시작 하는 방향으로 진행하였다.
해결방안: Restartable Root Saga
rootSaga.ts 파일에 다음과 같이 코드를 작성해주었었다. PageActionTypes.PreparePageChange은 페이지 이동이 발생할 때마다 디스패치되는 액션이고, 이 액션이 들어오면 루트 사가를 재시작 해준다.
const rootSaga = function*() {
yield all([
Saga1(),
Saga2(),
...
Saga9()
]);
};function restartable(saga: Saga, pageAction: PageActionTypes) {
return function*() {
let task = yield fork(saga);
while (yield take(pageAction)) {
yield cancel(task);
task = yield fork(saga);
}
};
}export default restartable(rootSaga, PageActionTypes.PreparePageChange);
원리는 간단하다. root saga 의 자식 saga들을 살펴보면 아래와 같이 saga effects 중 fork로 이어진 모습을 볼 수 있는데, fork는 부모(실행주체)에 attached 되는 속성을 지니고 있다. 이로 인해 부모가 재시작 되니 그에 종속된 자식 saga들도 함께 재시작 되는 것이다.
export default function* Saga1() {
yield fork(childrenSaga1);
yield fork(childrenSaga2);
yield fork(childrenSaga3);
}
마치며
사실 우리 제품 특성상 아직까지는 SPA 전환이 급한 것도, 꼭 필요한 것도 아니었다. 그럼에도 불구하고 팀원 분들께서 나의 성장을 위해 일을 떠나 개발 해보고 싶은 것은 무엇 인지 먼저 여쭈어봐 주시고, 최대한 많은 것을 배려해주셔서 항상 너무 감사하다. 진짜 1년이 넘은 지금도 우리 회사가 너무 좋다. 내 와우 / 롤 아이디를 Tapjoy 로 바꾼것을 후회하지 않는다. 펄-럭