GitHub 웹훅은 실패한 전달을 자동으로 다시 보내지 않습니다 — gh 폴링을 옮기며 거꾸로 적은 한 줄
AI 코딩 세션 하나가 53시간 동안 gh CLI 를 972번 불렀습니다. CI 완료와 새 코멘트, PR 병합을 폴링으로 확인하던 세션입니다. 이 폴링을 GitHub 웹훅으로 옮기며 README 에 «실패하면 다시 보낸다»고 적었는데, 공식 문서는 자동 재전송이 없다고 적고 있었습니다.
같은 기간 gh 로 상태를 확인하던 Claude Code 의 백그라운드 감시(Monitor)만 27개였습니다. 부끄럽지만 이 감시들은 석 달 반 전 제가 글로 권했던 패턴이 쌓인 결과입니다. 백그라운드 에이전트는 끝날 때 한 번만 알려 주니, 지켜보는 채널을 따로 띄워 상태를 계속 흘려받자는 패턴이었습니다.
이 글은 AI 코딩 세션들이 요청과 작업 상태를 주고받는 제 작은 NestJS 서버에 GitHub 웹훅 이벤트 원장을 붙이며 확인한 것들과, 그 과정에서 틀리게 적은 한 줄에 관한 기록입니다.
숫자는 정의부터 다시 셌습니다
처음 받아 든 숫자는 «72시간 동안 988번»이었습니다. 그대로 인용하려다 세션 기록을 다시 집계해 보니, 첫 도구 호출부터 제가 «더 나은 방법이 없을까»를 물은 시점까지 53.2시간이었습니다. 무엇을 «gh 명령 하나»로 셀지도 모호했습니다.
그래서 정의부터 정했습니다. 그동안의 Bash 호출 1,781개 중 gh 가 들어간 것이 510개였고, 그 안에서 명령 경계 뒤의 gh 를 정규식 (^|[\s;&|(`$])gh\s 로 세면 972번입니다. 백그라운드 감시는 103개였고, 그중 gh 로 상태를 반복 확인한 것이 27개였습니다.
| 기다린 것 | 감시 수 | 확인 간격 |
|---|---|---|
| CI 완료 | 13 | 60초 |
| 다른 사람이 단 이슈 코멘트 | 10 | 30·120·180초 |
| PR 병합·리뷰 | 4 | 90·120초 |
gh 와 sleep 을 묶은 대기 루프도 5개였습니다. 감시 하나는 대략 이렇게 생겼습니다. 실제 스크립트를 일반화한 것입니다.
# CI 가 끝날 때까지 60초마다 확인하는 감시 하나
until [ "$(gh run view "$RUN" --json status -q .status)" = "completed" ]; do
sleep 60
done
몇 줄짜리라 띄우는 데 부담이 없습니다. 돌이켜 보면 그 가벼움이 비용을 가렸다는 생각이 듭니다. 게다가 972번은 세션 기록에 적힌 명령을 센 것이라, 감시와 루프 «안에서» 반복된 호출은 빠져 있습니다. 실제 요청은 이보다 많고, 몇 번인지는 모릅니다.
gh 폴링은 바뀐 게 없어도 값을 치릅니다
제가 권했던 패턴이 틀렸다고 생각하지는 않습니다. 그 글의 Monitor 가 지켜본 것은 제가 띄운 배포처럼 끝이 정해진 작업이었습니다. 이번 감시들은 바깥 시스템을 지켜봤습니다. 다른 사람이 언제 코멘트를 달지는 아무도 모르고, 그사이 비용은 보이지 않는 곳에서 쌓였습니다.
바뀐 게 없어도 요청은 매번 나갑니다. gh 가 제 계정으로 보내는 REST 요청은 인증된 사용자 기본 한도인 시간당 5,000번에서 빠집니다(앱·토큰 종류에 따라 다를 수 있습니다). 발견은 간격만큼 늦어서, 180초 간격이면 코멘트가 달리고도 최대 3분을 모른 채 기다립니다. 그리고 대상마다 따로 묻습니다.
그래서 감시 하나의 호출 수는 «돌아간 시간 ÷ 간격 × 대상 수»가 됩니다. 이슈 다섯 개를 2분마다 보는 감시가 한 시간 돌면 60 ÷ 2 × 5 = 150번, 30초 간격이면 600번입니다. 감시 안의 호출은 기록에 없으니 측정이 아니라 계산입니다. 그래도 감시 하나가 한 시간에 수십에서 수백 번을 부른다는 것은 계산만으로도 보입니다.
폴링은 상태를 보러 가는 방식이라, 늦게 가도 «완료»를 놓치지는 않습니다. 이벤트 처리에서 레벨 트리거라고 부르는 성질에 가깝습니다. 그 대가로 비용이 변화의 수가 아니라 확인의 수에 비례합니다. 아무 일도 없던 한 시간에도 150번은 150번입니다.
GitHub 레포 이벤트 피드는 트리거가 되지 못했습니다
처음 본 대안은 레포 이벤트 피드였습니다. GET /repos/{owner}/{repo}/events 는 코멘트·이슈·PR·리뷰를 한 줄로 돌려주고, ETag 로 물어 304 를 받으면 한도도 쓰지 않습니다(이 API 문서에 한정된 설명입니다). 그런데 같은 문서에 이런 문장이 있었습니다.
This API is not built to serve real-time use cases. Depending on the time of day, event latency can be anywhere from 30s to 6h.
지연이 30초에서 6시간까지 벌어지고, 지난 30일 안의 이벤트를 최대 300개까지만 줍니다. 이벤트 타입 목록에 워크플로·체크 계열이 없어서 CI 결과도 들어 있지 않습니다. CI 완료는 웹훅의 workflow_run·check_suite 같은 이벤트로 받아야 합니다. «무엇이 있었나»를 훑는 데는 써도, «답이 달리면 바로 이어서 일하는» 트리거로는 쓸 수 없었습니다.
PR 에 달린 말은 세 웹훅 이벤트로 갈립니다
GitHub 웹훅으로 옮기기 전 처음 확인한 것은 PR 에 달린 말이 어떤 이벤트로 오는가였습니다. 최소 세 갈래였습니다.
| 받고 싶은 것 | 이벤트 | action |
|---|---|---|
| PR 대화 탭의 일반 코멘트 | issue_comment | created |
| 코드 줄(diff)에 단 코멘트 | pull_request_review_comment | created |
| 리뷰 제출(승인·변경 요청·코멘트) | pull_request_review | submitted |
GitHub REST 문서에는 «모든 PR 은 이슈지만, 모든 이슈가 PR 은 아니다»라는 문장이 있습니다. 그래서 PR 대화 탭의 일반 코멘트는 pull_request 쪽이 아니라 issue_comment 로 옵니다. 이슈 코멘트와는 payload 의 issue 에 pull_request 속성이 있는지로 가릅니다(payload.issue?.pull_request != null). 하나만 구독하면 나머지는 에러 없이 조용히 빠집니다.
웹훅 설정에서 고르는 것은 이벤트 «종류»와 주소·형식·비밀값뿐입니다. «나를 멘션한 코멘트만» 같은 필터는 없으니 거르는 것은 받는 쪽 몫입니다. 자동화가 단 코멘트도 똑같이 오기 때문에, 코멘트에 답하는 봇이라면 자기 코멘트에 다시 반응하는 고리를 조심해야 합니다. 보낸 계정(sender)으로 가를 수 있습니다.
GitHub 의 모범 사례 문서는 필요한 이벤트만 구독하라고 권합니다. 저는 1단계에서 그와 반대로 * 를 골라 전부 받고 있습니다. 거르기 전에 먼저 세기로 했기 때문입니다.
X-Hub-Signature-256 은 파싱 전의 원문 바이트로 잽니다
GitHub 웹훅의 X-Hub-Signature-256 헤더는 요청 본문을 비밀값으로 HMAC-SHA256 한 hex 값이고, 항상 sha256= 로 시작합니다. 문서는 평범한 == 대신 상수 시간 비교를 쓰라고 하고, 페이로드는 UTF-8 로 다루라고 적습니다.
import { createHmac, timingSafeEqual } from 'node:crypto';
const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
if (!sameSignature(signatureHeader, expected)) {
throw new AppError(401, 'BAD_SIGNATURE', '서명이 맞지 않습니다');
}
const payload = JSON.parse(rawBody.toString('utf8')); // 검증을 통과한 뒤에야 파싱한다
function sameSignature(given: string, expected: string): boolean {
const a = Buffer.from(given);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b); // 길이가 다르면 timingSafeEqual 이 예외를 던진다
}
Node 의 timingSafeEqual 은 길이가 다르면 예외를 던지므로 길이를 먼저 봅니다.
함정은 그 앞의 파서에 있었습니다. JSON 파서가 먼저 본문을 객체로 바꾸면 원문 바이트가 사라집니다. 제 추론이지만, 다시 직렬화한 문자열은 공백이나 이스케이프 표기가 달라져 서명과 어긋날 수 있습니다. 문서가 «파싱 전에 검증하라»고 못 박지는 않아도 예제는 검증을 먼저 합니다.
그래서 NestJS 의 기본 파서를 끄고, 웹훅 경로에만 원문(raw body) 파서를 먼저 걸었습니다. NestJS 11.2, Express 5.2, body-parser 2.3 기준입니다.
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
// 웹훅은 서명 검증에 원문 바이트가 필요하다. 이 경로만 원문으로 받는다.
app.use('/webhooks/github', express.raw({ type: () => true, limit: 4 * 1024 * 1024 }));
// 나머지 API 는 JSON 파서가 받는다. 원문 파서가 먼저 스트림을 읽은 요청은 건너뛴다.
app.useBodyParser('json', { limit: '128kb' });
핵심은 등록 순서입니다. body-parser 는 스트림이 이미 끝난 요청을 «body already parsed»로 보고 건너뜁니다. 원문 파서가 먼저 다 읽은 웹훅 요청은 뒤의 JSON 파서가 손대지 않습니다. 등록 순서가 곧 설계였습니다.
한도도 여기서 갈렸습니다. body-parser 의 기본 한도는 100kb 인데 GitHub 페이로드는 25MB 까지 옵니다. 큰 PR 이벤트가 걸릴 수 있어 웹훅 경로만 4MiB 로 올리고, 나머지 API 는 128kb 로 두었습니다.
수신기를 기존 API 에 끼워 넣지 않고 따로 둔 이유 중 하나도 이것이었습니다. 전역 설정을 바꾸지 않고 경로 하나만 바꿀 수 있어야 했습니다.
웹훅에는 10초 안에 답하고, 1단계는 저장만 합니다
GitHub 은 웹훅 전달에 10초 안에 2XX 응답을 기대하고, 서버가 내려가 있거나 10초를 넘기면 그 전달을 실패로 기록합니다. 그래서 서명을 확인하면 저장만 하고 곧바로 202 로 답합니다. 비밀값이 없으면 503, 헤더 형식이 틀리면 400, 서명이 틀리면 401 이고, 같은 IP 에서 잘못된 요청이 반복되면 잠시 429 로 막습니다.
CREATE TABLE gh_event (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
delivery_id VARCHAR(64) NOT NULL, -- X-GitHub-Delivery
event VARCHAR(48) NOT NULL, -- X-GitHub-Event
action VARCHAR(48) NULL,
repo_full_name VARCHAR(140) NULL,
payload_bytes INT UNSIGNED NOT NULL,
payload JSON NOT NULL, -- 원문 그대로(상한 초과 시 표시만)
PRIMARY KEY (id),
UNIQUE KEY uq_gh_event_delivery (delivery_id) -- 같은 배달은 한 건으로 접는다
);
X-GitHub-Delivery 는 이벤트를 식별하는 전역 고유 식별자(GUID)라, 여기에 유일 제약을 걸어 같은 전달을 한 건으로 접습니다. 원문은 1MiB 까지 그대로 두고, 넘으면 크기와 «잘렸다»는 표시만 남깁니다. 요청 본문이 4MiB 를 넘으면 413 으로 거절하는데, GitHub 은 25MB 까지 보내므로 그 사이의 이벤트는 받지 않는 셈입니다. 알고 고른 트레이드오프입니다.
레포마다 이벤트를 읽을 계정을 정하되 지정이 없으면 서버 소유자만 보게 해서, 지정을 깜빡해도 새지 않는 쪽을 기본값으로 두었습니다.
1단계의 원장은 여기까지만 합니다. 거르지 않고 저장할 뿐, 세션에 밀어 넣지도 사람에게 알리지도 않습니다. 원칙은 한 문장이었습니다.
무엇이 얼마나 오는지 재기 전에 거르면, 거르는 규칙은 측정이 아니라 추측이 됩니다.
«내 차례», 곧 세션이 이어서 일해야 한다는 신호가 전체의 5% 라면 규칙 몇 줄로 끝납니다. 50% 라면 필요한 것은 선별이 아니라 요약입니다. 통계에서 기저율이라고 부르는 이 비율에 따라 설계가 갈리니 먼저 셉니다.
측정 결과는 아직 없습니다. 분포를 말할 만큼 쌓이지 않았습니다.
README 에 거꾸로 적은 한 줄 — 실패한 웹훅 전달은 자동으로 다시 오지 않습니다
이 원장을 만들며 README 와 소스 주석에 이렇게 적었습니다.
같은 배달은 한 번만 남는다. GitHub 은 실패하면 다시 보낸다.
이 글을 준비하며 공식 문서를 다시 열어 보니 반대였습니다.
GitHub does not automatically redeliver failed deliveries.
GitHub 은 실패한 전달을 자동으로 다시 보내지 않는다는 문장입니다. 서버가 내려갔다면 다시 올라온 뒤 놓친 웹훅을 재전송하라는 것이 문서의 권고입니다.
더 민망한 것은 따로 있었습니다. 이틀 전 같은 작업에서 저는 자동 재전송이 없다는 바로 그 사실을 짚었고, 그것을 근거로 설계를 판단했습니다. 그런데 README 에는 기억으로 적은 반대 문장이 들어갔습니다. 이틀 사이에 스스로 뒤집은 셈입니다.
X-GitHub-Delivery 로 중복을 접는 설계는 여전히 맞습니다. 재전송을 «요청하면» 원래와 같은 값이 오기 때문입니다. 틀린 것은 «자동으로 다시 온다»는 믿음이었습니다. 웹훅은 변화가 일어난 순간 한 번 오는 엣지 트리거라서, 그 순간 받는 쪽이 없으면 받는 쪽에는 아무것도 남지 않습니다.
원장을 붙인 뒤 제 레포 세 곳에서 들어온 웹훅 전달 26건은 조회한 시점까지 모두 202 였습니다. 그때까지 유실이 없었다는 뜻일 뿐, 서버가 내려가 있던 동안의 전달도 안전하다는 뜻은 아닙니다.
그 구간은 사람이 다시 보내거나 재전송 API 로 복구해야 하는데, 이 서버에는 아직 그 장치가 없습니다. 그마저도 지난 3일 안의 전달만 다시 보낼 수 있습니다. 문서가 소개하는 복구 스크립트는 일정한 주기로 돌며 전달 기록을 가져와, 상태가 OK 가 아닌 것을 다시 보냅니다. 폴링을 줄이려고 붙인 웹훅의 빈틈을, 문서는 다시 주기적인 확인으로 메우고 있었습니다.
남는 것
폴링은 몇 줄로 띄울 수 있어서 비용이 보이지 않는 곳에 쌓입니다. 웹훅은 그 비용을 변화가 있을 때로 옮겨 주지만, 엣지 트리거라서 «놓친 것은 다시 오지 않는다»를 전제로 설계해야 합니다. 중복은 유일 제약 한 줄로 접히지만, 유실은 받는 쪽에 흔적을 남기지 않습니다.
제가 쓴 문장도 검증 대상이었습니다. «72시간·988번»은 다시 세고서야, «실패하면 다시 보낸다»는 문서를 다시 열고서야 틀린 줄 알았습니다. 숫자는 정의부터 다시 세고, 플랫폼의 동작은 원문을 열어 보고 적어야 한다는 것을 이번에 두 번 배웠습니다.
선별 규칙은 숫자가 쌓인 뒤에 정하려고 합니다. «내 차례» 신호가 5% 일지 50% 일지는 아직 모릅니다. 그 비율이 보이면, 그때 이어서 적어 볼 수 있을 것 같습니다.
참고 문서 (GitHub Docs · body-parser)
- Webhook events and payloads
- Validating webhook deliveries
- Best practices for using webhooks
- Handling failed webhook deliveries
- Redelivering webhooks
- REST API endpoints for repository webhooks
- REST API endpoints for events
- GitHub event types
- Rate limits for the REST API
- REST API endpoints for issue comments
- expressjs/body-parser
관련 글
사람 손 없이 PR→머지→배포: AI 에이전트 자동 연쇄를 설계하며 배운 것
코드 리뷰·머지 게이트·배포는 다 자동화했는데, 사슬의 진입점만 수동 호출로 남아 있었습니다. PR 생성 직후 자동 트리거, 보고가 아닌 GitHub를 직접 조회하는 머지 게이트, 배포 보류 게이팅까지 — 자동 연쇄를 직접 설계하며 겪은 시행착오를 정리했습니다.
CI가 전부 멈췄고, 저는 원인을 확인하지 않았습니다 — 30분 만에 끝난 대응의 함정
GitHub Actions hosted 러너가 새벽에 전면 차단됐습니다. 변수 한 줄과 self-hosted 러너로 30분 만에 우회했지만, 에러 메시지를 원인으로 착각한 탓에 진짜 이유는 2주 뒤에야 알게 됐습니다.
background agent는 종료 시 1번만 알린다: Monitor 이중 채널 패턴
백그라운드 배포 에이전트가 167초 만에 종료한 뒤 15분간 무음이었던 실제 사고에서 출발해, 일하는 채널(background agent)과 보는 채널(Monitor)을 분리하는 이중 채널 패턴, 그리고 tail·head가 만드는 폴링 함정 두 가지를 코드 예시로 정리했습니다.