결제 취소 누락과 데이터 중복 적재처럼 "돈과 데이터가 한 건 더 많거나 적은" 장애를, 원인 파악 → 조치 → 검증 → 재발 방지의 같은 4단계 루프로 다루는 운영 감각을 정리한다. 이 두 장애는 표면 증상이 정반대로 보이지만(하나는 일어나야 할 일이 안 일어났고, 하나는 일어나지 말아야 할 일이 두 번 일어났다) 뿌리는 같다. 분산 시스템에서 부수효과를...
결제 취소 누락과 데이터 중복 적재처럼 "돈과 데이터가 한 건 더 많거나 적은" 장애를, 원인 파악 → 조치 → 검증 → 재발 방지의 같은 4단계 루프로 다루는 운영 감각을 정리한다.
이 두 장애는 표면 증상이 정반대로 보이지만(하나는 일어나야 할 일이 안 일어났고, 하나는 일어나지 말아야 할 일이 두 번 일어났다) 뿌리는 같다. 분산 시스템에서 부수효과를 정확히 한 번(exactly-once) 일으키지 못한 결과다. 그래서 대응 도구도 거의 겹친다.
이 문서는 개념 자체보다 "실제로 터졌을 때 무엇을 어떤 순서로 하는가"에 집중한다. 멱등성·상태기계 개념은 결제 도메인 멱등성과 트랜잭션 재시도 기본기, 정산·대사 운영은 F&B 이커머스 결제·환불·정산 운영 가이드, 장애 대응 의사결정 언어는 SLO와 Error Budget 기반 장애 대응을 함께 본다.
두 장애 모두 외부 또는 내부 호출 사슬 어딘가에서 **"성공했는지 모르겠다"**는 모호한 상태가 생겼을 때 시작된다.
여기서 분기한다.
즉 같은 모호함이 한쪽에선 중복으로, 다른 쪽에선 누락으로 나타난다. 대응의 핵심은 모호한 상태를 결정 가능한 상태로 바꾸는 것이다.
"취소·환불 요청은 들어왔는데 실제 PG 취소가 안 된" 상태다. 원인은 보통 다음 중 하나다.
CANCELED로 바꿨지만, PG 취소 API 호출이 타임아웃 후 재시도 큐에 안 들어감.OrderCanceled 이벤트는 발행됐지만 결제 컨슈머가 그 메시지를 유실(at-least-once 미보장).장애를 인지하면 먼저 범위와 진행 여부를 고정한다.
-- 우리 DB는 취소인데 PG 취소 기록이 없는 의심 건
SELECT p.payment_id, p.order_id, p.status, p.pg_tx_id, p.updated_at
FROM payments p
WHERE p.status = 'CANCELED'
AND p.pg_cancel_tx_id IS NULL
AND p.updated_at >= '2026-06-12 00:00:00'
ORDER BY p.updated_at;그다음 의심 건의 pg_tx_id로 PG 대사 파일(또는 PG 관리자 API)을 조회해 실제 PG 측 상태를 확인한다.
여기서 세 부류로 갈린다.
로그는 traceId 기준으로 묶어 "취소 API를 호출한 적이 있는가, 응답이 무엇이었나"를 본다.
조치의 제1원칙은 멱등하게, PG를 진실원으로 보정하는 것이다.
보정 절차
1. 의심 건 목록을 동결(freeze)하고 사람이 검토할 수 있게 export
2. 각 건마다 PG 실제 상태 재조회 (자동 추정 금지)
3. PG에 승인만 있으면 → 멱등키를 붙여 PG 취소 API 재호출
4. PG 취소 성공 응답을 받은 뒤에만 our DB의 pg_cancel_tx_id 기록
5. 이미 PG에 취소가 있으면 → DB 기록만 동기화 (재취소 호출 금지)핵심은 4번이다. DB를 먼저 바꾸고 PG를 부르면 또 다른 누락을 만든다. 부수효과(PG 취소) 성공을 확인한 뒤 그 결과를 기록하는 순서를 지킨다.
대량 보정이면 한 번에 다 돌리지 말고 작은 배치로 나눠 각 배치 결과를 검증하며 진행한다.
payments를 대조해 취소 금액 합이 일치하는지(대사) 확인.status='CANCELED' AND pg_cancel_tx_id IS NULL이 일정 시간 이상 남으면 알람.같은 데이터가 두 번 이상 저장된 상태다. 대표 원인:
POST가 두 번 들어와 row 두 개 생성.먼저 **중복의 정의(자연키)**를 정한다. "무엇이 같으면 같은 데이터인가"를 비즈니스 키로 고정해야 중복을 셀 수 있다.
-- order_id + event_seq가 자연키라고 가정한 중복 탐지
SELECT order_id, event_seq, COUNT(*) AS cnt, MIN(id) AS keep_id
FROM order_events
GROUP BY order_id, event_seq
HAVING COUNT(*) > 1
ORDER BY cnt DESC;그다음 중복이 언제 들어왔는지(created_at 분포)를 보면 원인 구간이 좁혀진다.
소비 경로라면 consumer 로그에서 rebalance/재시작 타임스탬프를 중복 created_at과 겹쳐 본다.
조치 절차
1. 중복 유입을 먼저 멈춘다 (원인 경로 차단: 잡 중지, 컨슈머 일시 정지)
2. 자연키별로 남길 1건(keep_id) 규칙을 정한다 (보통 가장 이른 id 또는 가장 완전한 row)
3. 중복 row가 만든 2차 효과(집계 합산, 잔액, 카운트)를 먼저 역산/보정
4. 중복 row 삭제 또는 soft-delete
5. 다시 켜기 전에 멱등 장치(아래 3-5)를 먼저 넣는다3번을 건너뛰면 안 된다.
중복 row만 지우고 이미 그 row로 더해진 합계·잔액을 안 고치면 데이터는 깨끗해 보여도 숫자가 틀린다.
삭제는 되돌리기 어렵다 — 운영 DB에서는 먼저 dup_backup 테이블로 복사한 뒤 삭제하는 것을 기본으로 한다.
INSERT ... ON DUPLICATE KEY UPDATE 또는 INSERT ... ON CONFLICT DO NOTHING).두 장애를 한 번에 줄이는 토대는 결국 같은 네 가지다.
여기에 운영 측면에서 다음을 더한다.
스스로 답해보며 빈 곳을 찾는다.
INSERT ... ON CONFLICT DO NOTHING과 단순 INSERT의 동시 실행 결과 차이를 직접 확인한다.