GitHub Actions 크론 배치가 429 에러로 멈췄을 때: API Rate Limit 실측 데이터와 Retry/Fallback 설계기
정각만 되면 깔끔하게 돌아가야 할 GitHub Actions 크론(Cron) 워크플로우가 간헐적으로 멈춰 서는 현상을 겪어본 적이 있으신가요? 실패 로그를 열어보면 어김없이 나타나는 응답 코드가 있습니다. 바로 HTTP 429 Too Many Requests 또는 403 Forbidden (Secondary Rate Limit)입니다.
특히 여러 개의 리포지토리를 관리하거나, 배치를 통해 GitHub REST API로 이슈·PR·릴리스 데이터를 수집하는 환경이라면 이 문제는 단순한 해프닝으로 끝나지 않습니다. 데이터 유실로 이어지거나 후속 파이프라인 전체가 블로킹되는 장애로 확산되기 때문입니다.
이 글에서는 GitHub Actions 환경에서 발생시키는 API 요청이 왜 Rate Limit에 걸리는지 메커니즘을 정밀하게 분해하고, 이를 회피하기 위해 적용할 수 있는 Retry 및 Fallback 아키텍처 설계를 공유합니다.
정각의 비극: 크론 배치가 API Limit에 도달하는 이유
GitHub Actions의 크론 스케줄러(schedule 이벤트)는 엄밀한 의미의 Dedicated Cron이 아닙니다. 전 세계 수만 개의 워크플로우가 0 * * * *(매시 정각)에 실행되도록 설정되어 있기 때문에, 해당 시각에는 GitHub infrastructure 전체에 거대한 트래픽 큐가 형성됩니다.
여기서 진짜 문제는 크론의 실행 지연(Delay)에 그치지 않고, 배치 스크립트 내부에서 호출하는 GitHub API의 쿼터 고갈로 이어진다는 점입니다.
# 장애 발생 당시 액션 로그 예시
GH_API_RESPONSE=$(curl -s -w "%{http_code}" -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/owner/repo/pulls)
# 출력 결과
HTTP/2 429
{
"message": "You have exceeded a secondary rate limit. Please wait a few minutes before retrying.",
"documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#secondary-rate-limits"
}
기본적으로 GitHub Actions 작업 내부에서 자동 제공되는 GITHUB_TOKEN은 넉넉한 쿼터를 제공하는 것처럼 보이지만, 숨겨진 한계와 단시간 내 폭발적인 요청(Burst)을 제어하는 이차 제한(Secondary Rate Limit)에 매우 취약합니다.
GitHub API Rate Limit 메커니즘 분석
문제 해결을 위해서는 GitHub이 API 요청을 제한하는 두 가지 축인 Primary Rate Limit과 Secondary Rate Limit을 명확히 구분해야 합니다.
1. Primary Rate Limit (기본 시간당 쿼터)
인증 방식 및 계정 유형에 따라 1시간 단위로 부여되는 고정 쿼터입니다.
GITHUB_TOKEN(GitHub Actions 기본): 리포지토리당 시간당 1,000회 (GitHub Enterprise Cloud는 15,000회)- Personal Access Token (PAT) / User-to-Server: 계정당 시간당 5,000회
- GitHub App (Server-to-Server): 조직(Organization) 및 설치 수에 따라 시간당 5,000회 이상 (규모에 따라 가변적)
2. Secondary Rate Limit (동시성 및 급증 제한)
Primary 쿼터가 4,000회 이상 남아있더라도 429 또는 403 에러가 발생하는 원인입니다. GitHub 서버의 과부하를 막기 위해 다음과 같은 규칙을 가동합니다.
- Concurrent Requests: 동시에 100개를 초과하는 요청을 병렬로 처리할 때
- High Volume Bursts: 1초당 10회 이상의 요청을 지속적으로 보낼 때
- CPU-Intensive Endpoints: Search API 등 서버 리소스를 많이 먹는 엔드포인트를 빠른 주기로 호출할 때
| 구분 | Primary Rate Limit | Secondary Rate Limit |
|---|---|---|
| 원인 | 1시간 총 호출 건수 초과 | 단시간 집중 요청, 병렬 처리 과다, CPU 과부하 API 호출 |
| HTTP 상태 코드 | 403 Forbidden (x-ratelimit-remaining: 0) |
429 Too Many Requests 또는 403 Forbidden |
| 복구 시점 | 다음 정시 (포인트 리셋 시점) | Retry-After 헤더에 명시된 초 단위 시간 후 |
| 응답 헤더 특징 | x-ratelimit-reset (Unix Timestamp) |
retry-after 또는 x-ratelimit-reset |
실패가 발생하는 전형적인 패턴과 한계점 확인
GITHUB_TOKEN을 사용한 단순 curl 루프 방식은 특정 조건에서 Secondary Rate Limit에 쉽게 걸립니다. 대표적인 패턴은 매시간 정각 실행되는 이슈/PR 동기화 스크립트가 Shell Script 내 for 루프로 REST API를 순차 호출하는 구조입니다.
이런 구조에서는 전체 API 쿼터(시간당 1,000회)를 다 쓰기 한참 전에 Secondary Rate Limit에 걸려 작업이 중단되는 경우가 흔합니다. Primary 쿼터는 넉넉히 남아 있더라도, 리포지토리 내 수백 개 PR의 커밋 내역을 하나씩 조회하는 연쇄 호출(N+1 Query) 구조 자체가 단시간 집중 요청으로 간주되어 차단되기 때문입니다. 자신의 배치가 이런 패턴에 해당하는지는 Actions 로그에서 호출 빈도와 429/403 발생 시점을 직접 비교해 확인해 보는 것이 가장 정확합니다.
API Rate Limit 회피 및 복원력(Resilience) 설계 3단계
이 문제를 근본적으로 해결하려면 단일 GITHUB_TOKEN 기반의 직렬 호출 구조를 인증 분리, 백오프 리트라이(Exponential Backoff), GraphQL 전환의 3단계 아키텍처로 재설계하는 것이 효과적입니다.
[ 기존 구조 ]
GitHub Actions Cron -> GITHUB_TOKEN 사용 -> REST API 직렬 호출 -> [ 429 에러 즉시 실패 ]
[ 개선된 아키텍처 ]
GitHub Actions Cron
│
├── 1. GitHub App Token Issuance (인증 분리: 쿼터 5,000회 확보)
│
├── 2. GraphQL API 전환 (N+1 호출을 단일 쿼리로 통폐합)
│
└── 3. Smart Retry Client (429 발생 시 Retry-After + Exponential Backoff + Jitter)
1단계: Exponential Backoff & Full Jitter를 적용한 Retry Wrapper
API가 429 또는 403을 반환하면 즉시 실패 처리하는 대신, 응답 헤더의 Retry-After 값을 읽어 대기 후 재시도하는 래퍼(Wrapper) 스크립트를 구현해야 합니다. 헤더가 없는 경우 지수 백오프(Exponential Backoff)에 무작위성(Jitter)을 더해 재시도 충돌을 방지합니다.
아래는 Bash/Python 환경에서 모두 적용 가능한 핵심 알고리즘 구조입니다.
import time
import random
import requests
def call_github_api_with_retry(url, headers, max_retries=5):
base_delay = 2
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
# 성공 시 즉시 반환
if response.status_code == 200:
return response.json()
# Rate Limit 걸렸을 때 (429 또는 403 Secondary Rate Limit)
if response.status_code in [429, 403]:
# 1. Retry-After 헤더 우선 확인
retry_after = response.headers.get("Retry-After")
if retry_after:
sleep_time = int(retry_after)
else:
# 2. Header가 없으면 Full Jitter 적용한 지수 백오프 계산
# Delay = Random(0, Min(Max_Delay, Base * 2^attempt))
calculated_delay = base_delay * (2 ** attempt)
sleep_time = random.uniform(0, min(60, calculated_delay))
print(f"[429 Warning] Rate Limit 도달. {sleep_time:.2f}초 후 재시도합니다. (시도 {attempt + 1}/{max_retries})")
time.sleep(sleep_time)
continue
# 기타 에러 처리
response.raise_for_status()
raise Exception("최대 재시도 횟수를 초과했습니다.")
2단계: GITHUB_TOKEN 단일 의존 탈피 (GitHub App 인증 전환)
GITHUB_TOKEN은 워크플로우 실행 시점마다 새로 생성되지만, 기본 쿼터 한계(1,000/hr)가 낮습니다. 이를 해결하려면 조직(Org) 단위의 GitHub App을 생성하고, 액션 실행 시 App Installation Token을 발급받아 사용하도록 전환하는 것을 권장합니다.
이 방식을 사용하면 다음과 같은 이점이 있습니다.
- 쿼터 상승: 기본 쿼터가 1,000회에서 5,000회/시간으로 확장됩니다.
- 권한 분리: 리포지토리 전체 접근 권한 대신 특정 작업에 필요한 스코프(예:
issues:read,pull_requests:read)만 세밀하게 제어할 수 있습니다.
GitHub Actions 워크플로우에는 navict/github-app-token-action 등을 활용해 가볍게 적용할 수 있습니다.
jobs:
cron-batch:
runs-on: ubuntu-latest
steps:
- name: GitHub App 토큰 발급
id: generate-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: 배치 스크립트 실행
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
python scripts/sync_data.py
3단계: REST API에서 GraphQL API로의 마이그레이션
Secondary Rate Limit의 주원인은 **과도한 API 요청 횟수(Call Count)**입니다. REST API로 특정 PR 50개의 상세 정보와 리뷰 커멘트를 가져오려면 최소 51번의 HTTP 요청이 필요합니다.
GET /repos/{owner}/{repo}/pulls(1회)GET /repos/{owner}/{repo}/pulls/{number}/comments(50회)
이를 GraphQL API로 전환하면 단 1회의 요청으로 통합할 수 있습니다.
# 단 1회의 호출로 50개 PR과 내재된 커멘트까지 일괄 조회
query GetPRWithComments($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(first: 50, states: OPEN) {
nodes {
number
title
comments(first: 10) {
nodes {
body
author { login }
}
}
}
}
}
}
GitHub GraphQL API는 요청 횟수 대신 **Query Node Cost(쿼리 복잡도 비용)**를 계산하여 제한(시간당 5,000 포인트)합니다. 배치 작업 특성상 연쇄 요청이 많은 구조라면 GraphQL 전환만으로 전체 HTTP 요청 수를 90% 이상 줄일 수 있습니다.
파이프라인 안정화를 위한 체크리스트
크론 배치의 Rate Limit 에러를 완벽하게 방지하기 위해 코드 적용 전 체크해야 할 항목입니다.
- 크론 실행 시각 분산:
0 * * * *처럼 정각을 피하고17 * * * *또는42 3 * * *등 무작위 분(Minute) 단위로 스케줄을 변경했는가? - 병렬 처리(Concurrency) 제어:
xargs -P나 PythonThreadPoolExecutor등으로 동시 요청을 보낼 때 Worker 수를 5개 이하로 제한했는가? - Conditional Request 활용:
If-None-Match및ETag헤더를 보내 변경 사항이 없는 데이터는304 Not Modified로 응답받아 쿼터를 아끼고 있는가? - Fallback 토큰 백업: primary 인증(GitHub App) 실패 시 2차 토큰(PAT)으로 교체하여 요청을 이어가는 Fallback 로직이 있는가?
마치며
GitHub Actions 환경에서 API Rate Limit으로 인한 429 에러는 단순히 “잠시 기다렸다 다시 실행하면 되는” 일시적 해프닝이 아닙니다. 서비스의 데이터 정합성을 위협하고 파이프라인 전체의 신뢰도를 떨어뜨리는 명확한 기술 부채입니다.
대다수의 엔지니어링 문제는 무작정 리소스를 늘리기보다 호출 메커니즘을 이해하는 것에서 해결책이 시작됩니다. 정각에 몰리는 스케줄 시각을 바꾸는 아주 작은 조치부터, REST API의 N+1 호출 구조를 GraphQL로 개선하고 지수 백오프 리트라이를 갖추는 아키텍처적 보완까지 단계적으로 적용해 보시길 권합니다.
이러한 내결함성(Fault Tolerance) 설계를 하나씩 갖추어 나갈 때, 새벽녘 알람 없이도 안정적으로 작동하는 자동화 시스템을 완성할 수 있습니다.