Posts

클로저와 함께하는 디바운스 & 쓰로틀링

2025-02-12

프론트엔드 면접을 준비하면 클로저, 디바운스, 쓰로틀링은 자주 접하게 돼요. 꼭 면접 준비가 아니더라도 개발하다 보면 필수적으로 마주치는 개념들이죠. 저는 lodash 라이브러리를 직접 구현해보던 중, 디바운스와 쓰로틀링이 클로저 기반으로 동작한다는 걸 그제야 제대로 이해했어요.

클로저 (Closure)

클로저는 함수가 선언된 시점의 렉시컬 환경(Lexical Environment)을 기억하는 메커니즘이에요. 함수는 생성될 때 내부 슬롯 [[Environment]]에 자신이 정의된 스코프에 대한 참조를 저장해요. 이후 외부 함수가 종료되더라도, 내부 함수가 그 스코프의 변수를 참조하고 있는 한 해당 변수는 가비지 컬렉션 대상이 되지 않고 메모리에 유지돼요. 이 구조를 스코프 체인이라고 부르는데, 내부 함수는 자신의 스코프에서 변수를 찾지 못하면 [[Environment]]가 가리키는 외부 스코프로 타고 올라가며 탐색해요.

function createCounter() {
  let count = 0; // 외부 함수의 지역 변수

  return () => {
    count += 1; // 외부 변수 count에 접근
    console.log(`Count: ${count}`);
  };
}

const counter = createCounter();

counter(); // Count: 1
counter(); // Count: 2
counter(); // Count: 3

createCounter()가 반환한 화살표 함수는 count를 자신의 스코프에서 찾지 못하니까, [[Environment]]를 통해 createCounter의 실행 컨텍스트로 올라가서 count를 찾아요. createCounter()는 이미 종료됐지만, 반환된 함수가 count를 계속 참조하고 있으니까 count는 메모리에 남아 있는 거예요. counter()를 몇 번 호출하든 항상 같은 count 변수에 접근하게 되죠.

디바운스 (Debounce)

디바운스는 동일 이벤트가 연속으로 발생할 때 마지막 이벤트로부터 일정 시간이 지난 후에 한 번만 실행하는 패턴이에요. 검색창 자동완성처럼 입력이 끝난 뒤에 API를 호출하고 싶을 때, 또는 윈도우 리사이즈 이벤트처럼 짧은 시간 안에 수백 번 발생하는 이벤트를 처리할 때 써요.

const debounce = (func: Function, delay: number) => {
  let timer: NodeJS.Timeout;

  return (...args: any[]) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => func(...args), delay);
  };
};

debounce가 반환한 함수는 호출될 때마다 timer를 참조해요. debounce 자체는 이미 실행이 끝났지만, 반환된 함수가 timer를 계속 참조하니까 timer는 메모리에 유지되는 거죠. 이전에 등록된 타이머가 있으면 clearTimeout으로 취소하고, delay 밀리초 후에 다시 실행하도록 등록해요. 모든 호출이 같은 timer 변수를 공유하니까 이 취소-재등록 사이클이 성립해요. 클로저가 없다면 각 호출마다 timer가 새로 생성돼서 이전 타이머를 취소할 방법이 없겠죠.

쓰로틀링 (Throttling)

쓰로틀링은 이벤트가 아무리 많이 발생해도 일정 시간 간격 안에는 한 번만 실행하도록 제한하는 패턴이에요. 스크롤 이벤트나 버튼 연속 클릭 방지처럼, 최소한의 실행 간격을 보장해야 할 때 써요. 디바운스가 "마지막 이벤트 이후 N밀리초 뒤에 실행"이라면, 쓰로틀링은 "N밀리초마다 최대 한 번 실행"인 거예요.

항목디바운스쓰로틀링
동작마지막 이벤트만 실행일정 간격으로 실행
사용 예시검색창 자동완성스크롤 이벤트
const throttle = (func: Function, limit: number) => {
  let lastCall = 0;

  return (...args: any[]) => {
    const now = Date.now();
    if (now - lastCall >= limit) {
      lastCall = now;
      func(...args);
    }
  };
};

여기서도 구조는 디바운스와 동일해요. throttle이 반환한 함수는 lastCall을 클로저로 유지하거든요. 함수가 실행될 때마다 now - lastCall로 마지막 실행 이후 얼마나 지났는지 계산하고, limit을 넘었을 때만 실제로 실행해요. 여러 번 호출되더라도 모두 같은 lastCall에 접근하니까 이 간격 체크가 유효한 거죠.

디바운스의 timer와 쓰로틀링의 lastCall, 둘 다 외부 함수가 종료된 후에도 내부 함수가 참조를 유지함으로써 상태가 보존돼요. 이론으로 외웠을 때는 클로저가 추상적으로 느껴졌는데, 실제 동작하는 코드 안에서 보면 왜 이 구조가 필요한지 자연스럽게 이해돼요.


한 단계 더: leading vs trailing 실행

lodash의 debouncethrottle에는 leading, trailing 옵션이 있어요. 직접 구현할 때는 놓치기 쉬운 부분인데, UX에 실질적인 차이를 만들어내요.

trailing (기본값)
이벤트 발생: ----click----click----click
실행 타이밍:                            ↑ (마지막 이후)

leading
이벤트 발생: ----click----click----click
실행 타이밍: ↑ (첫 번째에 즉시)

디바운스 기본 동작(trailing)은 마지막 이벤트 이후에 실행돼요. 검색창 자동완성처럼 "입력이 완전히 끝난 뒤" 동작해야 하는 경우에 맞아요.

반면 버튼 중복 클릭 방지 같은 케이스에서는 첫 번째 클릭에 즉시 반응해야 해요. 마지막 클릭 이후를 기다리면 오히려 느리게 느껴지거든요. 이 경우 leading: true가 필요해요.

const debounce = (func, delay, { leading = false } = {}) => {
  let timer;
  let hasLeadingExecuted = false;

  return (...args) => {
    const shouldCallLeading = leading && !timer;

    if (timer) clearTimeout(timer);

    if (shouldCallLeading) {
      func(...args);
      hasLeadingExecuted = true;
    }

    timer = setTimeout(() => {
      if (!leading || hasLeadingExecuted) {
        if (!leading) func(...args); // trailing 실행
      }
      timer = null;
      hasLeadingExecuted = false;
    }, delay);
  };
};

React에서 디바운스/쓰로틀링 주의점

React 컴포넌트 안에서 디바운스 함수를 쓸 때 흔히 빠지는 함정이 있어요.

// 이렇게 하면 매 렌더링마다 새 함수가 생성됩니다
function SearchBar() {
  const handleSearch = debounce((value) => {
    fetchResults(value);
  }, 300);

  return <input onChange={(e) => handleSearch(e.target.value)} />;
}

리렌더링될 때마다 debounce가 호출돼서 새로운 클로저, 즉 새로운 timer 변수가 만들어져요. 이전 타이머를 취소할 방법이 없으니까 디바운스 자체가 동작하지 않게 되는 거죠.

해결은 useRef로 함수를 고정하거나, useCallbackuseMemo를 조합하는 방식이에요.

function SearchBar() {
  const handleSearch = useRef(
    debounce((value) => {
      fetchResults(value);
    }, 300)
  ).current;

  return <input onChange={(e) => handleSearch(e.target.value)} />;
}

useRef는 컴포넌트 생애 주기 동안 같은 참조를 유지해요. 한 번 만들어진 클로저가 리렌더링과 무관하게 살아 있으니까 timer가 제대로 공유되는 거죠.

마치며

클로저를 처음 배울 때는 "함수가 외부 스코프를 기억한다"는 설명이 잘 와닿지 않았어요. 그런데 디바운스 구현 코드를 보면 왜 클로저가 필요한지 눈에 보여요. timer 변수를 함수 바깥에 두지 않으면 호출마다 새로운 변수가 생기고, 이전 타이머를 취소하는 로직 자체가 무의미해지거든요.

"외부 변수를 기억한다"는 말은 결국 "호출 사이에 상태를 유지한다"는 뜻이고, 디바운스와 쓰로틀링은 그 특성이 없으면 구현 자체가 불가능한 패턴이에요.