본문 바로가기
카테고리 없음

디바운스와 스로틀, 언제 무엇을 써야 하는가

by 테크구루스 2026. 7. 3.

 

디바운스와 스로틀, 언제 무엇을 쓸 지 검색창 하나 때문에 서버를 잡아먹을 뻔했던 경험에서 출발해 두 기법의 차이와 실전 선택 기준을 정리합니다.

 

디바운스와 스로틀, 언제 무엇을 써야 하는가
디바운스와 스로틀, 언제 무엇을 써야 하는가

 

자동완성 검색창이 서버를 두드려 팬 날

 

처음 자동완성 검색창을 만들었을 때, 저는 아주 순진하게 짰습니다. 입력창의 onChange에 그대로 API 호출을 붙였죠. 한 글자 칠 때마다 서버로 검색어를 보내고 결과를 받아 목록을 그리는 방식이었습니다. 로컬에서는 완벽해 보였습니다. "제이든"을 치면 관련 결과가 착착 떴으니까요.

문제는 배포 다음 날 드러났습니다. 백엔드를 맡은 동료가 "검색 API 호출이 왜 이렇게 많아요?"라고 물어왔죠. 대시보드를 열어 보니 검색 엔드포인트의 초당 요청 수가 평소의 몇 배로 튀어 있었습니다. 원인은 명백했습니다. "제이든"이라는 세 글자를 입력하면 한글 조합 중간 단계까지 포함해 열 번 가까이 요청이 나갔던 겁니다. 그것도 한 사람이 말이죠. 사용자가 수백 명이면 그 숫자는 그대로 곱해집니다.

더 고약한 문제는 따로 있었습니다. 네트워크 탭을 열어 보니, 먼저 보낸 요청이 나중에 도착하는 경우가 있었습니다. "제이"의 결과가 "제이든"의 결과보다 늦게 와서, 화면에는 이미 지나간 검색어의 결과가 덮어써졌죠. 사용자 입장에서는 "분명히 다 쳤는데 엉뚱한 결과가 뜬다"는 버그였습니다. 저는 이걸 재현하느라 한참을 헤맸습니다. 입력을 천천히 하면 멀쩡하고, 빠르게 치면 깨졌으니까요.

급한 마음에 처음 손댄 방법은 setTimeout 하나를 걸어 두는 것이었습니다. 하지만 이전 타이머를 지워 주지 않았더니, 호출을 미루기만 할 뿐 결국 다 실행되어 아무것도 나아지지 않았습니다. 이 삽질이 제가 디바운스와 스로틀을 제대로 공부하게 된 계기였습니다. 두 기법 모두 "실행을 솎아 내는" 도구인데, 솎아 내는 방식이 서로 다르다는 걸 그때야 이해했죠.

돌아보면 이 사건이 특별히 뼈아팠던 이유는, 코드만 놓고 보면 "동작은 하는" 상태였다는 데 있습니다. 화면은 멀쩡히 검색 결과를 보여 줬고, 기능 명세도 다 충족했죠. 겉으로는 아무 문제가 없어 보였습니다. 하지만 그 아래에서는 불필요한 요청이 몇 배로 쏟아지고 있었고, 그 부담은 고스란히 서버와 요금으로 돌아왔습니다. 눈에 보이는 버그가 아니라 눈에 안 보이는 낭비여서 더 늦게 발견됐던 셈이죠. 그때부터 저는 입력에 반응하는 코드를 짤 때면 "이게 초당 몇 번이나 실행될까"를 먼저 헤아려 보는 버릇이 생겼습니다.

 

디바운스와 스로틀은 무엇이 다른가

 

한 문장으로 줄이면 이렇습니다. 디바운스는 "잠잠해지면 마지막에 한 번", 스로틀은 "정신없이 몰려와도 일정 간격마다 한 번" 실행합니다. 같은 입력 폭주를 줘도 실제 실행 횟수가 어떻게 갈리는지는 아래 그림이 한눈에 보여 줍니다.

 

디바운스는 이벤트가 들어올 때마다 대기 타이머를 처음부터 다시 셉니다. 그래서 입력이 이어지는 동안에는 한 번도 실행되지 않다가, 손을 멈춰 지정한 시간만큼 조용해지면 그제야 딱 한 번 실행되죠.

// 디바운스: 마지막 입력 후 300ms 동안 잠잠하면 그때 한 번
function debounce(fn, delay = 300) {
  let timer;
  return (...args) => {
    clearTimeout(timer);              // 이전 타이머 취소가 핵심
    timer = setTimeout(() => fn(...args), delay);
  };
}

스로틀은 성격이 다릅니다. 한 번 실행하면 정해진 간격 동안은 추가 호출을 그냥 무시하고, 간격이 지나야 다음 실행을 허용합니다. 그래서 이벤트가 아무리 쏟아져도 "간격당 최대 한 번"이 보장되죠.

// 스로틀: 실행 후 300ms 동안은 추가 호출을 무시
function throttle(fn, interval = 300) {
  let waiting = false;
  return (...args) => {
    if (waiting) return;
    fn(...args);
    waiting = true;
    setTimeout(() => (waiting = false), interval);
  };
}

두 기법의 차이를 한 표로 정리하면 이렇습니다.

구분 디바운스 스로틀
실행 시점 입력이 멈추고 잠잠해진 뒤 한 번 정해진 간격마다 한 번
관심사 마지막 최종 상태 진행되는 동안의 꾸준한 반영
입력이 계속 이어지면 계속 미뤄져 실행되지 않음 간격마다 꼬박꼬박 실행
대표 사례 검색 자동완성 스크롤·리사이즈 처리

한 가지 덧붙이면, 두 기법 모두 "언제 실행할지"를 조금씩 조절할 수 있습니다. 예를 들어 디바운스에도 첫 입력이 들어온 순간 바로 한 번 실행하고 이후를 묶는 선행 방식(leading edge)이 있고, 반대로 잠잠해진 뒤에 실행하는 후행 방식(trailing edge)이 있죠. 대부분의 검색 자동완성은 후행 방식을 씁니다. 사용자가 다 치고 손을 뗀 뒤의 검색어가 필요하니까요. 이런 세부 옵션까지 알아 두면, 라이브러리를 쓰든 직접 구현하든 상황에 맞게 미세하게 조정할 수 있습니다.

정리하면, 디바운스는 "결과가 최종적으로 확정된 순간"에 관심이 있고, 스로틀은 "흐르는 동안 꾸준히 반영하는 것"에 관심이 있습니다. 제 검색창이 필요했던 건 전자였죠. 타이핑이 끝난 뒤의 검색어 하나만 있으면 됐으니까요.

그래서 언제 무엇을 쓰는가

실무에서 저는 대략 이렇게 나눠서 씁니다.

  • 디바운스가 어울리는 곳 — 자동완성·검색어 입력, 폼 유효성 실시간 검사, 창 크기 조절이 끝난 뒤의 재계산처럼 "마지막 상태만 중요한" 경우입니다.
  • 스로틀이 어울리는 곳 — 스크롤 위치에 따른 처리, 마우스 이동 추적, 무한 스크롤 감지처럼 "진행되는 동안 일정하게 반영해야 하는" 경우죠.
  • 둘 다 애매한 곳 — 버튼 중복 클릭 방지처럼 상황에 따라 갈리는 경우도 있습니다. 첫 클릭만 즉시 처리하고 잠깐 잠그는 선행 스로틀이 맞을 때도, 마지막 입력만 남기는 디바운스가 맞을 때도 있죠. 이럴 땐 "사용자에게 즉각 반응을 보여 줘야 하는가"를 기준으로 고르면 대체로 답이 나옵니다.

디바운스와 스로틀, 어느 쪽을 선택할까
디바운스와 스로틀, 어느 쪽을 선택할까

 

다만 디바운스만으로는 앞서 말한 "옛 응답이 늦게 도착해 덮어쓰는" 문제가 완전히 사라지지 않습니다. 호출 횟수는 줄어도, 느린 네트워크에서는 지난 요청이 뒤늦게 도착할 수 있으니까요. 그래서 지금의 저라면 디바운스에 요청 취소를 반드시 함께 겁니다.

let controller;
const search = debounce((q) => {
  controller?.abort();                    // 이전 요청 취소
  controller = new AbortController();
  fetch(`/search?q=${q}`, { signal: controller.signal });
}, 300);

실제로 검색창에 디바운스와 요청 취소를 함께 적용한 뒤, 검색 API의 호출 수는 눈에 띄게 줄었습니다. 한 단어를 검색할 때 열 번 가까이 나가던 요청이, 타이핑이 끝난 뒤 사실상 한 번으로 수렴했죠.

 

적용 효과 비교
적용 효과 비교

 

백엔드 동료의 대시보드에서 그 뾰족하던 그래프가 완만해진 걸 확인했을 때의 후련함은 아직도 기억납니다. 게다가 "엉뚱한 결과가 뜬다"던 그 재현하기 힘든 버그도, 오래된 요청을 취소하니 자연스럽게 사라졌습니다.

개인적으로는, 이 둘을 "성능 최적화 기법"이라고만 외웠던 게 오래 헤맨 이유였다고 봅니다. 실제로는 "언제 실행할지를 고르는 문제"에 가깝죠. 마지막 한 번이면 디바운스, 흐르는 내내 일정 간격이면 스로틀. 이 기준 하나만 손에 쥐고 있으면, 새 상황을 만나도 어렵지 않게 판단할 수 있습니다. 처음의 저처럼 onChange에 API를 곧장 붙이기 전에, 이 글이 잠깐 멈춰 생각할 계기가 된다면 더 바랄 게 없겠네요.