결제 웹훅 재시도 폭주로 포인트가 세 번 적립된 백엔드 장애 케이스다. event_id 유니크만으로는 부족한 이유, 수신 인박스·멱등 키·트랜잭션 아웃박스 도입 순서, ACK 시점과 워커 SKIP LOCKED, 도입 전후 지표와 온콜 런북, PostgreSQL·API 연동 FAQ를 문제→시도→결과로 정리한 개발자 가이드.
목요일 14:07, 결제 완료 웹훅이 같은 event_id로 11번 들어왔다. 파트너 결제사 대시보드에는 "전달 성공"이 초록으로 표시되고 있었고, 우리 쪽 주문 테이블에는 결제 금액이 세 번 더해진 행이 있었다. 고객 문의 두 건은 "카드는 한 번만 긁혔는데 포인트가 세 번 쌓였어요"였다.
B2B SaaS 결제 연동 팀이 겪은 사고다. 처음엔 재시도 정책을 탓했고, 다음은 유니크 인덱스만 믿었고, 마지막에야 수신 순서·멱등 키·아웃박스를 한 묶음으로 고쳤다. 아래는 문제 발생부터 시도·결과까지를 시간순으로 복원한 케이스다. 회사명·절대 금액은 익명 처리했고, 비율·지연 숫자는 교육용으로 단순화했다.
※ 2026년 상반기 포스트모템·런북 초안 기반 재구성. 웹훅 스펙은 일반 HTTP 콜백 패턴을 전제로 한다.
무엇이 깨졌나 | 14:07~16:50 타임라인
스택은 Node.js API + PostgreSQL + 백그라운드 워커였다. 결제 완료 웹훅 엔드포인트는 본문을 검증한 뒤 주문 상태를 paid로 바꾸고, 포인트 적립·세금계산서 큐·슬랙 알림을 같은 요청 안에서 동기 호출했다. 응답 코드는 처리가 끝난 뒤에야 200을 보냈다.
시각
사건
체감 영향
14:07
파트너 웹훅 1차 수신, DB 커밋 직전 슬로우 쿼리 4.2초
게이트웨이 타임아웃 3초 초과
14:07~14:09
파트너 재시도 정책(즉시·30s·2m·10m…) 가동
동일 event 연속 수신
14:11
워커 없이 동기 적립이 중복 실행
포인트 3배 적립 1건 확인
14:28
CS 티켓 2건, 재무 대사 불일치 알람
온콜 호출
15:40
핫픽스: event_id 유니크 + 무시 응답
신규 중복 차단, 이미 쌓인 포인트는 수동
16:50
잔여 재시도 소진, 모니터링 안정
부분 장애 약 2시간 40분
카드 실결제는 한 번이었다. 깨진 쪽은 우리 쪽 부수 효과였다. 주문 상태 전이는 대체로 멱등에 가까웠지만, 포인트·알림·정산 이벤트는 그렇지 않았다.
타임아웃 뒤 재시도가 겹치면 동일 이벤트가 여러 워커 경로로 들어온다
원인 분해 | 재시도가 아니라 처리 계약
파트너 재시도는 스펙대로였다. 문서에 "2xx가 아니면 지수 백오프로 최대 N회"라고 적혀 있었다. 팀이 놓친 것은 네 가지 계약이다.
ACK 시점 — 무거운 부수 효과가 끝나기 전에 200을 주지 않았다. 파트너 타임아웃(이 경우 3초)보다 처리가 길면 재시도가 정상 동작한다.
멱등 키 부재 — event_id를 로그에만 남기고 DB 유니크·처리 테이블에 쓰지 않았다. 같은 본문이 와도 "처음 온 것처럼" 돌았다.
부수 효과 동기 체인 — 포인트 API, 알림, 정산 큐 발행이 웹훅 요청 스레드에 묶였다. 하나라도 느리면 전체가 타임아웃 후보가 된다. 외부 의존성 지연이 내부 장애로 퍼지는 구조는 회로 차단기 케이스와도 겹친다. 관련: 외부 API 회로 차단기 도입 케이스.
부분 성공 상태 — 주문은 paid인데 포인트만 두 번, 알림만 한 번 같은 중간 상태가 생겼다. 트랜잭션 경계가 업무 단위와 어긋났다.
슬로우 쿼리 자체는 인덱스 부재가 원인이었다. 다만 인덱스를 깔아도 재시도는 언젠가 다시 온다. 네트워크 끊김, 배포 중 502, 파트너 측 버그 모두 같은 형태로 재현된다. "오늘은 쿼리, 내일은 배포"를 전제로 한 쪽이 웹훅 수신 계약이다.
포스트모템 한 줄: "재시도를 없애 달라고 파트너에 요청하는 대신, 같은 이벤트가 열 번 와도 결과가 한 번이 되게 만들자."
시도 1 | event_id 유니크 인덱스만 추가
당일 핫픽스는 단순했다. webhook_events(event_id) 유니크 제약을 걸고, 충돌 시 200과 빈 본문을 바로 반환했다. 신규 중복 적립은 멈췄다. 이미 세 번 쌓인 포인트는 운영 SQL로 되돌렸다.
이틀 뒤 스테이징에서 재현 테스트를 돌리자 빈틈이 보였다. 두 워커가 거의 동시에 INSERT를 시도하면 한쪽은 유니크 충돌로 빠지지만, 그 전에 이미 포인트 API를 호출한 레이스가 남았다. 유니크는 행 단위 저장을 막아 줄 뿐, 애플리케이션이 부수 효과를 먼저 치면 소용없다.
또한 파트너가 가끔 같은 결제에 대해 event_id가 다른 "수정 이벤트"(금액 정정, 부분 환불 예고)를 보냈다. 키를 결제 ID만으로 잡으면 정당한 후속 이벤트가 무시된다. 키 설계를 "어떤 단위로 한 번인가"까지 내려가야 했다.
시도 2 | 먼저 200, 나중에 처리 (잘못된 순서로 뒤집기)
두 번째 시도는 응답을 빨리 주는 쪽이었다. 웹훅 핸들러가 본문을 Redis 리스트에 넣고 즉시 200, 워커가 꺼내 처리. 파트너 타임아웃은 사라졌다. 재시도 횟수도 줄었다.
문제는 큐 put 직후 프로세스 재시작이었다. 메시지가 유실되거나, at-least-once로 두 번 소비되면 다시 중복이 났다. "빠른 ACK"만으로는 부족하고, 영속 저장 + 소비 멱등이 같이 있어야 했다. 백그라운드 큐 패턴 자체는 도움이 되지만, 재시도·모니터링 설계 없이 리스트만 두면 장애 형태만 바뀐다. 관련: BullMQ 실전 튜토리얼, 프로덕션 구조화 로깅 체크리스트.
팀은 Redis 리스트를 폐기하고, PostgreSQL webhook_inbox 테이블에 원본 JSON을 먼저 INSERT하는 쪽으로 방향을 틀었다. 수신 트랜잭션의 성공 = "이 이벤트는 우리가 책임진다"는 의미가 된다.
수신(영속)과 처리(부수 효과)를 나눈 뒤 각각에 멱등 규칙을 걸었다
시도 3 | 멱등 키 + 인박스 + 아웃박스
3주차에 정착한 모델은 세 테이블과 한 가지 순서다.
단계
동작
실패 시
1. 수신
서명 검증 → webhook_inbox에 idempotency_key 유니크 INSERT
중복 키면 기존 상태 보고 200 (재처리 없음)
2. ACK
INSERT 성공 직후 200 반환 (부수 효과 전)
5xx면 파트너 재시도 허용(의도)
3. 처리
워커가 pending 행을 잠그고 주문 전이
재시도 가능 에러는 상태 유지, 독 메시지는 DLQ
4. 아웃박스
포인트·알림은 같은 DB 트랜잭션으로 outbox 행 기록 후 별도 발송
발송 실패해도 주문 커밋은 유지, 재발송만
idempotency_key는 파트너 event_id를 기본으로 쓰고, 없을 때만 provider + payment_id + type 해시를 썼다. "결제 1건 = 키 1개"가 아니라 "이벤트 타입 단위"로 잡아 부분 환불 이벤트가 막히지 않게 했다.
수신 핸들러 스케치 (서명 검증 후 인박스 INSERT)
// Express 스타일 스케치 — 실제 서명 검증·ORM은 팀 스택에 맞게
async function handlePaymentWebhook(req, res) {
if (!verifySignature(req)) return res.status(401).end()
const event = req.body
const key = event.id || `${event.provider}:${event.payment_id}:${event.type}`
try {
await db.query(
`INSERT INTO webhook_inbox
(idempotency_key, payload, status, received_at)
VALUES ($1, $2, 'pending', now())`,
[key, JSON.stringify(event)]
)
} catch (e) {
if (e.code === '23505') {
// 이미 수신한 이벤트 — 부수 효과 재실행 금지
return res.status(200).json({ ok: true, duplicate: true })
}
throw e
}
// 부수 효과 전에 ACK. 워커가 pending 을 소비한다.
return res.status(200).json({ ok: true })
}
워커 쪽은 SELECT … FOR UPDATE SKIP LOCKED로 행을 집고, 주문 상태 전이와 아웃박스 INSERT를 한 트랜잭션으로 묶었다. 포인트 HTTP 호출은 트랜잭션 밖에서 아웃박스 릴레이가 담당한다. DB 스키마를 바꿀 때 쓰는 expand-contract와 마찬가지로, 쓰기 경로를 둘로 나눠 각각을 안전하게 만드는 감각이다. 관련: 무중단 DB 스키마 마이그레이션 체크리스트.
관측은 webhook_duplicate_total, webhook_process_seconds, outbox_lag_seconds 세 지표를 기본으로 올렸다. 중복 카운터가 뛰는 것은 정상(파트너 재시도)일 수 있고, 처리 지연과 아웃박스 지연이 같이 오르면 워커 용량 문제다. 런타임 예외는 센트리로 묶되, "비즈니스 중복"과 "코드 예외" 알림 라우팅을 분리했다. 관련: Sentry 런타임 에러 모니터링 실전.
결과 | 도입 전후 숫자
스테이징에서 동일 이벤트 20회 연속 재시도와, 이후 운영 2주간 실제 파트너 재시도 로그를 기준으로 비교했다. 환경마다 절대값은 다르니 방향만 보면 된다.
지표
도입 전
도입 후
동일 event 부수 효과 실행 횟수
재시도 횟수와 거의 동일
1회 (중복 ACK만 증가)
웹훅 핸들러 p95
1.8~4.5초 (동기 체인)
80~150ms (INSERT+검증)
파트너 재시도로 인한 5xx 비율
피크 시 12%
수신 경로 <0.5%
포인트 중복 적립 사고
월 1~2건 수준(추정 포함)
0건 (2주 관측)
정산 대사 불일치 티켓
주 평균 3건
주 0~1건(다른 원인)
멱등 설계가 "재시도를 없앤" 것은 아니다. 파트너 재시도 로그는 여전히 남는다. 달라진 것은 재시도가 돈이 되는 사고로 바뀌지 않는다는 점이다. 중복 메트릭이 올라가도 CS가 뛰지 않으면 시스템은 의도대로 동작 중이다.
중간에 밟은 함정 5가지
1. 200을 너무 일찍, 저장 없이 — 메모리 큐만 믿고 ACK하면 프로세스 종료 시 이벤트가 사라진다. 유실은 중복보다 복구가 더 어렵다.
2. 유니크만 넣고 처리 중 락 없음 — 두 워커가 같은 pending 행을 집지 못하게 FOR UPDATE SKIP LOCKED 또는 동등한 리스 필드가 필요하다.
3. 모든 4xx/5xx를 동일 취급 — 서명 실패(401)에 200을 주면 공격 트래픽을 "성공"으로 학습시킨다. 반대로 우리 버그 5xx를 200으로 위장하면 파트너가 재시도를 멈춰 복구 기회를 잃는다.
4. 멱등 키 TTL 없음 — 인박스를 영구 보관하면 테이블이 비대해진다. 이 팀은 90일 후 콜드 스토리지로 옮기고, 키 재사용 위험이 없는 구간만 삭제했다.
5. 플래그 없이 전 트래픽 전환 — 첫 배포에서 워커 컨슈머 버그로 pending이 쌓였다. 테넌트 10%→50%→100% 플래그로 켠 뒤에야 안심했다. 관련: 기능 플래그 롤아웃 체크리스트.
중복 카운터와 아웃박스 지연을 한 화면에 두면 정상 재시도와 처리 적체를 구분하기 쉽다
어디에 같은 패턴을 쓸까
결제 웹훅만의 이야기가 아니다. 팀이 사내 가이드에 적어 둔 적용 범위다.
쓰기 좋은 곳 — 결제·구독·환불 콜백, 본인인증 결과, 문자·알림 전송 영수증, 스토어 인앱 결제, 외부 CRM 동기화 이벤트.
키를 조심할 곳 — 상태가 시간에 따라 바뀌는 스트림(배송 위치 갱신). 키에 버전·시퀀스를 넣거나 last-write 규칙을 명시한다.
과한 곳 — 순수 조회성 핑, 로그만 남기는 하트비트. 인박스 테이블 비용이 이득을 넘는다.
같이 볼 것 — 타임아웃·재시도 예산, 회로 차단기, 구조화 로그의 트레이스 ID, 정산 대사 배치.
"한 번만 실행"이 비즈니스 문장에 들어가면 거의 항상 멱등 키가 필요하다. UI 버튼 더블클릭, 모바일 오프라인 재전송, 메시지 큐 at-least-once도 같은 가족이다.
온콜 런북에 남은 확인 순서
중복 결제·중복 적립 의심 티켓이 올 때 보는 순서다.
카드사·결제 파트너 콘솔에서 실제 청구 횟수를 확인한다. 청구 1회·우리 부수 효과 N회면 멱등 버그 쪽이다.
webhook_inbox에서 해당 idempotency_key 행 수·status·received_at을 본다. 행이 여러 개면 키 설계 실패다.
아웃박스에 동일 비즈니스 키로 발송 성공이 여러 번인지 확인한다. 인박스는 1회인데 아웃박스만 N회면 릴레이 멱등 문제다.
최근 배포·플래그·워커 레플리카 수를 확인한다. pending 적체는 스케일·독 메시지 쪽을 의심한다.
고객 보정 전에 감사 로그에 "수동 조정" 사유를 남긴다. 나중에 정산 배치가 다시 덮어쓰지 않게 한다.
공지 문구도 "결제 장애" 대신 "적립 처리 지연, 카드 청구는 1회"처럼 범위를 적는다. CS 분류 시간이 줄고, 불필요한 환불 요청도 줄었다.
재시도·아웃박스·행 잠금 용어는 위 문서를 기준으로 했고, 본문 타임라인·수치는 익명 팀 포스트모템 재구성이다.
자주 묻는 질문
멱등 키와 유니크 인덱스는 같은 건가요?
유니크 인덱스는 저장 계층의 안전장치다. 멱등 키는 "이 비즈니스 동작을 한 번만"이라는 계약이고, 애플리케이션이 키를 언제 발급·검사·ACK하는지가 더 중요하다. 인덱스 없이 앱만 검사하면 레이스에 지고, 앱 없이 인덱스만 있으면 부수 효과 순서를 못 지킨다. 둘 다 필요하다.
웹훅 본문 해시로 키를 만들어도 되나요?
가능은 하다. 다만 필드 순서가 바뀌거나 서명 필드가 본문에 포함되면 해시가 달라져 중복 처리될 수 있다. 공급자가 안정적인 event id를 주면 그걸 우선하고, 없을 때만 정규화한 필드 부분집합으로 해시를 만드는 편이 안전하다.
ACK를 200으로 통일해야 하나요?
성공·중복 수신은 2xx로 통일하는 경우가 많다. 인증 실패·스키마 오류처럼 재시도해 봐야 소용없는 것은 4xx, 일시적 인프라 문제는 5xx로 나눠 파트너 재시도 정책을 존중한다. 문서에 코드별 의미를 적어 CS·파트너 지원과 공유하는 것이 좋다.
아웃박스 없이 워커에서 바로 외부 API를 호출하면 안 되나요?
작은 트래픽에선 당장 돌아간다. 다만 주문 커밋 후 프로세스 크래시가 나면 "주문은 paid, 포인트는 없음" 상태가 남는다. 아웃박스는 그 간격을 재시도 가능한 행으로 남긴다. 규모가 커질수록 가치가 커진다.
이미 중복 적립된 과거 데이터는 어떻게 하나요?
키 도입은 미래 유입만 막는다. 과거 건은 결제 원장과 포인트 원장을 조인해 초과분을 찾고, 고객 동의·약관에 맞는 회수·차감 절차를 탄다. 자동화 배치를 돌리기 전에 샘플 20건을 수동 검증하는 편이 사고 재발을 줄인다.
메시지 큐만 쓰면 멱등 테이블이 필요 없나요?
큐도 보통 at-least-once다. 소비자 쪽에서 비즈니스 키 멱등이 없으면 재전달 시 또 중복한다. 큐는 전달 도구이고, 한 번만 반영하는 계약은 여전히 수신 측 저장소에 있어야 한다.