들어가며
SNS 클론 코딩을 진행하며 게시글 임시저장 기능을 구현했다. 목표는 사용자가 입력할 때마다 자동으로 저장되도록 만드는 것이었다.
당시 setTimeout과 setInterval은 알고 있었지만, 이벤트 최적화는 개념으로만 알던 상태였다.
처음 작성한 코드는 단순했다.
textarea.addEventListener("input", () => {
saveDraft(textarea.value);
});입력할 때마다 저장 API를 호출했다. 문제는 바로 드러났다.
- 타이핑 중에도 계속 요청이 발생한다.
- 서버에 불필요한 트래픽이 쌓인다.
- UX 관점에서도 비효율적이다.
이벤트 최적화 방법을 찾다가 debounce를 접했다. 라이브러리를 사용할 수도 있었지만 동작 원리를 정확히 이해하고 싶어 직접 구현했다.
debounce와 throttle
이벤트를 최적화하는 두 가지 전략이다.
debounce는 이벤트가 연속으로 발생할 때 마지막 이벤트 이후 일정 시간이 지나야 실행한다. 타이핑 중에는 실행을 계속 미루다가 입력이 멈추면 그때 한 번 실행된다.
throttle은 이벤트가 아무리 자주 발생해도 일정 간격마다 최대 한 번만 실행한다. 실행 후 delay가 지나기 전에 들어오는 이벤트는 무시한다.
같은 연속 입력에 두 전략을 적용하면 결과가 다르다.
| 시간 | 이벤트 | debounce (300ms) | throttle (300ms) |
|---|---|---|---|
| 0ms | 입력 A | 타이머 시작 (300ms) | 실행 ✓ |
| 100ms | 입력 B | 타이머 재시작 | 무시 |
| 200ms | 입력 C | 타이머 재시작 | 무시 |
| 300ms | 입력 D | 타이머 재시작 | 실행 ✓ |
| 400ms | 입력 E | 타이머 재시작 | 무시 |
| 700ms | — | 실행 ✓ (E 이후 300ms) | — |
debounce는 5번 입력에 1번 실행했고, throttle은 2번 실행했다.
빠르게 연속으로 타이핑해보면 debounce와 throttle이 언제 실행되는지 확인할 수 있다.
핵심 차이는 "이벤트가 계속 발생하는 동안 어떻게 동작하느냐"다.
- debounce: 이벤트가 올 때마다 타이머를 초기화한다. 최종값이 중요한 경우에 적합하다. (검색 자동완성, 자동 저장)
- throttle: 마지막 실행 시간을 기록해 일정 간격을 강제한다. 중간 과정도 처리해야 하는 경우에 적합하다. (scroll, resize)
debounce 예제 코드
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number) {
let timer: number;
// 반환된 함수가 timer를 클로저로 기억한다
return (...args: Parameters<T>) => {
clearTimeout(timer); // 이전 타이머 취소
timer = window.setTimeout(() => fn(...args), delay);
};
}throttle 예제 코드
function throttle(fn: (...args: any[]) => void, delay: number) {
let lastCall = 0;
return (...args: any[]) => {
const now = Date.now();
// 마지막 실행으로부터 delay가 지난 경우에만 실행한다
if (now - lastCall >= delay) {
lastCall = now;
fn(...args);
}
};
}debounce 직접 구현하기
흐름은 이해했지만 왜 저렇게 구현해야하지? 의문이 들었고, 외워서 코드를 짜는 것은 의미가 없다는 생각이 들어 직접 하나씩 구현해보기로 했다.
1단계: setTimeout
처음에는 단순히 setTimeout만 추가했다.
textarea.addEventListener("input", () => {
setTimeout(() => {
saveDraft(textarea.value);
}, 300);
});그러나 요청 횟수는 줄지 않았다. 이벤트가 발생할 때마다 새로운 타이머가 등록되고, 이전 타이머는 그대로 실행됐기 때문이다.
setTimeout의 동작을 정리하면 이렇다.
- 브라우저 Web API 영역에서 타이머를 등록한다.
- delay가 지나면 콜백을 Task Queue로 이동시킨다.
- Call Stack이 비어야 실행된다.
setTimeout(fn, 300)은 "정확히 300ms 후 실행"이 아니라 "최소 300ms 이후, Call Stack이 빌 때 실행" 이다.
그리고 이전 타이머를 취소하는 로직이 없으므로 연속 입력 시 타이머가 계속 쌓인다.
2단계: clearTimeout
이전 타이머를 취소하면 된다.
let timeoutId: number;
textarea.addEventListener("input", () => {
clearTimeout(timeoutId); // 이전 타이머 취소
timeoutId = window.setTimeout(() => {
saveDraft(textarea.value);
}, 300);
});새 입력이 들어오면 이전 타이머를 취소하고 새 타이머를 등록한다. 타이핑을 멈춘 뒤 300ms가 지나야 저장된다.
실제 프로젝트에서 작성한 임시저장 코드도 이 패턴을 그대로 썼다.
let autosaveTimeoutId: number;
textarea.addEventListener("input", () => {
clearTimeout(autosaveTimeoutId);
autosaveTimeoutId = window.setTimeout(() => {
localStorage.setItem("draft", textarea.value);
showMessage();
}, 300);
});하나의 textarea에서는 잘 동작했다.
그런데 제목과 본문, 두 개의 입력 필드로 늘어나자 문제가 생겼다.
두 핸들러가 같은 autosaveTimeoutId를 공유하면서 타이머가 섞였다.
전역 변수는 모든 핸들러가 하나의 ID를 덮어쓰기 때문이다.
3단계: 클로저로 독립적인 타이머를 갖다
문제의 본질은 타이머 ID를 어디에 보관하느냐였다.
debounce를 함수로 추상화하면 반환된 함수마다 독립적인 timer 변수를 가진다.
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number) {
let timer: number;
// debounce를 호출할 때마다 새로운 timer 변수가 만들어진다
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = window.setTimeout(() => fn(...args), delay);
};
}반환된 함수는 선언 당시의 렉시컬 환경을 기억한다. 이것이 클로저다.
함수가 선언될 당시의 렉시컬 환경을 기억하는 것
debounce를 호출할 때마다 새로운 timer 변수가 만들어지고, 반환된 함수가 그것을 독점한다.
// 틀린 방법: 하나의 인스턴스를 두 필드가 공유하면 타이머가 또 섞인다
const debouncedSave = debounce(saveDraft, 300);
titleInput.addEventListener("input", () => debouncedSave(titleInput.value));
bodyInput.addEventListener("input", () => debouncedSave(bodyInput.value));
// 올바른 방법: 필드마다 debounce를 따로 호출해 독립적인 timer를 갖는다
const debouncedTitleSave = debounce(saveDraft, 300);
const debouncedBodySave = debounce(saveDraft, 300);
titleInput.addEventListener("input", () =>
debouncedTitleSave(titleInput.value),
);
bodyInput.addEventListener("input", () => debouncedBodySave(bodyInput.value));별도: 이벤트 핸들러와 this 바인딩
글자 수 제한을 구현할 때 화살표 함수로 작성했다가 에러를 만났다.
textarea.addEventListener("input", () => {
this.value = this.value.substring(0, 40); // this가 textarea가 아님
});화살표 함수는 자신의 this를 가지지 않는다.
상위 스코프의 this를 그대로 사용하기 때문에(렉시컬 바인딩) textarea를 가리키지 않는다.
반면 일반 함수는 이벤트 리스너에서 호출될 때 이벤트 대상 요소를 this로 바인딩한다.
textarea.addEventListener("input", function () {
if (this.value.length > 40) {
this.value = this.value.substring(0, 40);
}
});this(이벤트 대상)가 필요하면 일반 함수를 사용한다.this가 필요 없고 클로저 변수만 참조하면 화살표 함수도 문제없다.
debounce는 만능이 아니었다
임시저장에서 효과를 보자 글자 수 카운트에도 debounce를 적용했다.
textarea.addEventListener(
"input",
debounce(() => {
textCount.textContent = `${textarea.value.length}/40`;
}, 300),
);직접 사용해보니 문제가 있었다.
- 타이핑 중에는 카운트가 갱신되지 않는다.
- 입력이 완전히 멈춰야 숫자가 바뀐다.
- 사용자 입장에서 답답하다.
여기서 중요한 깨달음을 얻었다.
최적화가 항상 좋은 것은 아니다.
글자 수처럼 즉시 피드백이 필요한 곳에서는 debounce가 오히려 UX를 해친다. debounce는 API 요청 감소나 자동 저장처럼 "잠깐 기다려도 되는" 작업에 적합하다.
lodash.debounce
직접 구현해보니 debounce의 핵심이 "타이머 취소와 재등록"임을 알 수 있었다. 이 원리를 이해한 상태로 lodash.debounce 문서를 읽어보니, 옵션 하나하나가 어떤 타이밍 전략인지 쉽게 이해할 수 있었다.
import _ from "lodash";
const debouncedSearch = _.debounce(fetchResults, 300, {
leading: true,
trailing: true,
maxWait: 1000,
});빠르게 연속으로 타이핑해보면 각 옵션이 언제 실행되는지 확인할 수 있다.delay: 500ms
leading
처음 이벤트가 발생했을 때 즉시 한 번 실행한다. 검색어를 입력하기 시작하자마자 첫 결과를 보여주고 싶을 때 유용하다.
trailing
연속 이벤트가 끝난 뒤 마지막으로 한 번 실행한다.
기본값이 true이고, 일반적인 debounce가 이 동작이다.
leading + trailing
처음에 즉시 실행하고, 후속 입력이 있으면 마지막에 한 번 더 실행한다. 첫 반응이 빨라야 하면서 최종 입력값도 반영해야 할 때 쓴다.
maxWait
trailing: true이더라도 사용자가 계속 타이핑하면 실행이 무기한 지연될 수 있다.
maxWait은 그 상한을 설정한다.
// 입력이 멈추지 않아도 1000ms마다 강제로 한 번 실행한다
_.debounce(fetchResults, 300, { maxWait: 1000 });자동 저장처럼 "오래 기다리면 안 되는" 상황에서 안전망 역할을 한다.
비교 정리
| 전략 | 실행 기준 | 적합한 상황 | 부적합한 상황 |
|---|---|---|---|
| setTimeout | 지연 후 1회 | 일회성 지연 작업 | 반복 실행 |
| setInterval | 고정 간격 반복 | 주기 작업 | 이전 작업이 오래 걸릴 때 |
| debounce | 마지막 이벤트 이후 | 검색, 자동 저장, API 요청 감소 | 즉시 피드백 필요한 UI |
| throttle | 일정 간격 | scroll, resize, 마우스 추적 | 최종값만 필요한 경우 |
마치며
이번 경험을 통해 두 가지를 배웠다.
첫째, 최적화는 맥락이다. 글자 수 카운트처럼 즉시 피드백이 필요한 곳에 debounce를 적용했다가 UX를 해쳤다. 기술의 적합성보다 사용자 경험을 먼저 고려해야 한다.
둘째, 직접 구현해야 본질이 보인다.
setTimeout과 clearTimeout, 그리고 클로저의 조합으로 debounce가 동작한다는 것을 직접 구현한 덕분에 알게 되었고, lodash.debounce의 leading, trailing, maxWait 같은 옵션이 단순한 설정이 아니라 실행 타이밍 전략임을 이해할 수 있었다.
기능을 구현하는 것과, 동작 원리를 이해하고 구현하는 것은 다르다. 이번 경험은 단순한 이벤트 최적화가 아니라 JavaScript의 실행 구조를 이해하는 과정이었다.