[JS] debounce와 throttle로 이벤트 제어하기

세하·2025년 9월 29일

JavaScript

목록 보기
10/10

웹 사이트를 사용하다보면 사용자와의 상호작용하는 부분들이 많이 존재한다.
스크롤, 창 크기 조절, 키보드 입력 등 수많은 이벤트가 쉴 새 없이 발생하는데 만약 이벤트가 발생할 때마다 복잡한 연산이나 API 호출과 같은 무거운 작업을 수행한다면 어떻게 될까? 사용자가 검색창에 '강남맛집'을 입력하는 동안 '강', '강남', '강남맛' ... 등 모든 입력마다 API를 호출하게 되어 서버에 엄청난 부하를 주게 될 것이다.

출처

이러한 문제를 해결하기 위해 디바운스(Debounce)쓰로틀(Throttle) 이 등장했다.
두 기법은 과도한 이벤트 핸들러 호출을 제어하여 성능을 최적화하는 것을 목표로 한다.

debounce - 마지막 한 번만 실행

디바운스는 연속적으로 발생하는 이벤트를 하나의 그룹으로 묶어, 특정 시간 동안 추가적인 입력이 없을 때 마지막 이벤트에 대해서만 핸들러를 실행하는 기법이다.
위의 예시에서 '강남 맛집'을 입력 시 모든 입력마다 API를 호출하는것이 아닌, 일정한 시간동안 사용자의 입력이 없을 경우에만 API를 호출할 수 있게 된다.

  • 검색창 자동완성: 사용자의 입력이 끝났다고 판단되는 시점에만 API를 호출한다.
  • 창 크기 조절 후 처리: 사용자가 창 크기 조절을 멈췄을 때만 레이아웃을 재계산하는 등의 후속 작업을 실행한다.
/**
 * 디바운스 함수
 * @param {Function} func - 디바운스를 적용할 함수
 * @param {number} delay - 지연 시간 (밀리초)
 * @returns {Function} - 디바운스가 적용된 새로운 함수
 */
const debounce = (func, delay) => {
  let timeoutId; // setTimeout의 타이머 ID를 저장할 변수

  // 디바운스가 적용된 새로운 함수를 반환
  return (...args) => {
    // 이전에 설정된 타이머가 있다면 취소
    clearTimeout(timeoutId);

    // delay 이후에 func 함수를 실행하는 새로운 타이머 설정
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
};

// --- 사용 예시 ---

// 디바운스를 적용할 함수
const searchAPI = (keyword) => {
  console.log(`'${keyword}' 검색 요청을 보냅니다.`);
  // 실제로는 여기에 API 호출 코드가 들어갑니다.
};

// 500ms(0.5초) 지연 시간을 갖는 디바운스 함수 생성
const debouncedSearch = debounce(searchAPI, 500);

const searchInput = document.getElementById('searchInput');

searchInput.addEventListener('input', (event) => {
  debouncedSearch(event.target.value);
});

debounce 함수

  • 실행할 대상 함수(func)와 지연 시간(delay)을 인자로 받음
  • timeoutId라는 변수를 클로저(closure) 를 이용해 내부에서 유지한다.
  • timeoutId 변수는 setTimeout이 반환하는 타이머 ID를 저장하는 역할을 함
  • 새로운 함수를 반환하는데,이 함수가 실제 이벤트 리스너에 등록된다.

반환된 함수

  • 이벤트가 발생할 때마다 이 함수가 호출된다
  • clearTimeout(timeoutId): 호출될 때마다 이전에 설정되어 있던 타이머(만약 있다면)를 취소한다.
  • setTimeout(...): 그리고 나서 새로운 타이머를 설정한다. 이 타이머는 delay 시간 후에 원래의 func 함수를 실행한다.

이후 실제 실행 코드

debounce(searchAPI, 500)가 실행되면, debounce 함수는 searchAPI와 500이라는 재료를 가지고 새로운 함수를 조립해서 반환하고, const debouncedSearch = ... 코드를 통해, 이렇게 새롭게 만들어진 함수에 debouncedSearch라는 이름이 붙는 것이다.
debouncedSearch는 아래와 같은 함수라고 생각하면 된다.

// debouncedSearch의 실제 모습 (개념적으로)
(..args) => {
  clearTimeout(timeoutId); // timeoutId는 클로저를 통해 기억하고 있음

  timeoutId = setTimeout(() => {
    // searchAPI를 args와 함께 실행!
    searchAPI.apply(this, args);
  }, 500); // delay도 클로저를 통해 기억하고 있음
}

따라서 debouncedSearch(event.target.value)를 호출하면, event.target.value...args라는 매개변수로 들어가게 되고, setTimeout 내부에서 최종적으로 searchAPI(event.target.value)가 실행되는 구조이다.

동작 원리

사용자가 '강', '남', '맛', '집'을 빠르게 입력한다고 가정해보자
1. '강' 입력: 500ms 후에 searchAPI('강')을 실행하도록 타이머가 설정됨
2. (500ms가 지나기 전) '남' 입력: 이전 타이머('강'에 대한)는 취소되고, 500ms 후에 searchAPI('강남')를 실행할 새 타이머가 설정됨
3. '맛', '집' 이 입력될 때마다 이 과정 반복
4. 마지막 글자 '집'를 입력하고 500ms 동안 추가 입력이 없으면 마지막으로 설정된 타이머가 실행되어 searchAPI('강남맛집')이 딱 한 번 호출된다.

-> 이렇게 하면 불필요한 API 호출을 크게 줄여 성능을 최적화할 수 있다!

apply 내장 함수?

apply()는 자바스크립트 함수를 특정 this 값과 인수 배열을 가지고 호출할 때 사용하는 내장 메서드이다.
쉽게 말해, 함수를 대신 호출해 주면서 함수 내부의 this가 누구를 가리킬지 지정하고, 파라미터를 배열 형태로 전달하는 역할을 한다.

func.apply(this, args)
(func.apply(thisValue, [arg1, arg2, ...]) 형태로 사용된다.)

  1. this (첫 번째 인수): this 값 지정
    • apply의 첫 번째 인수는 func 함수 내부에서 this 키워드가 가리킬 대상을 지정한다.
    • 코드에서는 this를 그대로 전달하고 있다. 이는 debouncedSearch 함수가 호출될 때의 this 문맥(context)을 그대로 searchAPI 함수로 가져가기 위해서이다.
    • 예를 들어, 만약 debouncedSearch가 특정 객체의 메서드로 호출되었다면, searchAPI 내부의 this도 그 객체를 가리키게 된다.
    • 이처럼 this의 참조를 잃어버리지 않도록 해주는 중요한 역할을 한다.
  2. args (두 번째 인수): 인수 배열 전달
    • apply의 두 번째 인수는 func 함수에 전달될 파라미터들을 배열(또는 유사 배열 객체) 형태로 전달한다.
    • debounce 함수에서 반환된 함수는 (...args) => { ... } 형태로 작성되어 있다. 여기서 ...args는 debouncedSearch(event.target.value) 호출 시 전달된 모든 파라미터들을 ['검색어']와 같은 배열로 받는다.
    • func.apply(this, args)는 이 args 배열을 풀어서 searchAPI('검색어')와 같이 원래 함수에 인수로 전달해준다.
      • 그니까 만약 debouncedSearch('강남맛집', '세하') 라면 debounce 함수 안에 있는 (...args)가 '강남맛집'와 '세하'을 받아 ['강남맛집', '세하'] 이라는 배열로 만들고, 500ms 후 setTimeout의 콜백 함수가 실행되고, func.apply(this, args) 코드가 실행되면서, searchAPI.apply(this, ['강남맛집', '세하']) 이 된다. apply는 ['강남맛집', '세하'] 배열을 풀어서 searchAPI('강남맛집', '세하') 형태로 최종 호출한다.

최신 자바스크립트
요즘에는 화살표 함수스프레드 문법(...)을 사용하면 apply나 call 없이도 더 직관적으로 코드를 작성할 수 있는 경우가 많다.
setTimeout(() => func(...args), delay);
this가 화살표 함수에 의해 자동으로 상위 스코프에 바인딩되므로 apply를 쓴 것과 유사한 효과를 낸다. 하지만 debounce와 같은 범용 유틸리티 함수를 만들 때는 어떤 상황에서도 this가 명확하게 유지되도록 apply를 사용하는 것이 더 정석적인 방법이다.

결론적으로 func.apply(this, args)는 debouncedSearch가 호출될 때의 this 문맥과 모든 파라미터(args)들을 온전히 보존했다가, delay 시간이 지난 후에 원래 함수인 searchAPI에게 그대로 전달하여 실행해주는 역할을 한다.

throttle - 일정 간격으로 실행

throttle은 이벤트를 일정한 주기마다 한 번씩만 실행하도록 강제하는 기법이다. 디바운스가 "실행을 계속 미루는 것"이라면, 쓰로틀은 "일정 시간마다 꾸준히 실행하지만 그 사이의 이벤트는 무시하는 것"이다.

  • 스크롤 이벤트: 스크롤 위치를 감지하여 특정 애니메이션을 실행하거나 분석 데이터를 보낼 때 사용한다. (e.g., 무한 스크롤)
  • 마우스 이동: 마우스의 움직임에 따라 특정 요소를 따라다니게 하는 기능 구현 시 사용한다.

throttle는 플래그 변수(inThrottle) 를 클로저에 두고, 함수가 호출됐을 때 이 플래그를 확인하여 실행 여부를 결정한다.

/**
 * 쓰로틀 함수
 * @param {Function} func - 쓰로틀을 적용할 함수
 * @param {number} limit - 지연 시간 (밀리초)
 * @returns {Function} - 쓰로틀이 적용된 새로운 함수
 */
const throttle = (func, limit) => {
  // 쓰로틀 상태를 저장하는 플래그 변수
  // true이면, 현재 쿨타임(지연 시간) 중임을 의미함
  let inThrottle;

  // 쓰로틀이 적용된 새로운 함수를 반환합니다.
  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;

      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
};

// --- 사용 예시 ---

// 쓰로틀을 적용할 함수
const handleScroll = () => {
  console.log('API 요청! - 스크롤 이벤트 처리 중...');
  // 실제로는 여기에 무거운 작업(API 호출 등)이 들어갑니다.
};

// 1000ms(1초) 간격으로 실행되는 쓰로틀 함수 생성
const throttledScroll = throttle(handleScroll, 1000);

// window 객체에 스크롤 이벤트 리스너 추가
window.addEventListener('scroll', throttledScroll);

동작 원리

  • 이벤트 발생
    • 사용자가 스크롤을 시작하면 throttledScroll 함수가 계속해서 호출됨
  • 최초 실행
    • inThrottlefalse이므로 if (!inThrottle) 조건을 통과
    • handleScroll() 함수가 즉시 실행
    • inThrottle 변수가 true로 바뀌고, setTimeout으로 1초 뒤에 inThrottlefalse로 바꾸도록 예약을 건다.
  • 쿨타임 (1초 동안)
    • 사용자가 스크롤을 계속해도 throttledScroll 함수는 계속 호출됨
    • 하지만 inThrottletrue이므로 if 문을 통과하지 못하고 모든 호출이 무시
  • 쿨타임 종료(1초 이후):
    • 1초가 지나면 setTimeout에 예약된 작업이 실행되어 inThrottle이 다시 false가 됨
  • 다시 실행:
    • 이후 발생하는 첫 스크롤 이벤트에서 throttledScroll이 호출되면, inThrottlefalse이므로 다시 2번 과정부터 반복

-> 결과적으로 handleScroll 함수는 사용자가 아무리 빠르게 스크롤을 해도 1초에 최대 한 번만 실행되므로 성능을 효과적으로 제어할 수 있다.

0개의 댓글