본문으로 건너뛰기
기술스택 · stack/javascript/디바운스와 스로틀: 너무 자주 일어나는 이벤트 다루기

JavaScript · 6장 함수 심화

디바운스와 스로틀: 너무 자주 일어나는 이벤트 다루기

검색창에 "자바스크립트"를 치면 입력 이벤트가 글자마다 일어나요. 스크롤 이벤트는 1초에 수십 번 일어나요. 이벤트마다 서버에 요청하거나 무거운 계산을 하면 서버도 화면도 버티지 못해요. 이 장에서 배운 클로저 · 타이머 · 래퍼를 합쳐서, 호출 횟수를 줄이는 두 가지 래퍼를 직접 만들어요.

#6.8

01. 이벤트는 생각보다 자주 일어나요

사용자가 하는 동작 하나가 이벤트 여러 개가 돼요. 검색어 "자바스크"를 치는 동안 input 이벤트가 네 번, 페이지를 한 번 쓸어 내리는 동안 scroll 이벤트가 수십 번 일어나요. 그런데 우리가 정말 하고 싶은 일은 대개 그보다 훨씬 적어요.

  • 검색 자동 완성 · 임시 저장: 다 친 다음에 한 번이면 돼요. 중간 글자("자", "자바")로 보낸 요청은 버려져요. → 디바운스
  • 스크롤 위치에 따른 표시 · 드래그: 움직이는 동안에도 반응해야 하지만, 0.1초에 한 번 정도면 충분해요. → 스로틀
①맨 위 줄은 일어난 이벤트예요. 두 번 몰아서 일어났어요. 디바운스는 이벤트가 멈추고 정해진 시간이 지나야 마지막 것으로 한 번씩, 스로틀은 이벤트가 이어지는 동안 정해진 간격마다 한 번씩 실행해요.

02. 디바운스: 멈출 때까지 기다렸다 한 번

디바운스는 "마지막 호출 뒤로 정해진 시간 동안 조용하면 그때 실행"해요. 새 호출이 오면 기다리던 타이머를 취소하고 다시 기다려요. 타이머 번호를 상자에 기억해 두는 클로저(클로저)와, 받은 this · 인자를 넘기는 래퍼(래퍼 함수)를 합친 모양이에요.

function debounce(fn, wait) {
  let timer;                                  // 기다리는 타이머 번호
  return function (...args) {
    clearTimeout(timer);                      // 새 호출이 오면 기다리던 걸 취소하고
    timer = setTimeout(() => fn.apply(this, args), wait); // 다시 기다려요
  };
}

let requests = 0;
const search = debounce(query => {
  requests++;
  console.log(`검색 요청: "${query}"`);
}, 150);

// 30ms 간격으로 네 글자를 쳤다고 해요
['자', '자바', '자바스', '자바스크'].forEach((q, i) => setTimeout(() => search(q), i * 30));
setTimeout(() => console.log('요청 수', requests), 400);

네 번 불렀지만 요청은 마지막 "자바스크" 한 번이에요. 기다리는 시간은 검색창이라면 200~300ms가 흔해요. 너무 짧으면 줄이는 효과가 없고, 너무 길면 반응이 굼떠 보여요.

03. 스로틀: 정해진 간격에 한 번씩

스로틀은 "마지막으로 실행한 뒤 정해진 시간이 지나기 전에는 실행하지 않아요." 이벤트가 계속 이어져도 간격마다 꾸준히 실행된다는 점이 디바운스와 달라요. 규칙만 보면 아래처럼 간단해요. 20ms마다 일어난 스크롤 이벤트 중 100ms 간격으로 실행되는 것만 골랐어요(시간은 숫자로 흉내 냈어요).

const scrollEvents = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200]; // 일어난 시각(ms)
let lastRun = -Infinity;

const ran = scrollEvents.filter(time => {
  if (time - lastRun >= 100) {   // 마지막 실행 뒤 100ms가 지났으면
    lastRun = time;
    return true;                 // 실행해요
  }
  return false;                  // 아니면 건너뛰어요
});

console.log(ran); // [0, 100, 200] — 11번 중 3번만

실제로 쓰는 스로틀은 여기에 한 가지를 더해요. 건너뛴 호출이 있었다면, 간격이 끝날 때 마지막 것으로 한 번 더 실행해 줘요. 그래야 스크롤을 멈춘 마지막 위치가 반영돼요.

function throttle(fn, wait) {
  let lastRun = 0;
  let timer = null;
  let lastArgs;
  return function (...args) {
    lastArgs = args;
    const now = Date.now();
    const remaining = wait - (now - lastRun);
    if (remaining <= 0) {               // 간격이 지났으면 바로 실행
      clearTimeout(timer);
      timer = null;
      lastRun = now;
      fn.apply(this, args);
    } else if (!timer) {                 // 아니면 간격이 끝날 때 마지막 것으로 한 번 더
      timer = setTimeout(() => {
        lastRun = Date.now();
        timer = null;
        fn.apply(this, lastArgs);
      }, remaining);
    }
  };
}

04. 직접 입력하며 비교하기

아래 입력 칸에 글자를 쳐 보세요. 같은 입력에 대해 그대로 · 디바운스 · 스로틀이 각각 몇 번 실행되는지, 마지막으로 받은 값이 무엇인지 세요. 휴대폰이라면 "자동으로 입력" 버튼을 눌러도 돼요.

디바운스와 스로틀의 기다리는 시간은 둘 다 300ms예요. 빠르게 치다가 멈춰 보고, 천천히 한 글자씩도 쳐 보세요.

05. 실무에서 쓸 때 알아 둘 것

  • 무엇을 고를까요? 끝난 뒤의 결과만 필요하면 디바운스(검색 자동 완성, 임시 저장, 창 크기 바뀐 뒤 다시 배치), 하는 동안 꾸준히 반응해야 하면 스로틀(스크롤 위치 표시, 드래그, 무한 스크롤 확인)이에요. 버튼을 여러 번 눌러 주문이 두 번 들어가는 걸 막을 때는, 첫 클릭에 바로 실행하고 잠시 막는 쪽(앞쪽 실행)이 맞아요.
  • 래퍼는 한 번만 만들어요. 이벤트 핸들러 안에서 debounce(…)를 부르면 호출마다 새 타이머 상자가 생겨서 아무것도 줄지 않아요. React라면 useMemo · useRef로 같은 디바운스 함수를 유지해요.
  • 정리할 수 있게 만들어요. 화면을 떠날 때 기다리던 타이머가 실행되면 이미 없는 화면을 고치려 해요. 래퍼에 cancel 메서드를 붙여 두고 정리할 때 불러요. 늦게 도착한 옛 검색 결과가 새 결과를 덮어쓰지 않게 하려면 요청 취소(AbortController, 11장)도 함께 써요.
  • 이미 있는 것을 써도 돼요. lodash의 debounce · throttle은 앞쪽 · 뒤쪽 실행, cancel, flush까지 갖췄어요. 스크롤로 "화면에 보이는지"를 확인하는 일이라면 스로틀보다 IntersectionObserver가 더 맞아요(2부).

글을 쓸 때마다 임시 저장을 하는 코드예요. 지금은 다섯 번 쳤더니 다섯 번 저장해요. 같은 파일 안의 debounce를 써서, 200ms 동안 입력이 없을 때만 저장하게 고쳐 보세요. 아래처럼 나와야 해요.

저장: 안녕하
저장: 안녕하세요
저장 횟수 2
function debounce(fn, wait) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

let saves = 0;
const save = text => {
  saves++;
  console.log('저장:', text);
};

// 0.05초 간격으로 세 글자, 0.3초 쉬고, 다시 두 글자
const typing = [[0, '안'], [50, '안녕'], [100, '안녕하'], [400, '안녕하세'], [450, '안녕하세요']];
for (const [at, text] of typing) setTimeout(() => save(text), at);
setTimeout(() => console.log('저장 횟수', saves), 900);
해답 보기

save를 디바운스로 한 번 감싸 두고, 입력마다 그 감싼 함수를 불러요. 쉬는 0.3초가 기다리는 0.2초보다 길어서 "안녕하"에서 한 번, 마지막 "안녕하세요"에서 한 번 저장돼요.

const save = debounce(text => {
  saves++;
  console.log('저장:', text);
}, 200);

정리

  • 디바운스는 호출이 멈추고 정해진 시간이 지나면 마지막 것으로 한 번 실행해요.
  • 스로틀은 정해진 간격마다 많아야 한 번 실행하고, 끝에 마지막 것을 반영해요.
  • 둘 다 클로저 · 타이머 · 래퍼로 만들어요. 래퍼는 한 번만 만들어 같은 것을 계속 써요.
  • 결과만 필요하면 디바운스, 하는 동안 반응해야 하면 스로틀을 골라요.
검색창 자동 완성에는 디바운스와 스로틀 중 무엇이 맞을까요?

디바운스예요. 사용자가 치는 중간 글자로 보낸 요청은 버려지니까, 입력이 잠시 멈췄을 때 마지막 검색어로 한 번만 보내는 게 맞아요.

스크롤 이벤트 핸들러 안에서 debounce(update, 100)()처럼 부르면 왜 효과가 없을까요?

스크롤 이벤트마다 debounce를 새로 불러서, 매번 새 타이머 상자를 가진 새 함수가 생기기 때문이에요. 서로 취소하지 못하니 모든 호출이 실행돼요. 디바운스 함수는 밖에서 한 번 만들어 두고 그걸 불러요.

스로틀이 마지막 호출을 한 번 더 실행해 주는 이유는?

간격 안에서 건너뛴 호출 중 마지막 것이 최종 상태이기 때문이에요. 이게 없으면 스크롤을 멈춘 마지막 위치나 드래그를 놓은 자리가 반영되지 않을 수 있어요.

SIMPLE · 기술스택 / JavaScript — 6장 함수 심화