저번 낙관적 업데이트에 이어서, 이번에는 전역 디바운스 적용기이다.
살짝 언급되었지만, Motimo FE에서 API핸들링 관련 코드는 전역으로 관리되고 있다.
따라서 서비스 시연 시간에 동료 FE들에게 Debounce적용은 기본이라는 말을 들었을 때, '맞다!' 이외에도 '어떻게 하지?' 라는 생각이 들었다.
일반적인 디바운스 작업은 이벤트 핸들링 근처에 붙이는 것으로 알고 있기 때문이다.
그러나 그러기엔 너무 서비스 복잡도가 높아졌기 때문에, 전역에서 처리하는 방법을 생각하게 되었고 꽤 성공적으로 해결했기에 기록을 남긴다.
우선 배경 상황을 설명해야겠다.
꽤 설명하기 복잡하기 때문에, 우선 코드를 첨부한다.
const httpClient = new HttpClient({
baseUrl: (() => {
return process.env.API_URL || "";
})(),
...
// API 클라이언트 인스턴스 생성
export const api = new Api(httpClient);
차근 차근 설명하겠다.
HttpClient와 Api 클래스는 swagger-typescript-api를 통해 제작되었다.
htteClient는 baseUrl, Authorization Header 등의 처리를 담당한다.
api는 httpClient를 사용하여 동작하는 각 api들의 모음인 object이다.
re-generated를 통해 코드가 삭제될 수도 있는 것이다..
이 문제를 해결하기 위해, httpClient.request를 감싸는 Debounce-Wrapper를 만들어 감싼 뒤, httpClient.request에 덮어쓰는 방식을 사용했다.
기존 구조를 건드리지 않으면서 기능을 편입시키는 유일한 방식으로 보였기 때문이다.
타입처리는 아래와 같이 해결했다.
const debouncer = <T, E>(apiRequest: typeof httpClient.request<T, E>) => {
디바운스를 전역처리하기 위해, 개별 요청에 대해 timer 처리가 필요했다.
이를 위해 Object에 API URL을 key로, timer id값 (혹은 undefined)를 value로 처리했다.
핵심 쟁점은 httpClient.request의 반환인 Promise를 그대로 반환하면서도 디바운스 동작을 처리해야 한다는 것이었음.
이로 인해 생기는 고려사항은 아래와 같았다.
Promise안에 setTimeout 두기
각 API URL에 대한 타이머 Object 클로저 활용
Promise 외부 reject 할당 및 실행
위의 고려사항을 따져 작성한 전역 디바운서는 아래와 같았다.
// 주의! 아래 구조에 결함이 있음. 추후 설명 예정
const debounceer = <T, E>(apiRequest: typeof httpClient.request<T, E>) => {
const timeLimit = 300;
const timerDictionary: { [apiFullUrl: string]: number | undefined } = {};
let rejectTimer: (reason?: any) => void;
return (
requestParams: Parameters<typeof httpClient.request<T, E>>[0],
): ReturnType<typeof httpClient.request<T>> => {
const apiFullUrl = `${requestParams.path}?${requestParams.query}`;
const timer = timerDictionary[apiFullUrl];
if (timer) {
clearTimeout(timer);
rejectTimer();
// rejectTimer("debouncing");
}
const apiRes: Promise<T> = new Promise((resolve, reject) => {
rejectTimer = () => reject("debouncing");
timerDictionary[apiFullUrl] = Number(
// timer = Number(
setTimeout(async () => {
try {
// 비동기 동작 이후..
timerDictionary[apiFullUrl] = undefined; // timer비워주기..
resolve(res);
} catch (error) {
showToast(`API ERROR`, new Date());
}
}, timeLimit),
);
});
디바운스 처리에 대해 Toast 동작도 추가했기에 꽤 만족스러웠으나...
이번 회고록을 작성하며 다시 생각해보니, rejectTimer를 timer마다 자신의 것을 갖고 있어야 했다.
언뜻 정상 작동했던 것으로 보이는 이유는, 일반적으로 디바운스가 발생하는 상황에서는 동일 fetching이 연속적으로 발생하므로, rejectTimer함수는 이전 Promise에 대한 것임이 잘 들어맞았던 것임.
이 부분을 수정하면 더 좋은 코드가 될 것이다.
이 디바운싱 처리로 인해 SSR 전환 과정에서 어려움을 겪게 되었는데, 바로 에러 처리 때문이었음.
정확히는 전역 API관리 때문에 어려움을 겪었던 것인데, 디바운스가 꽤 큰 영향이 있었음.
reject등을 통해 에러를 던지게 되는데, 이 때 서버 환경에서 받은 에러를 통해 Streaming SSR 과정에서 RSC Payload 전송 과정에서 문제가 발생하여 아예 서비스가 동작하지 않게 되었던 것.
채용 시즌에, 토스에 지원했었는데 관련 테스트 문제 중 Result Pattern 이라 알려진 구조를 제작하는 것이 있었다.
해당 테스트를 망치고 이를 공부하며 도대체 왜 이런 구조를 사용하는지 이해가 가지 않았는데, 이번 경험을 통해 아예 에러를 발생시키면 안되는 상황을 알게 되어 뜻깊었다.
아무튼, 서버 환경에서 에러를 발생시키지 않도록, 그리고 showToast와 같이 전역 상태 훅을 사용하지 않도록 typeof window==='window'를 통해 분기 처리를 수행하여 전역 디바운스를 고도화했다.
이번 회고록을 작성하며, Gemini에게 다시 몇가지 물어보고 생각을 정리하게 되었다.
애초에 왜 Promise 안에 setTimeout을 넣어야 했는지, Reject관련해서 구조적 문제가 있는게 맞는지 등등.
이 과정에서 이렇게 Promise제어권을 외부에 전달하여 처리하는 방법을 Deferred Pattern이라 하며, Promise를 처리하는 여러 패턴 중 하나라는 것을 알게 되었다.
또한, 제한된 상황에서 Wrapping 방식으로 기능을 추가했던 경험은 다른 상황에서도 유용할 것 같았고, 사실 토큰 재발급 처리도 이 방식으로 API 결과를 감싸서 처리했는데 이는 별로 좋지 못한 상황이기에 작성하지 않기로 했다.
간단하게 정리하자면, 401에러에 대해 토큰 재발급 API 호출을 전역으로 처리하기 위한 것이었는데, 애초에 이러한 방식으로 토큰을 운영하는 것 자체가 문제인 것 같았다. 확실히 아예 BE에 맡기던가, FE라면 Middleware나 Proxy (Next.js에서) 를 통해 관리하는 것이 옳기 때문이다.
결과적으로 요구사항을 고려하며 머리를 짜내서 생각해낸 방법이 이미 존재하는 방법임을 알게 되어 이러한 패턴들을 공부해야함을 느꼈고,
조금 더 잘 정리하고 테스트를 작성하는 등의 후처리 과정을 통해 이번과 같은 결점이 없도록 개발해야 함을 느꼈다.