CHAPDO

금쪽이 육아방법 : throttle, debounce에 관하여

2025-01-05
throttledebouncelodashuseThrottleuseDebounce

🙃 들어가며

최근에 스웨거챗이라는 오픈소스 프로젝트를 진행했다. 스웨거 문서에 대해 더이상 백엔드 개발자에게 물어보는 것이 아닌, 챗을 통해 쉽게 질문하고 답변을 얻을 수 있는 서비스이다.

MVP 제작 후 받은 피드백 중 하나는 채팅에 디바운스 걸어주세요!, 엔터가 빠르면 여러번 눌립니다! 였다. 해당 피드백을 받고나서 아차!싶었다. 뭔가 기본적인 걸 놓친 느낌? 그러면서도 기본적인 거라고 생각했으면서도 그동안 내가 한 프로젝트에서 디바운스 신경을 얼마나 썼었나? 생각이 들었다. 더 나아가서 debounce가 뭐고 throttle이 뭔지 내가 제대로 알기는 하나 라는 생각까지 이어졌다.

그래서 오늘은 debounce와 throttle, 그리고 useDebounce, useThrottle까지 살펴볼 계획이다.

🙃 debounce, throttle

나는 debounce와 throttle 필요성을 절실히 느낄 때는 동료 개발자들에게 프로젝트 피드백을 받을 때이다. ‘제가 이번에 이런 거 만들어봤는데 한 번 봐주세요’하면 열 중에 아홉은 미친듯한 버튼 광클을 한다. 내 프로젝트를 터지게 하기 위한 그들의 미친듯한 노력을 보며 항상 디바운스 혹은 쓰로틀을 걸어야겠다고 다짐했던 것 같다.

 

이게 무슨 이야기인지 조금 더 자세하게 이야기해보면 다음과 같다. 스웨거챗에는 AI에게 스웨거에 대한 질문 혹은 대화를 챗으로 보낼 수가 있다. 이때 유저가 광클을 했다고 가정해보자. 그러면 광클의 횟수대로 유저의 메세지는 AI에게 보내질 것이며 그만큼 AI 사용 기회가 차감될 것이다. 광클을 막지 않으면 무분별한 서버 요청이 발생하고 이는 큰 손실로 이어질 것이다.

 

그러면 이런 금쪽이같은 유저를 막기 위해서는 어떻게 해야할까? 광클 금지라는 문구로는 그들을 막을 수 없을 것이다. 그때 우리에게 필요한 것이 바로 debounce와 throttle이다.

 

광클을 막는다는 같은 목표 아해 debounce와 throttle이 존재하지만, 약간의 차이가 있다. 광클을 좋아하는 금쪽이 자식이 있다고 생각해보자.

 

Javascript
// 쓰로틀된 "안돼!" - 3초마다 한 번씩 단호하게 말함 const throttledNo = useThrottle(() => { console.log("안돼!"); }, 3000); // 디바운스된 "안돼!" - 금쪽이가 조용해지고 3초 후에 말함 const debouncedNo = useDebounce(() => { console.log("안돼!"); }, 3000);

 

그러면 먼저 throttle 부모(throttledNo)를 보면 아래와 같다.

Javascript
금쪽이: (광클 시작) 부모: "안돼!" (즉시) 금쪽이: 칭얼칭얼 (계속 떼쓰는중...) 부모: (3초 동안 침묵) 부모: "안돼!" (3초 후) 금쪽이: (계속 떼쓰는중...) 부모: (다시 3초 동안 침묵) 부모: "안돼!" (다음 3초 후)

throttle은 즉각적인 반응을 보인다. 하지만 이후 3초 동안은 무슨 일이 있어도 반응을 안 한다. 그러다가 3초가 지나면 다시 반응을 하는 것이다.

 

반면에 debounce 부모(debouncedNo)는 약간의 차이가 있다.

Javascript
금쪽이: (광클 시작) 부모: (침묵) 금쪽이: 칭얼칭얼 (계속 떼쓰는중...) 부모: (계속 침묵) 금쪽이: (지쳐서 멈춤) 부모: (3초 더 기다림) 부모: "안돼!" (금쪽이가 멈추고 3초 후)

throttle 부모보다 더 인내심이 있다고 볼 수 있다. 난리를 피우면 아무런 반응도 해주지 않다가 금쪽이가 멈춰야 그제서야 반응을 해준다.

 

🙃 lodash 속 debounce, throttle

그러면 이제 비유에서 더 발전하여 lodash 실제 코드를 보면서 debounce와 throttle에 대해서 알아보자

debounce와 throttle은 프로그래밍에서 오래전부터 존재해온 일반적인 프로그래밍 패턴이다. 그런데 아무래도 Lodash는 이 패턴들을 더 안정적이고 사용하기 쉽게 구현하여 인기를 얻었기 때문에 이를 기준으로 살펴보고자 한다.

 

debounce

 

기본 매개변수부터 살펴보면

Javascript
function debounce(func, wait, options) {

func는 실행하고자 하는 함수이다. 그리고 wait는 대기 시간 (밀리초)을 의미한다. 이때 설정 객체 options가 존재하는데 첫 번째 호출 즉시 실행 여부인 leading , 마지막 호출 후 실행 여부인 trailing, 최대 대기 시간인 maxWait가 있다.

 

상태 변수 역시 여러 개가 존재하지만 주요 상태 변수만 뽑아보면 아래와 같다.

Javascript
var lastArgs, // 마지막으로 전달된 인자들 lastThis, // 마지막 this 컨텍스트 lastCallTime, // 마지막으로 호출된 시간 lastInvokeTime, // 마지막으로 실제 실행된 시간 timerId; // setTimeout의 ID

 

여러 함수들이 있지만 핵심인 debounced()만 보고자 한다.

Javascript
function debounced() { var time = now(), // 현재 시간 isInvoking = shouldInvoke(time); // 실행해야 하나? // 현재 호출 정보 저장 lastArgs = arguments; lastThis = this; lastCallTime = time; if (isInvoking) { // 타이머가 없으면 (첫 실행) if (timerId === undefined) { return leadingEdge(lastCallTime); } // maxWait 옵션이 있으면 if (maxing) { // 타이머 재설정하고 즉시 실행 clearTimeout(timerId); timerId = setTimeout(timerExpired, wait); return invokeFunc(lastCallTime); } } // 타이머 없으면 새로 설정 if (timerId === undefined) { timerId = setTimeout(timerExpired, wait); } return result; }

 

금쪽이가 처음 떼를 쓰기 시작한다. (leading: true일 때) 여기서 leading 옵션 true는 일반적인 debounce와 약간의 차이가 있다. 일반적인 debounce는 조용히 기다리지만, leading: true로 설정하면 첫 호출에는 즉시 반응하고, 그 이후 호출들은 무시하는 방식으로 동작한다.

 

Javascript
if (timerId === undefined) { return leadingEdge(lastCallTime); }

금쪽이가 “으아아아ㅏㅏㅏㅏ악” 하면서 떼쓰기 예열을 준비하면, 다시 말해 leading이 true면 부모는 가차없이 “안돼”라고 외치면서 타이머를 시작한다.

 

하지만 여기서 멈추면 금쪽이가 아니다. 금쪽이는 계속 떼를 쓴다. 이제 악다구를 넘어서서 바닥에 널브러진다.

 

Javascript
if (timerId === undefined) { timerId = setTimeout(timerExpired, wait); }

debounce 부모는 강하다. 타이머가 이미 실행 중이면 새로운 타이머로 리셋해버린다.

 

Javascript
function trailingEdge(time) { if (trailing && lastArgs) { return invokeFunc(time); } }

어느새 금쪽이도 진정을 하고 울음을 그친다. 마지막 떼쓰기가 끝나고 wait 시간이 지나면 debounce 부모는 "이제 진짜 안돼!"라고 말하면서 끝낸다.

 

Javascript
[호출들의 시작] [호출들의 끝] leadingEdge trailingEdge | | v v 클릭!클릭!클릭!클릭!클릭! ... 3초후

정리하면 위와 같은 느낌이다.

 

throttle

 

기본 구조는 아래와 같다.

Javascript
function throttle(func, wait, options) { var leading = true, // 기본값: 첫 호출 실행함 trailing = true; // 기본값: 마지막 호출도 실행함

 

throttle의 경우 debounce를 사용하고 있다는 점이 특이하다.

Javascript
return debounce(func, wait, { 'leading': leading, // 첫 호출 실행 여부 'maxWait': wait, // 이게 throttle의 핵심! 'trailing': trailing // 마지막 호출 실행 여부 });

이 코드를 보면 결국 throttle은 maxWait: wait를 설정한 debounce라고도 할 수 있다는 사실을 알게 된다. 이게 무슨 의미나면, debounce는 원래 "금쪽이가 조용해질 때까지" 기다리지만 maxWait을 설정하면 "아무리 금쪽이가 떼를 써도 이 시간이 지나면 반응한다"는 의미이다. 오은영 박사의 훈육 방식에 더 가깝다고 볼 수 있다. deounce 부모 보다 더 반응을 잘 해준다.

금쪽이가 처음 떼를 쓰면 즉시 "안돼!" (leading: true)라고 throttle 부모는 외친다. 3초 동안은 아무리 떼를 써도 무시한다. 하지만 3초가 지나면 반응을 해준다. "안돼!" (trailing: true)를 외치고 또 3초 동안 무시하고 이런 과정이 계속 반복되는 것이다.

 

Javascript
[3초 주기의 시작] [3초 주기의 끝] [새로운 3초 주기] leading=true trailing=true leading=true | | | v v v 클릭!클릭!클릭! ... 3초지남 ... 클릭!클릭!클릭! ... 3초지남 "안돼!" "안돼!" "안돼!" ↑_______무시_______↑ ↑______무시______↑ 3초 동안 3초 동안

정리하면 위와 같은 느낌이다.

🙃 useDebounce, useThrottle

나는 그동안 debounce, throttle 개념을 어떻게 구현했을까? lodash를 통해서 구현하는 방식을 택했었다. 그런데 이번에 새로 알게된 것이 바로 useDebounce와 useThrottle이다.

그러면 왜 훅을 활용하는걸까? 이유는 간단하다.

lodash를 직접 사용할 때보다 React의 생명주기에 맞게 동작한다. lodash 직접 사용하면 컴포넌트가 리렌더링될 때마다 새로운 throttle 함수 생성된다. 하지만 훅을 사용할 경우 의존성 배열이 변경될 때만 새로운 throttle 함수 생성되게 된다.

Javascript
// 문제가 있는 lodash 직접 사용 function BadComponent() { // 컴포넌트가 리렌더링될 때마다 새로운 throttle 함수 생성 const handleScroll = throttle(() => { console.log(window.scrollY); }, 1000); } // 훅을 사용한 올바른 방식 function GoodComponent() { // 의존성 배열이 변경될 때만 새로운 throttle 함수 생성 const handleScroll = useThrottle(() => { console.log(window.scrollY); }, 1000); }

또한 메모리 누수 방지도 가능해진다. 컴포넌트가 언마운트되어도 throttle의 타이머가 계속 남아있을수도 있다. 하지만 훅을 사용할 경우 useThrottle 내부에서 자동으로 throttle.cancel() 호출해서 메모리 누수를 방지한다.

Javascript
// 메모리 누수가 발생할 수 있는 경우 function LeakyComponent() { useEffect(() => { const handleScroll = throttle(() => { console.log(window.scrollY); }, 1000); window.addEventListener('scroll', handleScroll); // 컴포넌트가 언마운트되어도 throttle의 타이머가 계속 남아있음 }, []); } // 훅을 사용한 안전한 방식 function SafeComponent() { const handleScroll = useThrottle(() => { console.log(window.scrollY); }, 1000); useEffect(() => { window.addEventListener('scroll', handleScroll); return () => { window.removeEventListener('scroll', handleScroll); // useThrottle 내부에서 자동으로 throttle.cancel() 호출 }; }, [handleScroll]); }

마지막으로 불필요한 리렌더링 방지까지 된다.

Javascript
// 리렌더링이 발생하는 경우 function InefficientComponent() { const [count, setCount] = useState(0); // 매 렌더링마다 새로운 throttle 함수 생성 const handleChange = throttle(() => { console.log(count); }, 1000); return <button onClick={handleChange}>Click me</button>; } // 훅을 사용한 최적화된 방식 function OptimizedComponent() { const [count, setCount] = useState(0); // preservedCallback으로 안정적인 참조 유지 // useMemo로 throttle 인스턴스 재사용 const handleChange = useThrottle(() => { console.log(count); }, 1000); return <button onClick={handleChange}>Click me</button>; }

 

그러면 잘 만든 useDebounce, useThrottle 훅 예시를 어디서 볼 수 있을까? 나는 @toss/slash의 useDebounceuseThrottle을 선정하여 살펴보기로 했다.

 

두 훅은 구조가 매우 비슷하다.

매개변수부터 보면

Javascript
function useDebounce/useThrottle( callback: F, // 실행하고자 하는 원본 함수 wait: number, // 대기 시간 (밀리초) options = {} // 추가 설정 객체 )

 

그리고 사용하고 있는 핵심 훅 같은 경우 아래와 같다.

Javascript
const preservedCallback = usePreservedCallback(callback); const preservedOptions = usePreservedReference(options);

usePreservedCallback 은 콜백 함수의 안정적인 참조를 유지한다. 리렌더링되어도 함수의 참조가 유지되어서 불필요한 리렌더링을 방지하게 한다. usePreservedReference는 options 객체의 안정적인 참조를 유지한다. 객체의 내용이 같다면 참조도 같게 유지해준다.

 

이제 debounce/throttle 함수 생성하는데

Javascript
const debouncedCallback = useMemo(() => { return debounce(preservedCallback, wait, preservedOptions); }, [preservedCallback, wait, preservedOptions]);

useMemo를 사용해서 디바운스/쓰로틀된 함수를 메모이제이션하는 것을 볼 수 있다. 의존성 배열의 값([preservedCallback, wait, preservedOptions])이 변경될 때만 새로운 함수를 생성하는 식으로 React의 생명주기에 맞게 동작한다.

 

마지막으로 컴포넌트가 언마운트되거나 디바운스/쓰로틀된 함수가 새로 생성될 때 이전 타이머를 정리해서 메모리 누수를 방지한다.

Javascript
useEffect(() => { return () => { debouncedCallback.cancel(); }; }, [debouncedCallback]);

 

🙃 SwaggerChat에 적용해보기

마지막으로 간단한 채팅 입력창 예시를 들어보도록 하겠다.

Javascript
// useDebounce 사용 function ChatInput() { const [message, setMessage] = useState(''); const debouncedSave = useDebounce((text: string) => { // 사용자가 타이핑을 멈추고 500ms 후에만 저장 saveDraft(text); }, 500); return ( <input value={message} onChange={e => { setMessage(e.target.value); debouncedSave(e.target.value); }} /> ); } // useThrottle 사용 function ChatScroll() { const throttledHandleScroll = useThrottle(() => { // 스크롤 이벤트는 100ms마다 최대 한 번만 실행 checkNewMessages(); }, 100); useEffect(() => { window.addEventListener('scroll', throttledHandleScroll); return () => window.removeEventListener('scroll', throttledHandleScroll); }, [throttledHandleScroll]); return <div className="chat-container">...</div>; }

성능 최적화 (불필요한 함수 생성과 호출 방지), 메모리 관리 (자동 클린업), React의 생명주기와의 조화, 타입 안정성 (TypeScript 지원). 매우 귀찮고 어려워 보이는 이 모든 것들을 훅을 사용해서 debounce와 throttle을 구현하면 이 모든 것을 자동으로 처리해준다. 나는 이제 단순히 훅을 사용하기만 하면 되고, 복잡한 내부 로직은 신경 쓰지 않아도 되게 되는 것이다.

 

열심히 글을 썼는데 마무리를 어떻게 해야할지 모르겠다. 스웨거챗 많관부.