Ray Book
비동기 JavaScript

실전 비동기 유틸리티 패턴

실무에서 반복적으로 쓰이는 비동기 유틸리티, debounce, throttle, 동시성 제한을 시각화합니다

javascriptasyncdebouncethrottleconcurrency

비동기 제어의 필요성

이전 글에서 에러 처리 패턴을 다뤘습니다. 하지만 실전에서는 "에러가 나지 않아도" 제어가 필요한 상황이 있습니다.

  • 검색창에 타이핑할 때마다 API를 호출하면? → 요청 폭탄
  • 스크롤 이벤트마다 위치를 계산하면? → 프레임 드랍
  • 100개 URL을 동시에 fetch하면? → 서버 과부하

이 문제들을 해결하는 유틸리티 패턴을 살펴봅니다.

Debounce

마지막 호출 후 일정 시간이 지나야 실행 합니다. 연속된 호출 중 마지막 것만 실행됩니다.

function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

핵심: 매 호출마다 clearTimeout으로 이전 타이머를 취소하고 새 타이머를 설정합니다. 마지막 호출 후 ms가 지나야 비로소 실행됩니다.

검색 입력에 적용

const searchInput = document.querySelector("#search");

const search = debounce(async (query) => {
  const res = await fetch("/search?q=" + query);
  const data = await res.json();
  renderResults(data);
}, 300);

searchInput.addEventListener("input", (e) => {
  search(e.target.value);
});

사용자가 타이핑을 멈춘 후 300ms가 지나야 요청을 보냅니다.

코드
1function debounce(fn, ms) {
2 let timer;
3 return (...args) => {
4 clearTimeout(timer);
5 timer = setTimeout(() => fn(...args), ms);
6 };
7}
8 
9const search = debounce(query => {
10 fetch("/search?q=" + query);
11}, 300);
타임라인
입력: "r"Fired
0ms
타이머Scheduled
300ms 후 실행
사용자가 "r"을 입력합니다. 이전 타이머를 취소(clearTimeout)하고 300ms 타이머를 새로 설정합니다.

Leading vs Trailing

위의 debounce는 trailing방식입니다, 대기 시간이 끝난 후 실행. leading 방식은 첫 호출에 즉시 실행하고, 이후 호출을 무시합니다.

function debounce(fn, ms, leading = false) {
  let timer;
  return (...args) => {
    if (leading && !timer) {
      fn(...args); // 즉시 실행
    }
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!leading) fn(...args);
    }, ms);
  };
}
  • Trailing (기본): 검색 입력, 최종 결과만 필요
  • Leading : 버튼 클릭, 첫 클릭에 즉시 반응, 연타 방지

Throttle

일정 간격으로 최대 1번 실행 합니다. debounce와 달리 실행이 보장됩니다.

function throttle(fn, ms) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= ms) {
      last = now;
      fn(...args);
    }
  };
}

이 구현은 쿨다운 중 들어온 호출을 모두 버립니다 (leading-only). 마지막 호출이 유실될 수 있어, lodash의 _.throttle은 기본적으로 trailing 실행도 포함합니다. 스크롤 위치 저장처럼 마지막 상태가 중요한 경우에는 trailing 옵션을 고려하세요.

핵심: 마지막 실행 시간을 기록하고, 다음 호출에서 ms가 경과했는지 확인합니다.

코드
1function throttle(fn, ms) {
2 let last = 0;
3 return (...args) => {
4 const now = Date.now();
5 if (now - last >= ms) {
6 last = now;
7 fn(...args);
8 }
9 };
10}
11 
12const onScroll = throttle(() => {
13 updatePosition();
14}, 100);
타임라인
scroll 이벤트Executed
0ms
첫 번째 스크롤 이벤트. last가 0이므로 조건(now - last >= 100)을 만족하여 즉시 실행됩니다.

실전에서는 lodash_.debounce_.throttle이 널리 사용됩니다. lodash의 구현은 leading/trailing 옵션과 maxWait (최대 대기 시간) 을 지원하며, 흥미롭게도 _.throttle은 내부적으로 _.debouncemaxWait 옵션을 전달하는 방식으로 구현되어 있습니다.

Debounce vs Throttle

DebounceThrottle
실행 시점마지막 호출 후 ms 경과ms마다 최대 1번
연속 호출 시마지막 것만 실행주기적으로 실행
사용처검색 입력, 폼 검증, 리사이즈 완료스크롤, 마우스 이동, 게임 입력
특징조용해질 때까지 기다림일정 간격 보장
Debounce (300ms)연속 입력 중에는 타이머가 계속 리셋됩니다. 마지막 입력 후 300ms가 지나야 실행됩니다.
0ms200ms400ms600ms800ms1000ms1200ms
입력
입력
입력
입력
실행!
호출무시됨실행
// debounce, 리사이즈가 끝나면 레이아웃 재계산
window.addEventListener("resize",
  debounce(() => recalcLayout(), 250)
);

// throttle, 스크롤 중에도 100ms마다 위치 업데이트
window.addEventListener("scroll",
  throttle(() => updateScrollPosition(), 100)
);

동시성 제한 (Concurrency Pool)

Promise.all은 모든 작업을 동시에 시작합니다. 100개 URL을 동시에 fetch하면 서버가 429 (Too Many Requests) 를 반환하거나, 브라우저가 연결을 거부할 수 있습니다.

기본 구현

async function poolAll(urls, limit = 3) {
  const results = [];
  const executing = new Set();

  for (const url of urls) {
    const p = fetch(url).then(r => r.json());
    results.push(p);
    executing.add(p);
    p.finally(() => executing.delete(p));

    if (executing.size >= limit)
      await Promise.race(executing);
  }
  return Promise.all(results);
}

executing Set이 limit에 도달하면 Promise.race로 하나가 끝날 때까지 기다립니다. 완료되면 다음 요청을 시작합니다.

코드
1async function poolAll(urls, limit = 3) {
2 const results = [];
3 const executing = new Set();
4 
5 for (const url of urls) {
6 const p = fetch(url).then(r => r.json());
7 results.push(p);
8 executing.add(p);
9 p.finally(() => executing.delete(p));
10 
11 if (executing.size >= limit)
12 await Promise.race(executing);
13 }
14 return Promise.all(results);
15}
타임라인
fetch(url[0])Fired
슬롯 1/3
fetch(url[1])Fired
슬롯 2/3
fetch(url[2])Fired
슬롯 3/3
처음 3개 요청을 동시에 시작합니다. executing Set에 3개가 들어있으므로 limit에 도달합니다.

사용 예시

const urls = Array.from(
  { length: 100 },
  (_, i) => `/api/item/${i}`
);

// 동시에 최대 5개만 요청
const results = await poolAll(urls, 5);

Promise.all과 비교

// Promise.all, 100개 동시 요청 (위험)
const results = await Promise.all(
  urls.map(url => fetch(url))
);

// poolAll, 5개씩 순차적 병렬 (안전)
const results = await poolAll(urls, 5);
Promise.allpoolAll(limit=5)
동시 요청100개 전부최대 5개
서버 부하높음제어 가능
속도가장 빠름 (서버가 버틸 때)약간 느림
안정성429 에러 위험안전

큐잉 (Queue)

여러 비동기 작업을 순서대로 실행해야 할 때 사용합니다. 예를 들어, 채팅 메시지 전송은 순서가 보장되어야 합니다.

function createQueue() {
  let pending = Promise.resolve();

  return function enqueue(fn) {
    const run = () => fn();
    pending = pending.then(run, run);
    return pending;
  };
}

const queue = createQueue();

// 순서 보장: A → B → C
queue(() => sendMessage("A"));
queue(() => sendMessage("B"));
queue(() => sendMessage("C"));

pending이 이전 작업의 Promise를 가리키므로, .then()으로 체이닝하면 순서가 보장됩니다. 이전 작업이 실패해도 다음 작업은 실행됩니다 (then(run, run)).

requestAnimationFrame

UI 업데이트는 requestAnimationFrame으로 브라우저의 렌더링 주기에 맞추는 것이 가장 효율적입니다.

function rafThrottle(fn) {
  let scheduled = false;
  return (...args) => {
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      fn(...args);
      scheduled = false;
    });
  };
}

// 매 프레임마다 최대 1번 실행 (~16.6ms)
window.addEventListener("mousemove",
  rafThrottle((e) => updateCursor(e.clientX, e.clientY))
);

throttle(fn, 16)과 비슷하지만, 브라우저의 렌더링 타이밍에 정확히 동기화되므로 더 부드럽습니다.

시리즈 마무리

비동기 JavaScript 시리즈를 통해 다음을 다뤘습니다.

  1. 이벤트 루프 , 싱글 스레드에서 비동기가 동작하는 메커니즘
  2. 콜백 , 가장 오래된 비동기 패턴과 그 한계
  3. Promise , 체이닝, 에러 전파, 병렬 실행
  4. async/await , 동기 코드처럼 비동기를 작성
  5. 에러 처리 패턴 , 재시도, 타임아웃, AbortController
  6. 유틸리티 패턴 , debounce, throttle, 동시성 제한

이벤트 루프 위에 콜백이, 콜백 위에 Promise가, Promise 위에 async/await가 구축되었습니다. 각 계층을 이해하면 어떤 비동기 문제든 적절한 도구를 선택할 수 있습니다.