[JavaScript 시리즈 10편] 이벤트 루프, 디바운스, 스로틀로 반응성 다듬기

English version

저장된 상태를 업데이트하다 보면 이벤트가 폭발적으로 발생하는 순간이 있습니다. 브라우저가 작업을 처리하는 방식과 제어 도구를 이해하면 어떤 포인트에서 속도를 조절할지 판단할 수 있습니다. 이 글의 핵심은 "이벤트 루프 순서 이해 + 디바운스/스로틀"이며, scheduleRender 같은 마이크로태스크 최적화는 선택 심화로 분류했습니다.

이번 글에서 새로 나오는 용어

  1. 마이크로태스크: Promise처럼 더 급한 작업이 줄 서 있는 대기열로, 같은 프레임 안에서 먼저 실행됩니다.
  2. 디바운스: 짧은 시간 동안 반복되는 호출을 마지막 한 번만 실행하게 묶는 최적화 기법입니다.
  3. 스로틀: 일정 간격마다 한 번씩만 실행하도록 제한해 과도한 호출을 막는 기법입니다.
  4. passive 리스너: 스크롤 이벤트처럼 브라우저가 기본 동작을 미리 최적화할 수 있게 addEventListener 옵션에 { passive: true }를 주는 설정입니다.

핵심 개념

  • 이벤트 루프: 자바스크립트가 "지금 바로 할 일"과 "조금 뒤에 할 일"을 번갈아 처리하는 순서표입니다. Promise의 콜백은 마이크로태스크(더 급한 대기열), setTimeout은 매크로태스크(조금 여유 있는 대기열)에 들어갑니다.
  • 디바운스(debounce, 지연 실행): 짧은 시간에 반복되는 이벤트를 마지막 한 번만 실행하도록 묶는 기법입니다.
  • 스로틀(throttle, 간격 제한): 정해진 간격마다 한 번씩만 실행하게 제한하는 기법입니다.
  • 마이크로태스크 렌더링: Promise.resolve().then() 안에서 렌더를 예약하면 동일 프레임 내 중복 렌더를 막을 수 있습니다.
  • 성능 로그: DevTools Performance, 호출 횟수 로그 등을 통해 최적화 효과를 확인해야 합니다.

D2로 보는 이벤트 루프 흐름

EventLoopJSStackMicrotaskQueueMacrotaskQueueRenderer동기 코드Promise/queueMicrotasksetTimeout, I/O브라우저 렌더 동기 코드가 Promise 등록Microtask 처리타이머 등록타이머 콜백빈 프레임이면 렌더다음 틱

이 다이어그램은 "동기 코드가 먼저 → 약속해둔 마이크로태스크 → 조금 느긋한 매크로태스크 → 렌더" 순서를 시각화한 것입니다. 즉, Promise.then은 즉시, setTimeout은 다음 라운드에서 실행된다는 뜻입니다.

코드로 따라하기

먼저 아주 짧은 기대 출력부터 보겠습니다. 이 장의 핵심은 호출 순서와 호출 횟수입니다.

기본 순서 예시
A: 버튼 클릭
D: 즉시 실행된 동기 코드
C: Promise.then
B: setTimeout

스크롤 핸들러 호출 수 비교
- 스로틀 적용 전: 24회
- 스로틀 적용 후: 5회

이 숫자만 봐도 PromisesetTimeout의 순서 차이, 그리고 스로틀이 호출 횟수를 얼마나 줄이는지 감이 잡힙니다.

console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

Promise.resolve().then(() => console.log("C"));

console.log("D");
// 순서: A, D, C, B

마이크로태스크가 매크로태스크보다 먼저 실행되므로 Promise.then에서 UI 정리를 예약하면 빠르게 반응합니다. “A, D”는 동기, “C”는 마이크로태스크, “B”는 매크로태스크 순서라는 점이 핵심입니다.

function debounce(fn, delay = 200) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = window.setTimeout(() => {
      fn.apply(null, args);
    }, delay);
  };
}

const handleSearch = debounce((keyword) => {
  fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
    .then((res) => res.json())
    .then(renderResults);
}, 400);

document.querySelector(".search-input")?.addEventListener("input", (event) => {
  const target = event.target;
  if (target instanceof HTMLInputElement) handleSearch(target.value);
});

디바운스는 입력이 멈춘 뒤 일정 시간 후에만 네트워크 요청을 보내 서버 부담을 줄입니다. 즉, "쉬는 동안만 요청한다"로 기억하면 됩니다.

function throttle(fn, interval = 200) {
  let lastTime = 0;
  let timer;

  return (...args) => {
    const now = Date.now();
    const remaining = interval - (now - lastTime);

    if (remaining <= 0) {
      lastTime = now;
      fn.apply(null, args);
      return;
    }

    clearTimeout(timer);
    timer = window.setTimeout(() => {
      lastTime = Date.now();
      fn.apply(null, args);
    }, remaining);
  };
}

const reportScroll = throttle(() => {
  const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  document.querySelector(".progress-bar")?.setAttribute("style", `transform: scaleX(${progress})`);
}, 100);

window.addEventListener("scroll", reportScroll, { passive: true });

스로틀은 실행 간격을 보장해 스크롤 이벤트가 초당 수백 번 발생해도 레이아웃 계산을 일정하게 유지합니다. 실시간 반응은 유지하되, "초당 몇 번만"으로 제한합니다.

let pendingRender = false;

function scheduleRender() {
  if (pendingRender) return;
  pendingRender = true;
  Promise.resolve().then(() => {
    render();
    pendingRender = false;
  });
}

function updateState(partial) {
  Object.assign(state, partial);
  scheduleRender();
}

마이크로태스크 스케줄링으로 중복 렌더를 막으면 스크롤 중에도 프레임을 확보할 수 있습니다. 이 블록은 선택 심화로, 기본 디바운스/스로틀 패턴이 익숙해진 뒤 시도해도 무방합니다.

왜 중요한가

  • 검색 입력이나 자동저장 UI에서 디바운스를 사용하면 서버 요청 수가 크게 줄어듭니다.
  • 스크롤, 리사이즈 이벤트를 스로틀로 감싸면 모바일 기기에서도 부드러운 애니메이션을 유지할 수 있습니다.
  • 이벤트 루프 개념을 이해해야 성능 문제가 생겼을 때 “어디서 병목이 생기는가”를 설명할 수 있습니다.

실습

  • 핵심 따라 하기: 디바운스·스로틀 헬퍼를 그대로 구현하고 검색 입력과 스크롤 진행도 표시줄에 적용합니다.
  • 선택 확장하기: requestAnimationFrame 기반 스로틀을 추가하거나 마이크로태스크 렌더 스케줄러로 중복 렌더를 막습니다.
  • 디버깅: clearTimeout을 주석 처리해 중복 호출이 어떻게 발생하는지 로그로 확인한 뒤, 수정 전·후 호출 횟수를 비교합니다.
  • 완료 기준: DevTools Performance나 콘솔 숫자로 호출 수가 줄었다는 근거를 제시하면 실습이 끝납니다.

마무리

이벤트 루프와 제어기만 알아도 사용자 입력이 몰릴 때 UI가 버벅이는 문제를 크게 줄일 수 있습니다. 다음 글에서는 이런 반응성 제어를 컴포넌트 구조와 결합해 재사용 가능한 패턴으로 정리하겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요