← 문제로

3. 우편 대량 발송 재시도 시 중복 지급 (멱등성 단위 오류)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 우편 대량 발송 재시도 시 중복 지급 (멱등성 단위 오류)

난이도: 상

요약

멱등성 마커가 배치 단위(D MarkBatchSent)이고 마지막에 한 번 찍힌다. 600번째에서 예외가 나면 MarkBatchSent에 도달하지 못해 배치는 "미발송"으로 남고, 재시도 시 (A) 검사를 통과해 1~599번째 수신자에게 보상을 또 지급한다. 게다가 (B)지급과 (C)기록이 한 트랜잭션이 아니라, 둘 사이 크래시 시 "지급은 됐는데 기록은 없는" 상태도 생긴다.

문제점

1. (멱등성 단위) 마커가 배치 단위 + 끝에 위치 — (A)(D)

  • 증상: 재시도하면 앞쪽 599명이 보상을 두 번(또는 그 이상) 받는다.
  • 재현조건: 1000명 발송 중 600번째에서 InsertMail/Grant가 던짐 → MarkBatchSent 미실행 → 재시도 → IsBatchSent==false → 1번부터 다시 지급.
  • 근본원인: 멱등성의 단위가 "수신자"가 아니라 "배치"다. 부분 성공을 표현할 수 없어, 진행한 곳까지를 기억하지 못한다.

2. (원자성) 지급과 기록이 분리됨 — (B)(C)

  • 증상: (B) Grant 후 (C) InsertMail 전에 죽으면, 인벤토리엔 들어갔는데 우편 기록은 없다(추적·정산 불가). 반대 순서면 기록만 있고 미지급.
  • 근본원인: 한 수신자에 대한 "지급 + 기록 + 멱등 마커"가 하나의 원자 단위(트랜잭션)가 아니다.

3. (재시도 안전성) 부분 실패가 처음부터 재시작

  • 수신자별 진행 상태가 없으니 재시도는 항상 처음부터. 멱등 단위가 수신자가 아니면 이 재시작이 곧 중복 지급이다.

수정안

핵심은 멱등성 단위를 (batchId, recipientId)로 내리고, "마커를 먼저(원자적으로) 차지한 뒤에만 지급" 하는 것이다.

-- 수신자별 멱등 키에 유니크 제약
CREATE TABLE mail_grant (
  batch_id     BIGINT NOT NULL,
  recipient_id BIGINT NOT NULL,
  PRIMARY KEY (batch_id, recipient_id)
);
public void SendBatch(long batchId, List<long> recipients, RewardSpec reward)
{
    foreach (var r in recipients)
    {
        using var tx = _db.BeginTransaction();
        // (1) 멱등 마커를 먼저 '차지'. 이미 있으면 0행 → 이번 수신자는 건너뜀.
        bool claimed = _db.TryClaimGrant(tx, batchId, r);   // INSERT ... ON CONFLICT DO NOTHING
        if (!claimed) { tx.Commit(); continue; }

        // (2) 같은 트랜잭션 안에서 지급 + 우편 기록
        _inv.Grant(tx, r, reward);
        _db.InsertMail(tx, r, batchId, reward);
        tx.Commit();                                        // 마커+지급+기록이 함께 커밋
    }
}
  • TryClaimGrantINSERT ... ON CONFLICT (batch_id, recipient_id) DO NOTHING으로 구현 — 동시/재시도 모두 한 수신자당 정확히 한 번만 "차지"된다.
  • 마커·지급·기록이 한 트랜잭션이라 중간 크래시 시 전부 롤백되어, 재시도하면 다시 깨끗하게 차지·지급한다(정확히 한 번).
  • 인벤토리 지급이 별도 서비스라 같은 DB 트랜잭션에 못 묶이면, 아웃박스(outbox) 패턴으로 "지급 요청"을 같은 트랜잭션에 기록하고 비동기로 멱등 처리한다.

더 나은 설계

  • 수신자별 멱등 키 + 유니크 제약이 단일 진실원천(중복은 DB가 거부).
  • 트랜잭셔널 아웃박스: 인벤토리/외부 시스템 호출은 같은 트랜잭션에 못 들어가므로, 커밋과 함께 아웃박스에 이벤트를 남기고 디스패처가 at-least-once + 멱등 소비로 실제 지급.
  • 재개 가능한 배치: 진행 커서를 두면 재시도가 끝난 지점부터 이어간다(중복 지급 없이 빠른 재개).
  • 트레이드오프: 수신자별 트랜잭션은 대량 발송 시 커밋 횟수가 늘어 처리량이 떨어진다 → 배치 크기(예: 100명씩 한 트랜잭션)로 묶되, 멱등 단위는 여전히 수신자로 유지.

면접 포인트

  1. 멱등성의 단위와 위치가 핵심 — 배치 단위/마지막 커밋 마커는 부분 실패를 못 막는다. 단위는 가장 작은 "정확히 한 번" 대상(수신자).
  2. "마커를 먼저 차지하고 그 트랜잭션 안에서 효과를 낸다" — INSERT ON CONFLICT로 claim. 효과 후에 마커를 찍으면 그 사이 크래시가 중복을 만든다.
  3. 외부 시스템 지급은 한 트랜잭션에 못 묶이므로 아웃박스 + 멱등 소비로 at-least-once를 정확히 한 번처럼.