← 문제로

20. 트랜잭션 재시도 범위 안의 비트랜잭션 부수효과 반복 실행 (이중 알림·이중 집계) (C#)

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

해설 — 트랜잭션 재시도 범위 안의 비트랜잭션 부수효과 반복 실행 (이중 알림·이중 집계) (C#)

난이도: 상

요약

매출 카운터 증가와 알림 우편 발송이 재시도 루프 안, 그리고 Commit() 이전에 놓여 있다. 이들은 DB 트랜잭션의 일부가 아니라 롤백되지 않는 부수효과다. 동시성 충돌로 Commit()(또는 그 앞 Execute)이 DbSerializationException 을 던지면 트랜잭션은 취소되지만 이미 실행된 카운터 증가·메일 발송은 되돌려지지 않은 채 재시도에서 다시 실행된다. 결과적으로 구매 1건에 매출이 여러 번 집계되고 알림이 여러 통 나가며, 심하면 커밋되지 않은(존재하지 않는) 구매에 대한 phantom 알림까지 발생한다.

문제점

(A) 부수효과가 트랜잭션 경계·재시도 경계 안에 있음 — 재시도 시 중복 실행

  • 증상: 동시성 충돌이 잦은 구간에서 "구매 완료" 우편이 2~3통 오고, 일일 매출 카운트가 실제 판매보다 부풀려진다.
  • 재현 조건: 첫 시도에서 _metrics.PurchasesToday++_mail.Send(...) 가 실행된 뒤 tx.Commit() 이 직렬화 실패로 던진다 → catch → 재시도 → 부수효과 재실행. N번 충돌하면 N번 중복.
  • 근본 원인: DB 로 롤백되는 상태(재화·인벤토리)와, 롤백되지 않는 외부 부수효과(카운터·메일)를 같은 재시도 스코프에 섞었다. 부수효과는 "커밋이 확정된 뒤 정확히 한 번" 실행되어야 하는데 그 순서·멱등성이 보장되지 않는다.

(B) 재시도가 부수효과를 보상/멱등화하지 않음

  • 증상: catch 는 단지 다시 시도할 뿐, 이전 시도가 남긴 부수효과를 취소하거나 중복을 억제하지 않는다.
  • 근본 원인: 재시도 로직이 "트랜잭션만 재시도"가 아니라 "부수효과 포함 블록 전체를 재실행"하도록 작성됐고, 부수효과에 멱등 키가 없다.

수정안

부수효과를 커밋 성공 이후로 이동하고, 재시도 대상에서 제외한다. 알림·집계는 멱등하게(또는 아웃박스로) 처리한다.

public void Purchase(int userId, int itemId, long price)
{
    const int maxRetries = 3;
    for (int attempt = 1; attempt <= maxRetries; attempt++)
    {
        try
        {
            using var tx = _db.BeginTransaction();
            _db.Execute(tx, "UPDATE wallet SET gold = gold - @p WHERE user_id=@u AND gold>=@p", price, userId);
            _db.Execute(tx, "INSERT INTO inventory(user_id, item_id, qty) VALUES(@u,@i,1)", userId, itemId);
            // 부수효과를 트랜잭션 안에 남겨야 한다면 '아웃박스' 행으로 기록(커밋과 원자적):
            _db.Execute(tx, "INSERT INTO outbox(kind, user_id, dedup_key) VALUES('purchase_mail',@u,@k)", userId, DedupKey(userId, itemId));
            tx.Commit();
        }
        catch (DbSerializationException)
        {
            if (attempt == maxRetries) throw;
            continue; // 트랜잭션만 재시도 — 부수효과는 아직 실행하지 않았다
        }

        // 커밋이 확정된 뒤 '정확히 한 번' 경로. 멱등하게 처리.
        _metrics.PurchasesToday++;        // 커밋 성공당 1회만 도달
        _mail.Send(userId, "구매 완료");  // 이상적으로는 아웃박스 디스패처가 dedup_key로 멱등 발송
        return;
    }
}
  • 핵심: 부수효과는 Commit() 이 성공한 뒤, 재시도 루프 밖(또는 성공 분기)에서 한 번만 실행. 이러면 충돌로 몇 번 재시도해도 카운터·메일은 커밋 확정당 1회다.
  • 알림처럼 외부로 나가는 효과가 정확히 한 번이어야 하면, 커밋과 원자적으로 outbox 행을 남기고 별도 디스패처가 dedup_key 로 멱등 발송한다(프로세스 크래시에도 유실/중복 없음).

더 나은 설계

  • 아웃박스 패턴: 부수효과 의도를 트랜잭션 안에서 outbox 테이블에 기록 → 커밋과 원자적. 커밋 후 디스패처가 읽어 멱등 실행. "커밋됐지만 알림 유실"과 "재시도로 중복 알림"을 동시에 해결.
  • 멱등 키: 메일·집계에 (userId, itemId, purchaseId) 같은 키를 부여해 수신 측/발송 측에서 중복 제거.
  • 재시도는 순수 함수에만: 재시도로 감싸는 블록은 부작용이 없어야(idempotent) 안전하다. 외부 효과가 있으면 재시도 밖으로 빼거나 보상 로직을 둔다.
  • 트레이드오프: 커밋 후 실행은 단순하지만 "커밋 성공 직후 프로세스 크래시"면 알림이 누락될 수 있다(at-most-once). 아웃박스는 그것까지 막지만 인프라(테이블·디스패처·중복 제거)가 추가된다.

면접 포인트

  • 재시도(retry)의 전제: 재시도 대상 블록은 멱등해야 한다. 롤백되는 DB 상태와 롤백 안 되는 외부 효과를 한 스코프에 섞으면 재시도가 곧 중복 실행이 된다.
  • dual-write(커밋 성공·이벤트 발행 실패로 인한 불일치)와의 차이: 여기서는 반대로 재시도로 부수효과가 과다 실행되는 문제. 둘 다 근본 해법은 아웃박스/멱등.
  • "정확히 한 번"은 대개 불가능하며, 멱등 처리 + at-least-once 전달로 실효적 exactly-once 를 만든다.