3. 우편 대량 발송 재시도 시 중복 지급 (멱등성 단위 오류)
난이도 상내 리뷰 · C#
내 리뷰 · 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(); // 마커+지급+기록이 함께 커밋
}
}
TryClaimGrant는INSERT ... ON CONFLICT (batch_id, recipient_id) DO NOTHING으로 구현 — 동시/재시도 모두 한 수신자당 정확히 한 번만 "차지"된다.- 마커·지급·기록이 한 트랜잭션이라 중간 크래시 시 전부 롤백되어, 재시도하면 다시 깨끗하게 차지·지급한다(정확히 한 번).
- 인벤토리 지급이 별도 서비스라 같은 DB 트랜잭션에 못 묶이면, 아웃박스(outbox) 패턴으로 "지급 요청"을 같은 트랜잭션에 기록하고 비동기로 멱등 처리한다.
더 나은 설계
- 수신자별 멱등 키 + 유니크 제약이 단일 진실원천(중복은 DB가 거부).
- 트랜잭셔널 아웃박스: 인벤토리/외부 시스템 호출은 같은 트랜잭션에 못 들어가므로, 커밋과 함께 아웃박스에 이벤트를 남기고 디스패처가 at-least-once + 멱등 소비로 실제 지급.
- 재개 가능한 배치: 진행 커서를 두면 재시도가 끝난 지점부터 이어간다(중복 지급 없이 빠른 재개).
- 트레이드오프: 수신자별 트랜잭션은 대량 발송 시 커밋 횟수가 늘어 처리량이 떨어진다 → 배치 크기(예: 100명씩 한 트랜잭션)로 묶되, 멱등 단위는 여전히 수신자로 유지.
면접 포인트
- 멱등성의 단위와 위치가 핵심 — 배치 단위/마지막 커밋 마커는 부분 실패를 못 막는다. 단위는 가장 작은 "정확히 한 번" 대상(수신자).
- "마커를 먼저 차지하고 그 트랜잭션 안에서 효과를 낸다" —
INSERT ON CONFLICT로 claim. 효과 후에 마커를 찍으면 그 사이 크래시가 중복을 만든다. - 외부 시스템 지급은 한 트랜잭션에 못 묶이므로 아웃박스 + 멱등 소비로 at-least-once를 정확히 한 번처럼.
해설 · C++
해설 — 우편 대량 발송 재시도 시 중복 지급 (멱등 마커가 메모리·배치 단위) · C++
난이도: 상
요약
멱등 마커가 프로세스 메모리(_sentBatches)에 + 배치 단위로 + 루프 끝에(D) 있다. 600번째에서 예외가 나면 마커를 못 찍어 재시도 시 앞쪽이 다시 지급되고, 프로세스가 재기동되면 _sentBatches가 통째로 사라져 이미 완료된 배치마저 전부 재지급된다. 지급(B)과 기록(C)도 원자 단위가 아니다.
문제점
1. (영속성) 멱등 마커가 메모리에만 존재 — (A)(D)
- 증상: 프로세스 재기동 후 같은 배치를 재발송하면 모든 수신자에게 다시 지급된다.
- 재현조건: 발송 완료(
_sentBatches에 batchId 있음) 후 서버 재시작 → 집합이 비어count==0→ 전체 재지급. - 근본원인: 멱등성은 재기동을 견디는 영속 저장소(DB) 에 있어야 한다. 휘발성 메모리 마커는 크래시/배포에 무력하다.
2. (멱등성 단위) 마커가 배치 단위 + 끝에 위치 — (A)(D)
- 600번째 예외 시
_sentBatches.insert(D)에 도달 못 함 → 재시도에서 1~599번째가 또 지급. 멱등 단위가 "수신자"가 아니라 "배치"라 부분 성공을 표현 못 한다.
3. (원자성) 지급과 기록 분리 — (B)(C)
- (B)
grant후 (C)insertMail전에 예외/크래시 → 지급은 됐는데 기록은 없음(추적·정산 불가). 한 수신자에 대한 지급+기록+마커가 하나의 트랜잭션이 아니다.
4. (예외 안전성)
- 루프 중간 예외가 그대로 전파되어 부분 상태를 남긴다. 어디까지 했는지 영속적으로 모른다.
수정안
핵심은 멱등 마커를 DB의 (batchId, recipientId) 유니크 키로 영속화하고, "마커를 먼저 차지한 트랜잭션 안에서만 지급" 하는 것이다.
CREATE TABLE mail_grant (
batch_id BIGINT, recipient_id BIGINT,
PRIMARY KEY (batch_id, recipient_id)
);
void sendBatch(long batchId, const std::vector<long>& recipients, const RewardSpec& reward) {
for (long r : recipients) {
Tx tx = _db.begin();
// (1) 멱등 마커를 먼저 영속적으로 '차지'. 이미 있으면 false → 건너뜀.
if (!_db.tryClaimGrant(tx, batchId, r)) { // INSERT ... ON CONFLICT DO NOTHING
tx.commit();
continue;
}
// (2) 같은 트랜잭션에서 지급 + 기록
_inv.grant(tx, r, reward); // 외부 시스템이면 아웃박스로
_db.insertMail(tx, r, batchId, reward);
tx.commit(); // 마커+지급+기록 함께 커밋
}
}
- DB 유니크 제약이 동시·재시도·재기동을 가로질러 "수신자당 한 번"을 강제한다.
- 마커·효과가 한 트랜잭션이라 중간 크래시면 전부 롤백 → 재시도가 깨끗이 재차지·재지급(정확히 한 번).
- 인벤토리가 별도 서비스라 같은 트랜잭션에 못 묶이면 트랜잭셔널 아웃박스로 "지급 이벤트"를 같은 커밋에 남기고, 소비자가 멱등하게 실제 지급.
더 나은 설계
- 영속 멱등 키 + 유니크 제약이 단일 진실원천. 메모리 캐시는 최적화일 뿐 권위가 아니다.
- 트랜잭셔널 아웃박스 + at-least-once 멱등 소비로 외부 지급을 정확히 한 번처럼.
- 재개 커서: 진행 위치를 영속화해 재시도가 끝난 지점부터 이어가게(중복·재스캔 최소화).
- 트레이드오프: 수신자별 트랜잭션은 커밋 비용↑ → N명씩 묶어 커밋하되 멱등 단위는 수신자 유지. 메모리 마커로 빠른 경로를 두되, 진실은 DB.
면접 포인트
- 멱등 상태는 영속 저장소에 — 메모리 마커는 재기동/배포에 증발해 전량 재지급을 부른다.
- 멱등 단위는 가장 작은 "정확히 한 번" 대상(수신자), 그리고 효과 전에 마커를 원자적으로 차지(
INSERT ON CONFLICT). - 외부 시스템 지급은 DB 트랜잭션에 못 묶이므로 아웃박스 패턴 — 커밋과 이벤트를 한 원자 단위로.