Promise 고급 패턴 — 동시성 제한, 재시도, 타임아웃

기본 Promise 메서드 복습

실전 패턴을 다루기 전에 Promise.all, Promise.allSettled, Promise.race, Promise.any의 차이를 정확히 이해해야 합니다. 각 메서드는 여러 Promise를 조합하는 방식이 다릅니다.

// 성공/실패하는 Promise 생성 헬퍼
const success = (value, ms) =>
  new Promise((resolve) => setTimeout(() => resolve(value), ms));
const fail = (reason, ms) =>
  new Promise((_, reject) => setTimeout(() => reject(reason), ms));

// Promise.all — 모두 성공해야 함 (하나라도 실패하면 즉시 reject)
async function allExample() {
  try {
    const results = await Promise.all([
      success("A", 100),
      success("B", 200),
      fail("C 실패", 150),  // 이것 때문에 전체 실패
    ]);
  } catch (error) {
    console.log("all 실패:", error);  // all 실패: C 실패
  }
}

// Promise.allSettled — 모두 완료될 때까지 대기 (실패해도 계속)
async function allSettledExample() {
  const results = await Promise.allSettled([
    success("A", 100),
    fail("B 실패", 200),
    success("C", 150),
  ]);

  results.forEach((result) => {
    if (result.status === "fulfilled") {
      console.log("성공:", result.value);
    } else {
      console.log("실패:", result.reason);
    }
  });
  // 성공: A
  // 실패: B 실패
  // 성공: C
}

// Promise.race — 가장 먼저 완료된 것 반환
// Promise.any — 가장 먼저 성공한 것 반환
메서드하나 실패 시모두 실패 시반환
all즉시 rejectreject결과 배열
allSettled계속 진행모든 결과 반환상태+값 배열
race실패가 먼저면 rejectreject첫 번째 결과
any계속 진행AggregateError첫 성공 결과

동시성 제한 — Promise Pool

수백 개의 비동기 작업을 동시에 실행하면 서버에 과부하가 걸립니다. 동시 실행 수를 제한하는 Pool 패턴이 필요합니다.

async function promisePool(tasks, concurrency = 5) {
  /**
   * 동시 실행 수를 제한하여 Promise를 실행합니다.
   * @param {Function[]} tasks - () => Promise 형태의 함수 배열
   * @param {number} concurrency - 최대 동시 실행 수
   * @returns {Promise<any[]>} 결과 배열 (입력 순서 보장)
   */
  const results = new Array(tasks.length);
  let currentIndex = 0;

  async function worker() {
    while (currentIndex < tasks.length) {
      const index = currentIndex++;
      try {
        results[index] = await tasks[index]();
      } catch (error) {
        results[index] = { error };
      }
    }
  }

  // concurrency 수만큼 워커 생성
  const workers = Array.from(
    { length: Math.min(concurrency, tasks.length) },
    () => worker()
  );
  await Promise.all(workers);
  return results;
}

// 사용 예시: 100개의 API 요청을 동시 5개씩 실행
async function fetchAllUsers() {
  const userIds = Array.from({ length: 100 }, (_, i) => i + 1);
  const tasks = userIds.map(
    (id) => () => fetchUser(id) // () => Promise 형태
  );

  console.time("100개 요청");
  const results = await promisePool(tasks, 5);
  console.timeEnd("100개 요청");
  // 100개 요청: 2034ms (동시 5개씩, 각 요청 100ms 가정)

  const succeeded = results.filter((r) => !r?.error).length;
  console.log(`성공: ${succeeded}, 실패: ${100 - succeeded}`);
}

// 시뮬레이션용 fetch 함수
async function fetchUser(id) {
  await new Promise((r) => setTimeout(r, 100));
  if (id % 17 === 0) throw new Error(`User ${id} not found`);
  return { id, name: `User_${id}` };
}

재시도 패턴 — Exponential Backoff

네트워크 요청이 일시적으로 실패할 때 지수 백오프(Exponential Backoff)로 재시도하는 패턴입니다.

async function withRetry(fn, options = {}) {
  /**
   * 실패 시 지수 백오프로 재시도합니다.
   * @param {Function} fn - 실행할 비동기 함수
   * @param {object} options - 재시도 설정
   */
  const {
    maxRetries = 3,        // 최대 재시도 횟수
    baseDelay = 1000,      // 기본 대기 시간 (ms)
    maxDelay = 30000,      // 최대 대기 시간 (ms)
    backoffFactor = 2,     // 백오프 배수
    shouldRetry = () => true,  // 재시도 여부 판단 함수
  } = options;

  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn(attempt);
    } catch (error) {
      lastError = error;

      if (attempt === maxRetries || !shouldRetry(error)) {
        break;
      }

      // 지수 백오프 + 지터(jitter)
      const delay = Math.min(
        baseDelay * Math.pow(backoffFactor, attempt),
        maxDelay
      );
      const jitter = delay * 0.2 * Math.random(); // 20% 지터
      const waitTime = delay + jitter;

      console.log(
        `[재시도] ${attempt + 1}/${maxRetries} ` +
        `(${waitTime.toFixed(0)}ms 후): ${error.message}`
      );
      await new Promise((r) => setTimeout(r, waitTime));
    }
  }

  throw lastError;
}

// 사용 예시
async function fetchDataWithRetry() {
  const data = await withRetry(
    async (attempt) => {
      console.log(`요청 시도 ${attempt + 1}`);
      const response = await fetch("https://api.example.com/data");
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      return response.json();
    },
    {
      maxRetries: 3,
      baseDelay: 1000,
      // 4xx 에러는 재시도하지 않음 (클라이언트 에러)
      shouldRetry: (error) => !error.message.startsWith("HTTP 4"),
    }
  );
  return data;
}

타임아웃 패턴

특정 시간 내에 완료되지 않으면 에러를 발생시키는 패턴입니다.

function withTimeout(promise, ms, message) {
  /**
   * Promise에 타임아웃을 적용합니다.
   * @param {Promise} promise - 타임아웃을 적용할 Promise
   * @param {number} ms - 타임아웃 시간 (밀리초)
   * @param {string} message - 타임아웃 에러 메시지
   */
  const timeout = new Promise((_, reject) => {
    const timer = setTimeout(() => {
      reject(new Error(message || `${ms}ms 타임아웃 초과`));
    }, ms);

    // Promise가 먼저 완료되면 타이머 정리
    promise.finally(() => clearTimeout(timer));
  });

  return Promise.race([promise, timeout]);
}

// 사용 예시
async function fetchWithTimeout() {
  try {
    const data = await withTimeout(
      fetch("https://api.example.com/slow-endpoint"),
      5000,
      "API 응답 5초 타임아웃"
    );
    return data.json();
  } catch (error) {
    console.error("요청 실패:", error.message);
    // API 응답 5초 타임아웃
  }
}

복합 패턴 — 재시도 + 타임아웃 + 동시성 제한

실전에서는 여러 패턴을 조합하여 사용합니다.

async function robustFetch(urls, options = {}) {
  /**
   * 여러 URL을 안정적으로 가져옵니다.
   * 재시도, 타임아웃, 동시성 제한을 모두 적용합니다.
   */
  const {
    concurrency = 3,
    timeout = 5000,
    maxRetries = 2,
  } = options;

  const tasks = urls.map(
    (url) => async () => {
      return withRetry(
        async () => {
          const response = await withTimeout(
            fetch(url),
            timeout,
            `${url} 타임아웃`
          );
          if (!response.ok) {
            throw new Error(`HTTP ${response.status}: ${url}`);
          }
          return { url, data: await response.json() };
        },
        { maxRetries }
      );
    }
  );

  const results = await promisePool(tasks, concurrency);
  const succeeded = results.filter((r) => !r?.error);
  const failed = results.filter((r) => r?.error);

  console.log(`완료: ${succeeded.length}성공, ${failed.length}실패`);
  return { succeeded, failed };
}

// 사용 예시
// const urls = Array.from({ length: 50 }, (_, i) =>
//   `https://api.example.com/items/${i}`
// );
// const { succeeded, failed } = await robustFetch(urls, {
//   concurrency: 5,
//   timeout: 3000,
//   maxRetries: 2,
// });

실전 팁

  • allSettled 우선: 여러 독립적인 요청에서는 all 대신 allSettled을 사용합니다. 하나의 실패가 전체를 망치지 않습니다
  • 지수 백오프: 재시도 간격은 지수적으로 늘리고, 지터(jitter)를 추가하여 동시 재시도로 인한 서버 부하를 방지합니다
  • 취소 가능한 작업: AbortController를 사용하면 fetch 요청을 취소할 수 있습니다
  • 메모리 주의: 수천 개의 Promise를 동시에 생성하면 메모리가 급증합니다. 반드시 동시성을 제한합니다
  • 에러 분류: 재시도 가능한 에러(5xx, 네트워크)와 불가능한 에러(4xx, 인증)를 구분합니다
  • 로깅: 재시도, 타임아웃 발생 시 로그를 남겨 문제를 추적할 수 있게 합니다

이 글이 도움이 되었나요?