구글 앱스 스크립트 UrlFetchApp HTTP 429 오류 해결: Retry-After와 재시도

구글 앱스 스크립트에서 UrlFetchApp.fetch()로 외부 API를 호출할 때 다음과 같은 오류가 발생할 수 있습니다.

HTTP 429
Too Many Requests

HTTP 429는 외부 API 서버가 일정 시간 동안 너무 많은 요청을 받았다고 판단해 추가 요청을 제한한 응답입니다.

이 오류를 해결하려면 단순히 모든 요청 사이에 같은 대기 시간을 넣기보다 어느 서버가 429를 반환했는지, 응답에 Retry-After가 있는지, API가 분당·사용자별·프로젝트별 중 어떤 기준으로 요청 수를 계산하는지 확인해야 합니다.

또한 외부 API가 반환한 HTTP 429와 Apps Script 자체의 일일 할당량이나 서비스 호출 제한은 같은 오류가 아닙니다. 두 문제는 확인할 위치와 수정 방법이 다릅니다.

이 글은 Google의 UrlFetchApp, HTTPResponse, Apps Script 할당량, Utilities와 Cache Service 공식 문서 및 HTTP 상태 코드 표준을 기준으로 작성했습니다. 실제 요청 제한과 재시도 정책은 호출하는 API의 공식 문서를 우선 적용해야 합니다.

구글 앱스 스크립트 UrlFetchApp의 HTTP 429 요청 제한과 재시도 흐름을 설명하는 대표 개념 이미지
HTTP 429는 외부 API가 요청 속도를 제한했다는 응답입니다. Retry-After 헤더와 API 정책을 확인한 뒤 요청 수를 줄이고 제한된 재시도를 적용해야 합니다. 위 이미지는 오류 처리 흐름을 설명하기 위한 대표 개념 이미지입니다.


먼저 외부 API 429와 Apps Script 할당량 오류를 구분합니다

오류 형태 오류를 반환한 위치 먼저 확인할 항목
HTTP 429 UrlFetchApp가 호출한 외부 API 서버 Retry-After, API별 분당·사용자별 요청 제한
Service invoked too many times Apps Script 서비스 Apps Script 일일 할당량과 짧은 시간의 호출 빈도
실행 시간 초과 Apps Script 실행 환경 반복 횟수, 대기 시간, 한 번의 실행에서 처리하는 작업량
401 또는 403 외부 API의 인증·권한 처리 토큰, API 키, 권한 범위와 계정 접근 권한

HTTP 429가 확인됐다면 Apps Script의 할당량만 늘리려고 해서는 해결되지 않습니다. 외부 API가 정한 요청 속도와 재시도 정책을 따라야 합니다.

반대로 UrlFetchApp가 HTTP 응답을 받기 전에 Apps Script 서비스 제한 예외가 발생했다면 외부 API의 Retry-After 헤더를 확인할 수 없습니다. Apps Script 실행 기록과 할당량을 별도로 확인해야 합니다.

muteHttpExceptions로 상태 코드와 응답을 확인합니다

UrlFetchApp.fetch()는 기본적으로 실패 상태 코드가 반환되면 예외를 발생시킵니다.

응답 상태 코드와 헤더를 직접 확인하려면 요청 옵션에 muteHttpExceptions: true를 지정합니다.

function inspectApiResponse() {
  const url =
    'https://api.example.com/v1/items';

  const response =
    UrlFetchApp.fetch(
      url,
      {
        method: 'get',
        muteHttpExceptions: true
      }
    );

  const statusCode =
    response.getResponseCode();

  const headers =
    response.getAllHeaders();

  const body =
    response.getContentText();

  console.log(
    '상태 코드: ' +
    String(statusCode)
  );

  console.log(
    'Retry-After: ' +
    String(
      getHeaderIgnoreCase_(
        headers,
        'Retry-After'
      )
    )
  );

  console.log(
    '응답 일부: ' +
    summarizeResponseBody_(body)
  );
}

function getHeaderIgnoreCase_(
  headers,
  targetName
) {
  const names =
    Object.keys(headers);

  const matchedName =
    names.find(function (name) {
      return (
        name.toLowerCase() ===
        targetName.toLowerCase()
      );
    });

  if (!matchedName) {
    return null;
  }

  const value =
    headers[matchedName];

  if (Array.isArray(value)) {
    return value.length > 0
      ? value[0]
      : null;
  }

  return value;
}

function summarizeResponseBody_(body) {
  return String(body || '')
    .replace(/\s+/g, ' ')
    .slice(0, 500);
}

muteHttpExceptions는 오류를 해결하거나 무시하는 옵션이 아닙니다. 실패 응답도 HTTPResponse 객체로 받아 상태 코드와 응답 내용을 직접 판단할 수 있게 하는 옵션입니다.

상태 코드를 확인하지 않고 응답 본문만 JSON으로 변환하면 429 오류 메시지나 HTML 오류 페이지를 정상 데이터로 처리하려다 다른 예외가 발생할 수 있습니다.

로그에 인증 정보를 남기지 마세요

Authorization 헤더, API 키, 액세스 토큰이나 개인정보가 포함된 응답 본문 전체를 그대로 기록하지 않습니다. 상태 코드, Retry-After와 원인 확인에 필요한 응답 일부만 남깁니다.

429 응답의 Retry-After 헤더를 먼저 확인합니다

HTTP 429 응답에는 다시 요청하기까지 기다릴 시간을 나타내는 Retry-After 헤더가 포함될 수 있습니다.

값은 다음 두 형식 중 하나로 전달될 수 있습니다.

형식 의미
초 단위 숫자 Retry-After: 30 30초 뒤 다시 요청
HTTP 날짜 지정된 GMT 날짜와 시각 해당 시각 이후 다시 요청

Retry-After가 없으면 API 공식 문서의 재시도 정책을 확인합니다. 별도 정책이 없다면 대기 시간을 점차 늘리는 지수 백오프를 제한된 횟수만 적용할 수 있습니다.

GET 요청에 적용할 수 있는 제한된 재시도 코드

다음 코드는 조회용 GET 요청에서 429와 일부 일시적인 서버 오류만 제한적으로 다시 시도합니다.

재시도 횟수를 제한하고, Retry-After가 있으면 해당 값을 우선하며, 헤더가 없을 때는 지수 백오프와 임의 지연을 사용합니다.

const HTTP_RETRY_CONFIG =
  Object.freeze({
    maxAttempts: 5,
    baseDelayMs: 1000,
    maxBackoffMs: 32000,
    maxInlineRetryAfterMs: 60000
  });

function fetchGetWithRetry_(
  url,
  params
) {
  const requestParams =
    Object.assign(
      {},
      params || {},
      {
        method: 'get',
        muteHttpExceptions: true
      }
    );

  let lastStatusCode = null;
  let lastResponseBody = '';

  for (
    let attempt = 0;
    attempt <
      HTTP_RETRY_CONFIG.maxAttempts;
    attempt += 1
  ) {
    const response =
      UrlFetchApp.fetch(
        url,
        requestParams
      );

    const statusCode =
      response.getResponseCode();

    const responseBody =
      response.getContentText();

    lastStatusCode =
      statusCode;

    lastResponseBody =
      responseBody;

    if (
      statusCode >= 200 &&
      statusCode < 300
    ) {
      return response;
    }

    if (
      !isRetryableStatusCode_(
        statusCode
      )
    ) {
      throw new Error(
        buildHttpErrorMessage_(
          statusCode,
          responseBody
        )
      );
    }

    const isLastAttempt =
      attempt ===
      HTTP_RETRY_CONFIG.maxAttempts - 1;

    if (isLastAttempt) {
      break;
    }

    const retryAfterMs =
      parseRetryAfterMs_(
        response.getAllHeaders()
      );

    if (
      retryAfterMs !== null &&
      retryAfterMs >
        HTTP_RETRY_CONFIG
          .maxInlineRetryAfterMs
    ) {
      throw new Error(
        'HTTP ' +
        String(statusCode) +
        ': 서버가 ' +
        String(retryAfterMs) +
        '밀리초 뒤 재시도를 요청했습니다. ' +
        '현재 실행에서 기다리지 말고 ' +
        '시간 기반 트리거 등으로 나중에 다시 실행하세요.'
      );
    }

    const delayMs =
      retryAfterMs !== null
        ? retryAfterMs
        : calculateBackoffMs_(attempt);

    Utilities.sleep(delayMs);
  }

  throw new Error(
    '재시도 한도를 초과했습니다. ' +
    buildHttpErrorMessage_(
      lastStatusCode,
      lastResponseBody
    )
  );
}

function isRetryableStatusCode_(
  statusCode
) {
  return (
    statusCode === 429 ||
    statusCode === 500 ||
    statusCode === 502 ||
    statusCode === 503 ||
    statusCode === 504
  );
}

function parseRetryAfterMs_(
  headers
) {
  const rawValue =
    getHeaderIgnoreCase_(
      headers,
      'Retry-After'
    );

  if (
    rawValue === null ||
    rawValue === undefined
  ) {
    return null;
  }

  const value =
    String(rawValue).trim();

  if (value === '') {
    return null;
  }

  const seconds =
    Number(value);

  if (
    Number.isFinite(seconds) &&
    seconds >= 0
  ) {
    return Math.round(
      seconds * 1000
    );
  }

  const retryDateMs =
    Date.parse(value);

  if (
    Number.isNaN(retryDateMs)
  ) {
    return null;
  }

  return Math.max(
    retryDateMs - Date.now(),
    0
  );
}

function calculateBackoffMs_(
  attempt
) {
  const exponentialDelay =
    HTTP_RETRY_CONFIG.baseDelayMs *
    Math.pow(2, attempt);

  const cappedDelay =
    Math.min(
      exponentialDelay,
      HTTP_RETRY_CONFIG.maxBackoffMs
    );

  const jitterMs =
    Math.floor(
      Math.random() * 1001
    );

  return cappedDelay + jitterMs;
}

function buildHttpErrorMessage_(
  statusCode,
  responseBody
) {
  return (
    'HTTP ' +
    String(statusCode) +
    ': ' +
    summarizeResponseBody_(
      responseBody
    )
  );
}

function getHeaderIgnoreCase_(
  headers,
  targetName
) {
  const names =
    Object.keys(headers);

  const matchedName =
    names.find(function (name) {
      return (
        name.toLowerCase() ===
        targetName.toLowerCase()
      );
    });

  if (!matchedName) {
    return null;
  }

  const value =
    headers[matchedName];

  if (Array.isArray(value)) {
    return value.length > 0
      ? value[0]
      : null;
  }

  return value;
}

function summarizeResponseBody_(
  body
) {
  return String(body || '')
    .replace(/\s+/g, ' ')
    .slice(0, 500);
}

코드의 처리 순서는 다음과 같습니다.

  1. 실패 응답도 받도록 muteHttpExceptions를 설정합니다.
  2. 200번대 응답이면 HTTPResponse를 반환합니다.
  3. 429와 일부 일시적 서버 오류만 재시도 대상으로 분류합니다.
  4. Retry-After가 있으면 해당 대기 시간을 우선합니다.
  5. Retry-After가 없으면 지수 백오프와 임의 지연을 사용합니다.
  6. 정해진 횟수를 모두 사용하면 원래 상태 코드와 응답 일부를 포함해 실패시킵니다.

400, 401, 403, 404처럼 요청 내용·인증·권한·주소를 수정해야 하는 오류는 같은 요청을 반복해도 해결되지 않을 가능성이 높으므로 자동 재시도 대상에 포함하지 않았습니다.

API 응답을 JSON으로 바꾸기 전에 상태 코드를 확인합니다

앞에서 만든 함수가 성공한 HTTPResponse만 반환하므로 그다음에 응답 본문을 JSON으로 변환할 수 있습니다.

function loadApiItems() {
  const properties =
    PropertiesService
      .getScriptProperties();

  const apiUrl =
    properties.getProperty(
      'API_URL'
    );

  const apiToken =
    properties.getProperty(
      'API_TOKEN'
    );

  if (!apiUrl) {
    throw new Error(
      '스크립트 속성에 API_URL이 없습니다.'
    );
  }

  if (!apiToken) {
    throw new Error(
      '스크립트 속성에 API_TOKEN이 없습니다.'
    );
  }

  const response =
    fetchGetWithRetry_(
      apiUrl,
      {
        headers: {
          Authorization:
            'Bearer ' + apiToken,
          Accept:
            'application/json'
        }
      }
    );

  const responseText =
    response.getContentText();

  try {
    return JSON.parse(
      responseText
    );

  } catch (error) {
    throw new Error(
      '성공 응답을 JSON으로 변환하지 못했습니다: ' +
      summarizeResponseBody_(
        responseText
      )
    );
  }
}

API URL과 토큰은 코드에 직접 공개하지 않고 스크립트 속성 등 적절한 저장 위치에서 읽도록 구성했습니다.

성공 응답이라고 해도 모든 API가 JSON을 반환하는 것은 아닙니다. 호출하는 API의 응답 형식이 XML, CSV 또는 일반 텍스트라면 해당 형식에 맞게 처리해야 합니다.

POST 요청을 무조건 자동 재시도하지 않습니다

GET 조회 요청은 같은 요청을 반복해도 서버 데이터가 추가로 생성되지 않는 경우가 일반적입니다.

하지만 주문 생성, 메시지 발송, 결제 요청, 행 추가와 같은 POST 요청은 같은 요청을 반복하면 중복 결과가 생길 수 있습니다.

HTTP 표준에서도 비멱등 요청을 자동으로 반복하려면 해당 요청이 실제로 반복 가능한 구조인지 확인할 수 있어야 한다고 설명합니다.

POST 재시도 전에 확인할 사항

API가 멱등성 키를 지원하는지 확인합니다.
동일한 요청 ID가 중복 생성을 막는지 확인합니다.
429 응답 전에 서버가 작업을 처리했을 가능성을 구분할 수 있는지 확인합니다.
생성 결과를 조회해 기존 요청의 성공 여부를 확인할 수 있는지 확인합니다.
API 공식 문서가 자동 재시도를 허용하는지 확인합니다.

API가 이러한 구조를 제공하지 않는다면 앞의 GET용 재시도 함수를 POST 요청에 그대로 적용하지 않습니다.

요청을 재시도하기 전에 호출 수부터 줄입니다

429를 재시도로만 처리하면 이미 많은 요청이 발생한 상태에서 추가 요청을 더 보내게 됩니다.

다음 구조가 있는지 먼저 확인합니다.

  • 같은 URL을 반복문 안에서 여러 번 호출함
  • 동일한 데이터를 함수마다 다시 가져옴
  • 여러 트리거가 같은 시간에 같은 API를 호출함
  • API가 제공하는 페이지 크기를 지나치게 작게 설정함
  • 일괄 조회 API가 있는데 항목별 요청을 반복함
  • 변하지 않는 데이터를 매 실행마다 다시 가져옴

외부 데이터가 짧은 시간 동안 바뀌지 않는다면 Cache Service로 결과를 임시 저장해 같은 요청이 반복되는 횟수를 줄일 수 있습니다.

반복 조회 데이터는 Cache Service로 줄일 수 있습니다

다음 코드는 같은 API 응답을 스크립트 캐시에 일정 시간 저장합니다.

function getCachedJson_(
  cacheKey,
  url,
  params,
  expirationSeconds
) {
  const cache =
    CacheService.getScriptCache();

  const cachedValue =
    cache.get(cacheKey);

  if (cachedValue !== null) {
    return JSON.parse(
      cachedValue
    );
  }

  const response =
    fetchGetWithRetry_(
      url,
      params
    );

  const responseText =
    response.getContentText();

  let parsedValue;

  try {
    parsedValue =
      JSON.parse(responseText);

  } catch (error) {
    throw new Error(
      'API 응답을 JSON으로 변환하지 못했습니다: ' +
      summarizeResponseBody_(
        responseText
      )
    );
  }

  cache.put(
    cacheKey,
    responseText,
    expirationSeconds
  );

  return parsedValue;
}

스크립트 전체 사용자가 같은 데이터를 공유해도 되는 경우에는 getScriptCache()를 사용할 수 있습니다.

사용자마다 다른 데이터를 가져온다면 스크립트 공용 캐시에 저장하지 말고 getUserCache() 또는 다른 사용자별 저장 구조를 검토해야 합니다.

캐시 데이터는 지정한 만료 시간까지 반드시 유지된다고 보장되지 않습니다. 따라서 캐시에서 null이 반환되면 API를 다시 호출할 수 있도록 작성해야 합니다.

fetchAll은 요청 제한을 우회하는 기능이 아닙니다

UrlFetchApp.fetchAll()은 여러 요청을 한 번에 전달하고 HTTPResponse 배열을 받을 수 있는 기능입니다.

하지만 요청을 하나의 함수 호출로 묶었다고 해서 외부 API 입장에서 요청 수가 한 건으로 바뀌는 것은 아닙니다.

대상 API가 초당 요청 수나 동시 요청 수를 제한한다면 많은 요청을 한꺼번에 보내는 구조가 오히려 짧은 시간의 429 응답을 늘릴 수 있습니다.

fetchAll을 사용하기 전에는 API의 일괄 요청 지원 여부, 동시 요청 제한, 분당 요청 수와 응답별 오류 처리 방법을 확인해야 합니다.

Retry-After가 길면 현재 실행에서 계속 기다리지 않습니다

Utilities.sleep()은 지정한 밀리초 동안 스크립트를 대기시키며 한 번에 지정할 수 있는 최대값은 300,000밀리초입니다.

하지만 API가 몇 분이나 몇 시간 뒤 다시 시도하라고 응답했다면 현재 실행에서 긴 시간을 계속 기다리는 구조보다 작업 상태를 저장하고 시간 기반 트리거로 나중에 다시 실행하는 방식이 적절할 수 있습니다.

앞의 재시도 예제는 Retry-After가 60초를 넘으면 기다리지 않고 오류를 발생시키도록 구성했습니다. 60초는 모든 API에 적용되는 공식 기준이 아니라, 한 번의 실행 안에서 지나치게 오래 기다리지 않도록 정한 예제 설정값입니다.

실제 최대 대기 시간은 Apps Script 실행 제한, 처리해야 할 작업량과 API의 재시도 정책을 고려해 결정해야 합니다.

동시에 시작되는 트리거도 요청 급증을 만들 수 있습니다

시간 기반 트리거, 사용자 수정 트리거 또는 여러 사용자의 실행이 비슷한 시각에 시작되면 각각의 스크립트가 같은 외부 API를 호출할 수 있습니다.

개별 실행 안에서는 요청 횟수가 적어 보여도 API 키나 계정 전체를 기준으로 합산하면 짧은 시간에 제한을 넘을 수 있습니다.

이 경우에는 다음 방법을 검토합니다.

  • 여러 트리거의 실행 시각을 서로 다르게 분산
  • 같은 데이터를 Cache Service로 공유
  • 처리할 항목을 한 번에 모두 요청하지 않고 작업 단위로 분리
  • 중복 실행 여부를 기록하고 이미 처리 중인 작업은 시작하지 않음
  • API가 제공하는 일괄 처리 엔드포인트 사용

LockService는 스크립트 실행이 특정 보호 구간에 동시에 들어가는 것을 제한할 수 있지만 외부 API의 요청 한도를 늘려주는 기능은 아닙니다.

상태 코드별로 자동 재시도 여부를 구분합니다

상태 코드 먼저 확인할 원인 같은 요청 자동 재시도
400 요청 본문·매개변수·JSON 형식 요청을 수정하기 전에는 재시도하지 않음
401 인증 토큰·API 키 인증을 갱신하기 전에는 재시도하지 않음
403 권한 또는 API별 사용 제한 응답 사유와 API 문서를 먼저 확인
404 URL·리소스 ID·삭제된 대상 주소를 수정하기 전에는 재시도하지 않음
429 요청 속도·사용량 제한 Retry-After 또는 제한된 지수 백오프 적용
500·502·503·504 일시적인 서버 또는 게이트웨이 오류 API 정책에 따라 제한된 재시도 검토

HTTP 429가 반복될 때 최종 확인 순서

1. muteHttpExceptions로 실제 상태 코드 확인
예외 문구만 보지 말고 HTTPResponse의 상태 코드, 헤더와 응답 일부를 확인합니다.
2. Retry-After 확인
서버가 대기 시간을 제공했다면 그 시간보다 빠르게 반복 요청하지 않습니다.
3. API 공식 제한 확인
초당·분당·사용자별·API 키별·프로젝트별 중 어떤 제한이 적용되는지 확인합니다.
4. 중복 요청 제거
반복문 안의 동일 호출, 중복 트리거와 같은 데이터의 반복 조회를 줄입니다.
5. 캐시와 일괄 요청 검토
바뀌지 않는 조회 결과는 캐시하고 API가 제공하는 일괄 엔드포인트가 있다면 항목별 호출을 줄입니다.
6. 재시도 횟수 제한
성공할 때까지 반복하지 말고 최대 시도 횟수와 최대 대기 시간을 정합니다.
7. POST 중복 처리 여부 확인
데이터 생성 요청은 멱등성 키나 중복 확인 구조가 있을 때만 자동 재시도를 적용합니다.
8. Apps Script 자체 제한과 구분
HTTPResponse를 받기 전에 서비스 할당량 예외가 발생했다면 외부 API 429와 별도로 처리합니다.

내용 확인에 사용한 공식 자료

공식 문서 확인일: 2026년 7월 29일

Post a Comment

다음 이전