이번에 서버로부터 대규모 데이터를 불러오는 기능을 구현하면서, 서버의 작업이 끝날 때까지 기다려야 하는 상황을 만났다.
지금까지는 POST 요청의 response에 따라 Resolved와 Rejected (대부분 에러) 케이스를 바로 판단할 수 있었다. 내가 POST한 task의 처리 결과를 바로 보내주기 때문이다 (대부분 이렇다).
하지만 이번 엔드포인트에서는 task의 처리 결과가 아니라 progress ID를 일차적으로 받았다. 이 ID를 다른 엔드포인트에 전달해, status: done
응답을 받을 때까지 계속해서 GET 요청을 보내는 과정이 필요했다.
‘반복적으로 어떤 로직을 실행한다' 라는 polling의 개념 자체는 크게 어렵게 느껴지지 않았다. 하지만 이번 작업에서는 고려해야 할 점이 몇 가지 있었다.
서버와 지속적인 커넥션을 유지해야 하는 경우 polling은 흔히 사용되지만, 보통 long polling 방식을 권장한다.
일반 polling(i.e. short polling)은 일정 주기로 계속 서버에 새 connection을 만든다. 반면 long polling을 connection을 열어 놓고, 서버가 응답을 줄 때까지 pending 상태로 기다린다. 응답을 받거나 pending connection이 끊기면(ex. 네트워크 에러) 해당 connection을 닫고 다시 새 connection을 열어 응답을 기다린다.
따라서 polling과 long polling의 차이점은 delay를 만들어내는 주체가 클라이언트냐, 서버냐에 있다.
async function subscribe() {
let response = await fetch("/subscribe");
if (response.status == 502) {
await subscribe();
} else if (response.status != 200) {
// error
showMessage(response.statusText);
// reconnect after 1s
await new Promise(resolve => setTimeout(resolve, 1000));
await subscribe();
} else {
// Get and show the message
let message = await response.text();
showMessage(message);
// establish new connection to get the next message
await subscribe();
}
}
subscribe();
long polling을 찾아보면, 위와 같이 async-await 구문으로 쉽게 구현해둔 예제들을 많이 볼 수 있다. await 키워드를 사용해 fetch("/subscribe")
요청이 데이터를 받을 때까지 connection을 pending 상태로 유지하는 것이다. 다른 글에서 살짝 언급했듯, await 키워드를 만난 async 함수는 microtask queue로 이동되었다가 call stack이 비어있을 때 다시 실행된다. 따라서 이 polling 함수가 실행되는 동안에도 서비스의 다른 부분들이 작동할 수 있다.
하지만, 이번 경우에는 응답을 기다리는 것이 아니라 응답이 ‘특정한 값’일 때까지 기다리는 것이다.
let response = await fetch("/subscribe");
위 예제의 핵심은 이 await 절이다. 서버가 응답을 10초만에 보내주었다면 10초, 1분만에 보내주었다면 1분. 그 시간동안 한 connection을 유지한 채로 기다리는 것이다. 그러나 내가 다뤄야 하는 엔드포인트는 응답을 바로 보내주었다. 단 내가 보낸 POST 요청이 완료되지 않았을 경우에는 null, 완료되었다면 true를 보내주는 것이 차이였다. 따라서 서버가 delay를 만들어내는 long polling이 아니라, 원하는 값을 얻을 때까지 클라이언트가 일정 주기로 계속 응답을 요청하는 polling 구현이 필요했다.
나는 polling이라는 기능 자체가 생소했고, 비동기 작업을 처리하는 데 겁을 먹고 있었기 때문에, 우선 JS로 구현하는 로직을 찾아 적용했다.
const wait = function (ms = 1000) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
};
먼저, 원하는 시간만큼 async 함수를 지연시켜 줄 wait 함수를 작성한다.
const poll = async function (fn, fnCondition, ms) {
let result = await fn();
while (fnCondition(result)) {
await wait(ms);
result = await fn();
}
return result;
};
그리고 polling을 수행하는 함수를 작성한다. async 함수 안에서 while, for 등의 loop 반복문은 내부의 await 구문이 처리될 때까지 기다렸다가 다음 loop로 넘어간다. 따라서 매 loop 마다 주어진 함수의 결과를 기다렸다가 변수 result에 저장한다. 그래서 내가 원하는 값을 얻을 때까지 while 문으로 계속 API 응답을 불러오다가, while loop가 깨지고 얻어진 값을 리턴하는 것이다.
vanilla JS로 구현된 polling 로직들은 이처럼 간소하고 직관적이라 희망차게 적용해보았다. 그러나 내가 개발 중인 기능이 모달 위에서 작동한다는 특이점으로 인해 생각보다 처리해주어야 할 부분이 늘어났고, 그 처리를 모두 JS로 작성했다간 코드가 길고 복잡해져 human error의 가능성만 더 키울 것 같았다. 그래서 라이브러리로 눈을 돌려야 했다.
위의 JS 코드가 갖는 가장 큰 문제는, 사용자가 모달을 닫거나, 이전 단계로 돌아가거나, 취소 버튼을 누르는 등 보낸 API 요청이 취소되어야 하는 상황에서도 polling이 멈추질 않는다는 것이다.
처음에는 아주 단순하게, “모달이 unmount되니까 while loop도 termintated 되겠지?” 라고 생각했다.
당연히 이건 틀린 추정이다. 한 번 실행된 함수는 자신의 상위 컨텍스트가 변하든, 없어지든 상관없이 계속 실행된다. 즉, 자신을 호출한 컴포넌트가 unmount되었다고 해서 실행되던 도중 abort되지 않는다.
<SubmitButton onClick={() => {
setIsModalOpen(!isModalOpen);
submitForm()
}} />
사용자의 정보를 입력받는 모달의 ‘제출’ 버튼을 예로 들어 보자. 여기서 onClick 핸들러는 함수다. setIsModalOpen()에 의해 모달 컴포넌트가 unmount 되어도, onClick 핸들러는 멈추지 않고 아랫 줄의 submitForm()까지 실행시킨다.
따라서 사용자가 API 호출을 취소시켰을 때 while문도 함께 멈추도록 isUnmounted 같은 state를 하나 추가하면 해결될 일 아닐까?
그래서 간단하게 구현해보았다.
codeSandbox
const [isMounted, setIsMounted] = React.useState(true);
const wait = async (ms = 1000) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
let count = 0;
const pollIncrement = async () => {
while (count < 100 && isMounted) {
await wait(2000);
console.log(++count);
}
return count;
};
React.useEffect(() => {
pollIncrement();
return () => {
setIsMounted(false);
};
}, []);
그러나 제대로 작동하지 않았다. 우리가 변경된 값을 컴포넌트에 반영하기 위해 state를 사용하는 이유는, 컴포넌트를 재렌더링시키기 위해서다. 컴포넌트가 재렌더링되면, 함수들은 새로 만들어진다. 즉, 기존에 실행되고 있던 함수의 값에 영향을 주지 못한다. 위 sandbox 예제를 보면 useEffect의 return 문을 이용해 모달이 unmount될 때 while 문의 조건이 false가 되도록 만들었지만, console.log는 멈추지 않고 계속 찍힌다. 즉 업데이트된 state가 이미 실행 중인 while문에는 반영되지 않았다는 뜻이다.
다만, 직접 stackoverflow에 물어보니 useRef를 사용하면 위 로직을 유지한 채로 모달이 닫힐 때 polling이 중단되도록 할 수 있다고 한다.
순수 JS만으로 작성하는 건 위와 같이 고려할 사항이 늘어나 코드가 길어졌고, 다른 팀원들과의 컨벤션도 맞지 않았다. 그래서 polling이 가능하면서, unmount 시의 cleanup을 간편하게 할 수 있는 다른 라이브러리를 고민하다 React-Query를 선택했다. React-Query의 enabled 와 refetchInterval 속성을 사용하면, 원하는 조건이 충족될 때까지 일정 간격으로 쿼리를 호출할 수 있다.
useQuery({
queryKey: [taskId],
queryFn: () => fetchTaskStatus(taskId),
enabled: Boolean(taskId),
refetchInterval: 2000,
onSuccess: data => {
if (data && fnCondition(data)) {
setTaskId('');
setIsModalOpen(false)
}
},
onError: () => {
if (taskId) {
setTaskId('')
}
},
})
위 코드의 작동은 다음과 같다:
useQuery는 쿼리를 호출한 컴포넌트의 생명주기를 따른다. 따라서 컴포넌트가 unmount되면 해당 useQuery 역시 inactive 상태가 된다. 그러니 모달이 닫히면 더 이상 refetch가 일어나지 않을 것이다.
그런데, 코드를 리뷰하던 중 팀장님께서 RxJS 사용을 권하셨다. 유저가 무작정 기다리게 할 수는 없으니, polling은 계속하되 30초 쯤 지나면 모달을 닫고 기다릴지 확인하는 안내를 띄우는 건 어떻냐는 제안이었다.
위의 코드에서는 refetchInterval 과 실행/종료 조건만 주고 있기 때문에, 현재 요청이 몇 번째 interval인지 알 방법이 없다. 물론 ref를 하나 추가해서 onSuccess함수가 실행될 때마다 1씩 추가하면 되지만, 이전부터 RxJS에 대한 호기심이 컸기 때문에 좋은 기회라고 생각해 다시 RxJS를 사용해서 구현해보았다.
아, 결론부터 말하자면, 나는 아직 RxJS를 제대로 이해하지 못했다. RxJS는 우리가 흔히 사용하는 Redux, React-Query처럼 특정 기능을 쉽게 사용할 수 있게 하는 유틸성 라이브러리가 아니라, 프로그래밍 패러다임을 바꾸어놓는 라이브러리다. 그래서 선행할 학습이 많고, 진입 장벽이 굉장히 높다고 한다. 그래서일까, React-Query를 처음 접했을 때처럼 ‘아 어디 한번 써 볼까~’ 하고 쓱 보는 걸로는 감을 잡는 것조차 어려웠다.
그래서 아직도 RxJS가 뭔지 알아가는 중이기에 여기서 개념부터 설명할 수는 없지만,
이렇게 세 가지 항목을 간단히 정리하려 한다.
RxJS의 개념을 빗댄 여러 비유들 중, 방송국(텔레비전) - 시청자 관계에 빗댄 게 개인적으로 가장 이해하기 쉬웠다.
이는 RxJS의 데이터 흐름인 Push 방식을 잘 나타낸다.
RxJS의 핵심 개념은 Observable 객체다. 이 객체의 특징은 Promise 뿐만 아니라 클릭 등의 모든 비동기 이벤트를 ‘스트림’ 형태로 담는다는 점이다. 방송국에서 시간표에 따라 일련의 방송들을 내보내듯, 다수의 이벤트를 시간의 흐름에 따라 연속적으로 리턴한다. 그런 점에서, 이벤트를 요소로 갖는 Array와 유사한 개념으로 생각해보았다.
JS에서 배열은 어떤 형태의 값이든 요소로 가질 수 있다. 이처럼, 옵저버블 역시 다양한 형태의 값(Promise, 각종 이벤트…)을 연속적으로 emit해줄 수 있다. 다만, 차이점이 있다면 배열은 pull 방식이기 때문에 Consumer가 원할 때 값을 가져올 수 있지만 Observable은 미래 값들과 이벤트의 모임이기 때문에, 값이 emit될때까지 기다려야만 소비할 수 있다는 점이다.
RxJS의 이러한 관점(방식)은 개발에 어떤 도움을 줄까. 비동기 이벤트를 다루며 보통 로직이 복잡해지기 시작하는 지점은, 비동기의 ‘… 하면 … 한다’ 를 구현하면서부터다. 일반적으로 이러한 조건들은 로직을 조합하여 구현된다. 하지만 RxJS를 사용하면 ‘…하면’ 이라는 조건을 값으로 처리할 수 있게 된다.
// 일반 자바스크립트
let count = 0;
let rate = 1000;
let lastClick = Date().now() - rate;
document.addEventListener("click", () => {
if (Date.now() - lastClick >= 1000) {
console.log(`Clicked ${++count} times`);
lastClick = Date.now();
}
});
// RxJS
import { fromEvent } from "rxjs";
import { throttleTime, scan } from "rxjs/operators";
fromEvent(document, "click")
.pipe(
throttleTime(1000),
scan((count) => count + 1, 0)
)
.subscribe((count) => console.log(`Clicked ${count} times`));
이 두 방식의 차이를 보자. 각각 JS와 RxJS로 “클릭 이벤트가 1초 이상의 간격으로 발생했다면’ 이라는 조건을 다룬 것이다.
따라서 코드가 굉장히 간결해지는데, 이는 RxJS의 선언형 작성 방식과도 관련이 있다.
“좌표 정보를 담은 배열 Array를 받아, 1사분면에 있는 점을 5개 골라 각 점과 원점과의 거리의 합을 구하라” 라는 문제가 있다고 하자.
// 명령형
const Array = [[1, 1], [2, -3], [4, 5], ...]
let sum = 0, count = 0
for (let i = 0; i < Array.length; i++) {
const [x, y] = Array[i];
if (x > 0 && y > 0) {
sum += Math.sqrt(x*x + y*y)'
count++;
}
if (count === 5) return sum;
}
Array를 명령형으로 다룰 경우, 위와 같이 각 조건에 대해 어떻게 처리해야 할 지 설명해주어야 한다.
const Array = [[1, 1], [2, -3], [4, 5], ...]
let sum = Array
.filter(([x, y]) => x > 0 && y > 0)
.slice(0, 5)
.map(([x, y]) => Math.sqrt(x*x + y*y))
.reduce((acc, val) => acc + val, 0)
반대로 선언형으로 다룬다면 위처럼 ‘무엇’을 해야 할지만 밝힐 뿐 ‘어떻게’에 대한 로직은 숨길 수 있다. 그래서 우리는 배열을 다룰 때 다양한 메서드를 사용해서 코드의 가독성과 간결성을 향상시킨다.
우리는 그동안 비동기 이벤트를 명령형으로 다루는 경우가 많았다. 왜냐하면 비동기 데이터를 받아오는 출처가 각기 다르고(이벤트 리스너, API 호출…), 한 로직에서 한 개의 비동기 데이터만 다룰 수 있기 때문이다. TV로 치면 시간표가 있는 게 아니라 무한도전만 하는 채널, 음악중심만 하는 채널… 방송 별로 채널이 나뉘어 있어 사용자가 보려는 방송에 따라 계속 채널을 돌려야 하는 셈이다.
예를 들어, 연속적인 클릭 이벤트를 모아서 좌표들의 평균 지점을 구한다고 생각해 보자.
const [clickedPoints, setClickedPoints] = React.useState([])
document.addEventListener("click", (e) => {
setClickedPoints([...clickedPoints, e.target.value])
});
React.useEffect(() => {
// 평균 구하기
}, [clickedPoints])
클릭 이벤트를 모으는 데만 위와 같은 로직이 필요하다. 왜냐하면 비동기 이벤트를 연속적으로 다루지 않고, 각 이벤트를 발생 시점의 snapshot으로 다루기 때문이다.
import { fromEvent } from "rxjs";
import { throttleTime, scan } from "rxjs/operators";
fromEvent(document, "click")
.pipe(
map((event) => event.target.value),
reduce(
// 평균 구하기
)
)
반대로, RxJS를 사용하면 이벤트를 모으는 로직이 필요없다. 또한, 일련의 이벤트를 어떻게 처리할지 선언형으로 작성할 수 있게 된다. Array를 다룰 때처럼 여러 메서드를 사용해, 간결한 로직을 완성할 수 있게 되는 것이다.
위에서 React-Query로 작성한 polling 로직을 다시 가져와보자.
useQuery({
queryKey: [taskId],
queryFn: () => fetchTaskStatus(taskId),
enabled: Boolean(taskId),
refetchInterval: 2000,
onSuccess: data => {
if (data && fnCondition(data)) {
setTaskId('');
setIsModalOpen(false)
}
},
onError: () => {
if (taskId) {
setTaskId('')
}
},
})
나는 여기서, onSuccess 콜백 안에서 조건을 작성해야 한다는 점을 개선하고 싶었다. 내가 원하는 건 status === true 인 res
다. 반면 위 로직에서는 ‘각 호출에 성공할 시’ 라는 조건이 한 depth 더 생기는 셈이다. 그리고 콜백에서 다루는 data는 연속적인 데이터가 아니라, 요청 성공으로 돌려받은 단 한 개의 데이터를 의미한다. 하지만 polling은 일정 간격을 두고 연속적으로 데이터를 가져오는 개념이다. 따라서, RxJS에서 다루는 ‘스트림’의 개념과 더 잘 부합한다고 생각했다.
RxJS로 polling 로직을 작성하면, interval 이라는 operator를 활용해 일정 주기로 계속 프라미스를 emit 하도록 구현할 수 있다. 그리고 pipe를 이용해 방출된 프라미스를 보고 status 값을 판단해 적절한 로직 처리를 해줄 것이다.
코드를 보기 전, RxJS를 접한 지 반나절만에 작성한 코드(엉성함)임을 먼저 밝힌다.
const task = React.useRef(new Subject());
const polling = () => {
return interval(2000)
.pipe(
take(60),
takeUntil(task.current),
mergeMap(fetchData),
first(res => fnCondition(res), null)
)
.toPromise()
.then(resPromise => {
if (!resPromise) {
setTaskStatus('pending');
handleClose();
return;
}
if (res.done) {
setTaskStatus('done');
handleClose();
return;
}
})
.catch(errPromise => {
console.error(errPromise);
setTaskStatus('rejected');
handleClose();
return;
});
};
polling();
이때는 polling이 작동하는 것만으로도 아주 뿌듯했는데, 지금 다시 보니 꽤나 괴랄한 로직을 탄생시킨 것 같기도 하다. 기껏 RxJS를 쓰면서 toPromise로 다시 프라미스로 돌려놓은 것과, 아래의 반복되는 명령형 로직들이 신경쓰인다. 개선의 여지가 많지만, 일단 이 코드를 이해해보자.
지금 보니 toPromise 할 필요 없이 tap operator를 사용해 훨씬 간결하게 작성할 수 있었을 것 같다! 사실 이 코드만 놓고 보면 React-Query로 작성하는 편이 훨씬 간결하고 좋아 보이지만, RxJS의 장점을 이해하고 써 보았다는 데 의의를 두었다. 😂
다만, React-Query의 queryKey는 queryProvider 범위 안에서 공유되는 반면 RxJS Observable은 공유되지 않는다. 그래서 여러 컴포넌트에 걸쳐 공유되어야 하는 값을 제어한다면 React-Query로 구현하는 편이 나을 것 같다.
이제, 개선된 polling 로직을 통해 사용자가 취소 버튼을 누르면 모달을 닫고 polling을 중단하는 데까지는 성공했다. 그리곤 또 다른 문제를 만났다. 맨 처음 계획했던 polling 로직을 다시 보자.
위 두가지 케이스 모두, polling 중단 뿐 아니라 앞서 보낸 POST 요청의 취소가 필요한 사항이다. 그래서 abortController를 사용했다. MDN을 보면, “AbortController
인터페이스는 하나 이상의 웹 요청을 취소할 수 있게 해준다.” 라고 소개하고 있다. 그러니 이걸 사용하면 맨 처음 보냈던 POST 요청을 취소할 수 있을 것이라고 생각했다.
const abortCtrl = React.useRef(new AbortController());
const handleRequest = async () => {
// abort previous fetching
abortCtrl.current.abort();
abortCtrl.current = new AbortController();
// send request
postData(
{...params},
{ signal: abortCtrl.current.signal }
)
.catch(err => {
// handle abort
if (err.name === 'AbortError') {
return;
}
// handle other error cases
...
})
}
useQuery를 사용하면 new AbortController()
조차 필요없이 간단히 구현할 수 있지만, 나는 async 함수 안에서 polling 로직과 함께 호출하느라 그냥 axios를 통해 구현했다.
로직에는 문제가 없었다. 그러나 분명 network 탭에서 canceled
라고 표시되는 요청이, 그대로 처리되어 새로고침 해보면 UI가 업데이트 되어 있는 사태가 발생했다. 취소한 요청이 반영되다니. 귀신인가, 사이버 퇴마가 필요한가?
문제의 원인은 ‘요청을 취소한다’ 라는 개념을 어떻게 이해하느냐에 있었다. abortController를 사용하면 내가(클라이언트) 보낸 요청을 취소하는 건 맞다. 그러나 서버의 작업을 취소시킬 수는 없다.
위의 로직은 리스트 아이템을 불러온다든가 하는 GET 요청에서는 완벽하게 작동한다. 예를 들어 데이터를 불러오는 데 5초가 걸리는 리스트가 있다고 해 보자. 5초의 로딩 동안 사용자가 버튼을 계속 누르면, 불필요하게 같은 값을 여러 번 요청하는 셈이므로 비효율적이다. 또는, 받아온 데이터를 쌓아서 스크롤 리스트로 보여주려 한다면 중복된 값이 계속 리스트에 쌓여서 의도와 다른 UI가 보이게 될 것이다. 이런 경우 클라이언트가 GET하려던 데이터를 discard하는 것으로 해결할 수 있다.
그러나 서버에 데이터를 POST하는 것은 다른 케이스다. 요청을 받은 순간 클라이언트가 그 데이터를 사용하든, discard시키든 관계없이 서버는 일단 데이터를 처리한다. abort()를 통해 구현되는 것은 클라이언트가 해당 POST 요청의 response를 기다리지 않게 되는 것뿐이다.
내가 의도했던 서버 작업의 중단을 위해서는 서버 쪽에서 롤백 등 abort signal 핸들링 로직을 구현해주어야 가능하다. 따라서 싱겁게도 결론은 로딩 중 모든 버튼들을 disabled시키는 것으로 끝났지만, abortController를 처음 접하고 사용해볼 수 있는 경험이었다! 바로 문제를 만나버려 오히려 개념을 더 제대로 이해할 수 있었다.