Debounce와 Throttle은 모두 이벤트를 제어하기 위한 개념입니다. 여기서 이벤트를 제어한다는 것은, 이벤트의 실행 빈도를 줄여 성능을 최적화한다는 것을 의미합니다.
이번 글에서는 이벤트를 제어하는 대표적인 두 가지 방식인 Debounce와 Throttle에 대해 정리하고, 직접 구현하는 단계까지 진행해 보겠습니다.
"끝난 거 맞지? 보낸다?"가 바로 디바운스입니다.
Debounce란 연속적으로 발생하는 이벤트를 그룹화하여, 특정 시간이 지난 후 마지막 이벤트에 대해서만 딱 한 번 함수를 실행하는 성능 최적화 기법입니다.
사용자가 검색창에 백숙을 입력하는 상황을 생각해 봅시다.
Debounce의 적용이 없다면 'ㅂ', '배', '백', '백ㅅ', '백수', '백숙'으로, 키를 누를 때마다 서버에 API 요청을 보냅니다. 이는 불필요한 요청을 수없이 발생시켜 서버에 큰 부담을 줍니다.
반면 Debounce를 적용하면, 사용자가 입력을 마칠 때까지 기다립니다. 백숙이라는 타이핑을 멈추고 일정 시간(예를 들어 0.5초)이 지나면, 그때 딱 한 번만 최종 결과인 백숙으로 API 요청을 보냅니다.
결과적으로, 6번 이상 발생할 수 있었던 API 요청을 단 1번으로 줄여 시스템의 부하를 크게 줄이고 사용자 경험을 향상시킬 수 있습니다.
이제 코드로 이해해 보겠습니다.
function debounce(fn, delay) {
// timeout 변수를 통해 함수가 다시 호출되어도 이전 값을 기억하도록 합니다.
let timeout;
// 디바운스가 적용된 새로운 함수를 반환합니다.
return function (...args) {
// 이벤트가 발생할 때마다 기존의 setTimeout을 취소합니다.
clearTimeout(timeout);
// 지정된 delay 이후에 fn 함수를 실행하는 새로운 setTimeout을 설정합니다.
// 사용자가 입력을 멈추지 않으면 계속해서 기존 타이머가 취소되고 새 타이머가 설정됩니다.
timeout = setTimeout(() => fn.apply(this, args), delay);
};
}
// input 이벤트에 handleInput 함수를 연결하면,
// 사용자가 타이핑을 멈추고 300ms가 지난 후에만 fetchData가 호출됩니다.
const handleInput = debounce((value) => {
fetchData(value);
}, 300);
React 환경에서는 useEffect 훅을 사용하여 컴포넌트 내에서 Debounce를 구현할 수 있습니다.
import { useEffect, useState } from "react";
function SearchComponent() {
// 사용자의 현재 입력값
const [input, setInput] = useState("");
// 디바운싱이 적용된 값
const [debounced, setDebounced] = useState("");
// input 상태가 변경될 때마다 useEffect가 실행됩니다.
useEffect(() => {
// 300ms 후에 debounced 상태를 현재 input 값으로 업데이트하는 타이머를 설정합니다.
const handler = setTimeout(() => setDebounced(input), 300);
// cleanup 함수: 다음 useEffect가 실행되기 전 또는 컴포넌트가 언마운트될 때 호출됩니다.
// 기존의 타이머를 취소하여, 사용자가 입력을 계속하는 동안에는 debounced 상태가 업데이트되지 않도록 합니다.
return () => clearTimeout(handler);
// 의존성 배열에 input을 넣어 input이 변경될 때만 이 effect가 실행되도록 합니다.
}, [input]);
// debounced 상태가 변경될 때 API를 호출합니다.
useEffect(() => {
if (debounced) fetchData(debounced);
}, [debounced]);
return <input value={input} onChange={(e) => setInput(e.target.value)} />;
}
매번 useEffect로 Debounce 로직을 작성하는 것은 번거롭습니다. 로직을 재사용 가능한 커스텀 훅(useDebounce)으로 만들면 훨씬 깔끔하고 효율적인 코드를 작성할 수 있습니다.
import { useState, useEffect } from "react";
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
// value가 변경되면, delay 이후에 debounced 상태를 업데이트합니다.
const handler = setTimeout(() => setDebounced(value), delay);
// value나 delay가 변경되면 기존 타이머를 정리합니다.
return () => clearTimeout(handler);
// value나 delay가 변경될 때마다 effect를 재실행합니다.
}, [value, delay]);
// 최종적으로 디바운싱된 값을 반환합니다.
return debounced;
}
// 커스텀 훅 사용 예시
const debouncedInput = useDebounce(input, 300);
// debouncedInput 값이 변경될 때만 API를 호출합니다.
useEffect(() => {
if (debouncedInput) fetchData(debouncedInput);
}, [debouncedInput]);
배차 간격이 10분인 버스에는 Throttle의 개념이 녹아 있다고 볼 수 있습니다. 정류장에 사람이 한 명이 있든 100명이 있는 10분에 한 대만 오는 것이 버스죠.
Throttle은 연속적으로 발생하는 이벤트를 일정한 시간 간격으로, 최대 한 번만 실행되도록 제어하는 성능 최적화 기법입니다.
스크롤 이벤트를 예로 들어보겠습니다.
사용자가 스크롤을 내릴 때, 스크롤 이벤트는 매우 짧은 시간 동안 수십, 수백 번 발생할 수 있습니다. Throttle을 적용하지 않으면, 이벤트가 발생할 때마다 등록된 함수가 계속해서 호출되어 심각한 성능 저하를 유발할 수 있습니다.
반면 Throttle을 200ms로 적용하면, 스크롤 이벤트가 아무리 많이 발생하더라도 함수는 200ms당 최대 한 번만 실행됩니다. 즉, 첫 이벤트가 발생하면 함수를 즉시 실행하고, 이후 200ms 동안 발생하는 모든 이벤트를 무시합니다. 200ms가 지난 후에야 다시 함수를 실행할 수 있게 됩니다.
결과적으로, 과도한 함수 호출을 방지하여 브라우저의 부담을 줄이고 부드러운 사용자 경험을 제공할 수 있습니다. 주로 스크롤, 창 크기 조절, 마우스 이동과 같이 실시간으로 사용자의 움직임을 추적하고 즉각적인 피드백이 필요할 때 유용하게 사용됩니다.
function throttle(fn, delay) {
// 마지막으로 함수가 호출된 시간을 저장합니다.
let last = 0;
// 스로틀링이 적용된 새로운 함수를 반환합니다.
return function (...args) {
const now = Date.now();
// 현재 시간과 마지막 호출 시간의 차이가 delay 이상일 때만 함수를 실행합니다.
if (now - last >= delay) {
// 마지막 호출 시간을 현재 시간으로 업데이트합니다.
last = now;
fn.apply(this, args);
}
};
}
// scroll 이벤트에 handleScroll 함수를 연결하면,
// 스크롤이 아무리 많이 발생해도 200ms에 한 번만 console.log가 실행됩니다.
const handleScroll = throttle(() => {
console.log("Scroll fired");
}, 200);
React에서는 useRef와 useCallback을 함께 사용하여 렌더링 사이에 값을 유지하고 함수를 메모이제이션하는 방식으로 Throttle을 구현할 수 있습니다. 이는 특히 이벤트 리스너에 함수를 등록하고 해제할 때 유용합니다.
import { useCallback, useRef } from "react";
function useThrottle(fn, delay) {
// useRef를 사용하여 컴포넌트가 리렌더링 되어도 마지막 호출 시간을 유지합니다.
const lastCall = useRef(0);
// useCallback을 사용하여 의존성(fn, delay)이 변경되지 않는 한 함수를 재생성하지 않습니다.
return useCallback(
(...args) => {
const now = Date.now();
if (now - lastCall.current >= delay) {
lastCall.current = now;
fn(...args);
}
},
[fn, delay]
);
}
JavaScript 구현과 마찬가지로, Throttle 로직도 재사용 가능한 커스텀 훅(useThrottle)으로 만들어 관리하는 것이 효율적입니다.
import { useRef } from "react";
function useThrottle<T extends (...args: any[]) => void>(fn: T, delay: number): T {
const lastCall = useRef(0);
// 반환되는 함수는 lastCall ref에 접근할 수 있습니다.
return ((...args: any[]) => {
const now = Date.now();
if (now - lastCall.current >= delay) {
lastCall.current = now;
fn(...args);
}
}) as T;
}
// 커스텀 훅 사용 예시
const throttledScroll = useThrottle(() => {
console.log("Scrolling...");
}, 200);
useEffect(() => {
// 컴포넌트가 언마운트될 때 이벤트 리스너를 제거하여 메모리 누수를 방지합니다.
window.addEventListener("scroll", throttledScroll);
return () => window.removeEventListener("scroll", throttledScroll);
// throttledScroll 함수가 변경될 때마다 effect를 재실행합니다. (useThrottle이 useCallback을 사용하면 더 안정적입니다)
}, [throttledScroll]);
Debounce와 Throttle은 모두 유용한 기법이지만, 사용 사례에 따라 적합한 것을 선택해야 합니다.
Debounce는 연속된 이벤트의 마지막에 한 번만 처리하고 싶을 때 사용합니다.
검색창 입력:사용자가 타이핑을 멈췄을 때 API 요청.
입력 폼 유효성 검사:사용자가 입력을 마쳤을 때 유효성 검사 실행.
Throttle은 일정한 주기로 이벤트를 처리하고 싶을 때 사용합니다.
무한 스크롤: 사용자가 스크롤하는 동안 주기적으로 콘텐츠 로드 여부 확인.
마우스 움직임 추적: 마우스가 움직이는 동안 주기적으로 위치를 감지하여 그래픽 효과 적용.
API 반복 요청: 버튼을 여러 번 눌러도 일정 시간당 한 번만 API 요청.
| 구분 | Debounce (디바운스) | Throttle (스로틀) |
|---|---|---|
| 핵심 | 마지막 이벤트만 실행 | 일정한 간격으로 실행 |
| 목적 | 연관된 모든 이벤트를 그룹화하여 마지막에 한 번만 처리 | 이벤트의 실행 횟수를 일정하게 제어 |
| 주요 사례 | 검색창, 폼 유효성 검사 | 스크롤, 마우스 이동, 창 크기 조절 |
⚠️ 모든 이벤트에 Debounce나 Throttle을 적용할 필요는 없습니다. 성능에 영향을 주지 않는 가벼운 이벤트 핸들러에 적용하면 오히려 코드 복잡성만 증가시킬 수 있습니다. 병목 현상이 발생하는 부분을 먼저 확인하고 최적화를 진행하는 것이 좋습니다.
⚠️ delay 값은 사용자 경험에 직접적인 영향을 미칩니다. 너무 길면 UI가 굼뜨게 느껴지고(e.g., 검색 결과가 너무 늦게 나옴), 너무 짧으면 최적화 효과가 미미합니다. 사용 사례에 맞는 최적의 delay 값을 찾는 것이 중요합니다.
⚠️ useEffect 내에서 setTimeout이나 addEventListener를 사용했다면, 반드시 cleanup 함수를 통해 clearTimeout이나 removeEventListener를 호출해야 합니다. 그렇지 않으면 컴포넌트가 사라져도 타이머나 이벤트 리스너가 계속 남아 메모리 누수를 일으킬 수 있습니다.