왜 중복 이벤트가 문제인가
GitHub, GitLab, Docker Registry 같은 서비스는 웹훅을 최소 1회(at-least-once) 전달을 보장한다. 즉 같은 이벤트가 두 번 이상 도착할 수 있다. 소비자가 2xx를 늦게 응답하면 발신 측 타임아웃으로 재시도가 발생하고, 로드밸런서 재시도나 사용자의 push 재실행까지 겹치면 동일한 push 이벤트가 여러 번 배포 파이프라인을 깨운다.
배포 트리거는 부작용(side effect)이 크다. 같은 커밋으로 롤아웃이 두 번 돌면 리소스 경합, 이미지 태그 덮어쓰기, 마이그레이션 중복 실행, 심하면 진행 중인 배포를 스스로 취소하는 레이스가 생긴다. 그래서 멱등성(idempotency)은 선택이 아니라 필수다.
멱등성 키를 무엇으로 잡을까
핵심은 "같은 배포 의도"를 하나로 식별하는 키를 정하는 것이다. 발신 측이 주는 delivery ID는 재시도 시 유지되지만, 사용자가 같은 커밋을 다시 push하면 새 ID가 온다. 목적에 따라 키를 나눠야 한다.
| 키 후보 | 중복 제거 범위 | 주의점 |
|---|---|---|
| X-GitHub-Delivery | 동일 이벤트의 네트워크 재시도 | 같은 커밋 재push는 못 막음 |
| repo + ref + commit SHA | 같은 커밋의 배포 의도 | 강제 push로 SHA 동일 시 재배포 불가 |
| SHA + 워크플로 이름 | 배포 단위별 dedup | 키 설계가 가장 실무적 |
재시도만 막을지, 같은 커밋 전체를 막을지 명확히 구분해서 키를 조합해야 한다.
서명 검증이 먼저다
멱등 처리 이전에 웹훅이 진짜인지 확인해야 한다. HMAC 서명을 상수 시간 비교로 검증한다.
import hmac, hashlib
def verify_signature(secret: bytes, body: bytes, header: str) -> bool:
# header 예: "sha256=abcd..."
algo, _, sig = header.partition("=")
if algo != "sha256":
return False
mac = hmac.new(secret, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, sig)
DB 유니크 제약으로 원자적 dedup
가장 견고한 방법은 애플리케이션 락이 아니라 DB의 유니크 제약이다. 삽입에 성공하면 "이 이벤트를 내가 처음 잡았다"가 원자적으로 보장된다. 여러 파드가 동시에 같은 이벤트를 받아도 정확히 하나만 통과한다.
CREATE TABLE deploy_events (
idempotency_key TEXT PRIMARY KEY,
delivery_id TEXT NOT NULL,
commit_sha TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'processing',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 처음 잡는 경우에만 INSERT 성공, 중복이면 0행
INSERT INTO deploy_events (idempotency_key, delivery_id, commit_sha)
VALUES ($1, $2, $3)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;
RETURNING이 행을 반환하면 이 요청이 승자다. 빈 결과면 이미 다른 요청이 처리 중이거나 완료했다는 뜻이니 즉시 200으로 무시한다.
핸들러 조립하기
검증 → dedup 삽입 → 승자만 배포 트리거 순서로 묶는다.
def handle_webhook(req):
if not verify_signature(SECRET, req.body, req.headers["X-Hub-Signature-256"]):
return 401
payload = json.loads(req.body)
sha = payload["after"]
workflow = "deploy-prod"
key = f"{payload['repository']['full_name']}:{workflow}:{sha}"
row = db.execute(INSERT_SQL, [key, req.headers["X-GitHub-Delivery"], sha])
if row is None: # 중복 → 이미 처리 중/완료
return 200
try:
trigger_deploy(sha) # 실제 부작용은 승자만 실행
db.execute("UPDATE deploy_events SET status='done' WHERE idempotency_key=%s", [key])
except Exception:
db.execute("UPDATE deploy_events SET status='failed' WHERE idempotency_key=%s", [key])
raise
return 200
빠른 응답과 비동기 처리 분리
발신 측 타임아웃(대개 10초)을 넘기면 성공했어도 재시도가 온다. 따라서 수신 즉시 dedup만 기록하고 200을 반환한 뒤, 실제 배포는 큐로 넘겨 워커가 처리하는 구조가 안전하다. 이렇게 하면 트리거가 오래 걸려도 중복 재전송을 유발하지 않는다.
실무 주의점
- 실패 상태 정리:
processing으로 남고 워커가 죽으면 재시도가 영원히 막힌다. TTL이나 상태 기반 재처리 정책을 둔다. - Redis 단독 의존 주의:
SET key val NX EX 600은 빠르지만 만료·유실 시 중복이 새어 나온다. 최종 방어선은 DB 제약으로 둔다. - 강제 push: 동일 SHA로 다시 배포해야 할 정당한 경우가 있으므로, 수동 재배포 경로는 키에 실행 시각이나 run ID를 포함해 별도로 연다.
- 관측성: 중복으로 걸러진 이벤트 수를 메트릭으로 남기면 재시도 폭증이나 발신 측 이상을 조기에 파악할 수 있다.