문제
가끔 프론트엔드에서 실시간으로 입력을 받아 검색어에 따른 추천 검색어나 자동완성을 제공해줘야 하는 상황이 있다.
이를 구현할 때 여러번 동일한 문제를 마주친 적이 있는데, 바로 입력값이 바뀜에 따라 api 요청을 하게 되면 너무 많은 api 요청이 발생한다는 점이다.
위의 프로젝트에서 검색창에 "선릉역"을 검색하면, "ㅅ,서,선,ㄹ,르,릉,ㅇ,여,역"과 같이 9번의 api 요청이 발생하게 된다.
물론 바로바로 값을 받아 기능을 수행하면 좋겠지만, 다음과 같은 애로사항이 있을 수 있다.
- 유료 api의 경우 비용이 많이 발생함.
- 너무 빠른 타자로 입력할 시 깜빡임이나 이전 결과값이 나올 수 있음.
- 검색과 관련한 다른 기능과 비동기의 타이밍이 안맞아서 잠재적인 에러가 발생할 수 있음.
위 3가지의 사항 모두 실제로 겪어본 문제인데.. 이런 경우에 도움이 될 수 있는 해결방법이 쓰로틀링(Throttling)과 디바운싱(Debouncing)이다.
이론
쓰로틀링과 디바운싱 모두 불필요한 이벤트의 발생을 줄이기 위한 방식으로 위 사항말고도 event listener 등 여러 상황에서 사용할 수 있다. 두 방식은 지향점은 같지만 동작 방식의 차이가 있다.
사실 쓰로틀링과 디바운싱 내에서도 리딩 방식이냐 트레일링 방식이냐에 따라 이벤트의 발생 시점이 다를 수 있다.
그래서 아래 설명에 대한 방식을 굳이 따지자면 다음과 같다고 할 수 있다.
쓰로틀링
{ Leading: true, Trailing: false }
디바운싱
{ Leading: false, Trailing: true }
만약 Leading과 Trailing에 따른 각 방식의 자세한 동작원리가 궁금하다면 해당 블로그 글을 참고해보자.
쓰로틀링 (Throttling)
쓰로틀링은 이벤트를 일정한 주기마다 발생시키는 방법이다.
즉, 마지막 함수가 호출된 후 일정 시간이 지나기 전에는 다시 호출되지 않도록 하는 것이다.
아래 예시는 쓰로틀링을 3초로 설정했을 때의 결과를 나타낸 이미지이다.
0초에 이벤트가 실행되고 나면, 3초가 지나기 전까진 아무리 많은 이벤트가 발생하더라도 무시된다.
그 후 3초가 되면 다시 이벤트가 일어나고 3초가 지나기 전까진 동일하게 이벤트가 발생되더라도 무시된다.
쓰로틀링은 실시간에 가깝게 UI를 업데이트해야 하지만, 이벤트가 너무 잦을 때 성능 문제를 방지하고 싶을 때 사용하면 좋다.
그래서 무한 스크롤, 화면 리사이즈 이벤트와 같은 상황에 주로 사용된다.
디바운싱(Debouncing)
디바운싱은 이벤트 발생이 종료되고 정해진 시간 동안 이벤트가 발생하지 않을 경우 이벤트를 발생시키는 방법이다.
즉, 이벤트가 발생하지 않는 시점을 기준으로 정해진 시간 동안 이벤트가 발생하지 않아야 이벤트가 발생된다.
아래 예시는 디바운싱을 3초로 설정했을 때의 결과를 나타낸 이미지이다.
0초 이후 더 이상의 이벤트가 발생하지 않았기 때문에 3초 후에 실행되었다.
그 후 4초에 이벤트가 실행되어 7초에 실행 예정이었는데, 5초에 이벤트가 발생되어 타이머가 초기화되었고 8초에 실행되게 되었다.
디바운싱은 사용자가 연속된 액션을 멈춘 뒤에 한 번만 처리해도 되는 경우에 사용하면 좋다.
그래서 주로 검색어 자동완성, 폼 입력 검증과 같은 상황에 쓰이곤 한다.
구현
사실 lodash라는 라이브러리나 useHooks 사이트를 살펴보면 쉽게 가져다 쓸 수 있다.
현업이라면 만들기 보단 가져다 쓰는 것이 효율적이지만, 한 번 경험상 만들어 봤다.
useThrottle
Leading: true, Trailing: false
useThrottle은 현재 시간을 기반으로 wait 시간이 얼마나 지났는지 판단해 실행 가능한지 아닌지를 결정하도록 하였다.
import { useCallback, useRef } from 'react';
export function useThrottle<T extends unknown[]>(
callback: (...args: T) => void | Promise<void>,
wait = 1000,
) {
const lastCallTime = useRef<number | null>(null);
const throttleFn = useCallback(
(...args: T) => {
const now = Date.now();
// 첫 호출이거나, wait 시간이 지난 경우 바로 실행되도록 한다.
if (lastCallTime.current === null || now - lastCallTime.current >= wait) {
lastCallTime.current = now;
callback(...args);
}
},
[callback, wait],
);
return throttleFn;
}
useDebounce
Leading: false, Trailing: true
useDebounce는 setTimeout을 이용해서 구현하였고 컴포넌트가 언마운트 된 후 불필요한 실행 방지하기 위해 useEffect를 사용해 타이머가 정리될 수 있게 해 주었다.
import { useCallback, useEffect, useRef } from 'react';
export function useDebounce<T extends unknown[]>(
callback: (...args: T) => void | Promise<void>,
wait = 1000,
) {
const timeout = useRef<ReturnType<typeof setTimeout>>();
const debounceFn = useCallback(
(...args: T) => {
// 2. 이미 실행되었다면, 기존의 타이머를 취소한다.
if (timeout.current) {
clearTimeout(timeout.current);
}
// 1. 처음 실행된다면, 타이머를 실행한다.
// 3. 다시 타이머를 실행한다.
timeout.current = setTimeout(() => {
callback(...args);
}, wait);
},
[callback, wait],
);
// 컴포넌트가 언마운트될 때 불필요하게 실행되지 않도록 보장하기 위해서 이전에 설정된 setTimeout을 취소한다.
useEffect(() => {
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, []);
return debounceFn;
}
React 기반으로 훅을 만들긴 했는데, Vue의 경우에도 로직의 흐름은 똑같으니 ref와 watch를 적절히 사용해 구현하면 될 것 같다.
비교
실시간 검색을 받아 일치하는 목록을 보여줘야 하는 기능에 위의 두 코드를 적용해 봤다.
쓰로틀링과 디바운싱 모두 300ms 타이머를 설정해 주었다.
영상에서 보이듯이 쓰로틀링의 경우 0.3초마다 결과를 보여주고, 디바운싱의 경우 입력이 0.3초 동안 이뤄지지 않으면 요청이 보내진다.
이를 보면 알 수 있듯이 실시간 검색에는 개인적으로 디바운싱을 사용하는 것이 적합해 보인다.
왜냐면, 쓰로틀링의 경우엔 마지막으로 요청이 들어간 쿼리가 "선릉ㅇ"으로 "선릉역"이 제대로 검색된 것이 아니기 때문이다.
(물론 쓰로틀링 방식도 Trailing Edge 방식으로 한다면 마지막 요청을 보장하도록 할 수 있다.)
성능
기존의 아무런 방식도 적용하지 않았을 때 보다 요청 횟수를 9번 -> 1번으로 줄일 수 있었다.
요청이 줄어들다 보니 당연히 전송량도 줄어들었다.
현재 프로젝트의 경우 네이버 Search API를 사용하고 있는데 사용량을 25000번까지 허용해 줘서 아직 한도를 채우는 경우는 잘 없지만 아마 많은 유저가 사용할 경우 분명 문제가 되었을 것 같다. 기존의 방식을 유지했다면 100명이 "선릉역"을 한 번씩만 검색해도 900번의 호출이 발생할 것이니 아찔했을 것이다.
'Programming' 카테고리의 다른 글
[우테코 프리코스/7기] 프리코스 최종 코딩테스트 후기 (36) | 2024.12.17 |
---|---|
[우테코 프리코스/7기] 프리코스 3주 차 회고 (4) | 2024.11.07 |
[우테코 프리코스/7기] 프리코스 2주 차 회고 (1) | 2024.11.02 |
[우테코 프리코스/7기] 프리코스 1주 차 회고 (4) | 2024.10.24 |
[우테코 프리코스/7기] 커밋 방식 알아보기 (4) | 2024.10.16 |