20. 트랜잭션 재시도 범위 안의 비트랜잭션 부수효과 반복 실행 (이중 알림·이중 집계) (C#)
난이도 상내 리뷰 · 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 를 만든다.
해설 · C++
해설 — 트랜잭션 재시도 범위 안의 비트랜잭션 부수효과 반복 실행 (이중 알림·이중 집계) (C++)
난이도: 상
요약
purchasesToday_.fetch_add(1) 와 mail_.send(...) 가 재시도 for 루프 안, tx.commit() 이전에 있다. 이들은 DB 트랜잭션의 일부가 아니라 롤백되지 않는 부수효과다. commit()(또는 앞선 exec)이 DbSerializationError 를 던지면 트랜잭션은 취소되지만 이미 증가한 카운터·발송된 메일은 되돌려지지 않고, 재시도에서 다시 실행된다. 구매 1건에 매출이 여러 번 잡히고 알림이 여러 통 나가며, 커밋되지 않은 구매에 대한 phantom 알림까지 발생할 수 있다.
문제점
(A) 부수효과가 트랜잭션·재시도 경계 안에 있음 — 재시도 시 중복 실행
- 증상: 충돌이 잦은 구간에서 "구매 완료" 우편이 2~3통, 일일 매출 카운트가 실제보다 부풀려짐.
- 재현 조건: 첫 시도에서
fetch_add(1)+send(...)실행 후tx.commit()이 직렬화 실패로 throw →catch→ 재시도 → 부수효과 재실행. - 근본 원인: 롤백되는 DB 상태와 롤백되지 않는 외부 부수효과를 같은 재시도 스코프에 섞었다. 부수효과는 커밋 확정 후 "정확히 한 번" 실행되어야 하는데 그 순서·멱등성이 없다.
(B) 재시도가 부수효과를 보상/멱등화하지 않음
- 증상:
catch는 재시도만 하고 이전 시도의 부수효과를 취소·억제하지 않는다. - 근본 원인: 재시도 블록이 "트랜잭션만"이 아니라 "부수효과 포함 전체"를 재실행하며, 부수효과에 멱등 키가 없다.
수정안
부수효과를 커밋 성공 이후로 옮기고 재시도 대상에서 제외한다. 커밋과 원자적이어야 하는 알림은 아웃박스로 남긴다.
void purchase(int userId, int itemId, int64_t price) {
const int maxRetries = 3;
bool committed = false;
for (int attempt = 1; attempt <= maxRetries && !committed; ++attempt) {
try {
DbTransaction tx = db_.begin();
tx.exec("UPDATE wallet SET gold = gold - " + std::to_string(price) +
" WHERE user_id=" + std::to_string(userId) + " AND gold>=" + std::to_string(price));
tx.exec("INSERT INTO inventory(user_id,item_id,qty) VALUES(" +
std::to_string(userId) + "," + std::to_string(itemId) + ",1)");
// 부수효과를 커밋과 원자화해야 하면 아웃박스 행으로:
tx.exec("INSERT INTO outbox(kind,user_id,dedup_key) VALUES('purchase_mail'," +
std::to_string(userId) + ",'" + dedupKey(userId, itemId) + "')");
tx.commit();
committed = true; // 커밋 성공만 표시, 부수효과는 아직 안 함
} catch (const DbSerializationError&) {
if (attempt == maxRetries) throw;
// 트랜잭션만 재시도 — 부수효과를 실행하지 않았으므로 되돌릴 것도 없다
}
}
// 커밋 확정 후 '정확히 한 번' 경로
purchasesToday_.fetch_add(1, std::memory_order_relaxed);
mail_.send(userId, "구매 완료"); // 이상적으로는 아웃박스 디스패처가 dedup_key로 멱등 발송
}
- 핵심: 부수효과는 커밋이 성공한 뒤 루프 밖에서 한 번만. 충돌 재시도가 몇 번이든 카운터·메일은 커밋 확정당 1회.
- 외부로 나가는 효과가 exactly-once 여야 하면 커밋과 원자적으로
outbox를 남기고 디스패처가dedup_key로 멱등 발송한다.
더 나은 설계
- 아웃박스 패턴: 부수효과 의도를 트랜잭션 안 outbox 에 기록 → 커밋과 원자적. 커밋 후 디스패처가 멱등 실행. 유실·중복을 함께 해결.
- RAII 롤백 가드:
DbTransaction소멸자가 미커밋 시 자동rollback()하도록 하면 예외 경로 누수를 막는다. 단 이는 DB 상태에만 유효하고 외부 부수효과에는 무력 — 그래서 부수효과 분리가 필수. - 멱등 키:
(userId,itemId,purchaseId)로 발송·집계 중복 제거. - 트레이드오프: 커밋 후 실행은 단순하나 "커밋 직후 크래시"면 알림 누락(at-most-once). 아웃박스는 이를 막지만 인프라 추가.
면접 포인트
- 재시도 블록은 멱등해야 안전하다 — 롤백되는 트랜잭션 상태와 롤백 안 되는 부수효과(네트워크 발송·인메모리 카운터)를 분리하는 것이 원칙.
- C++ 관점:
std::atomic::fetch_add는 스레드 안전하지만 "재시도로 두 번 부르면 두 번 증가"한다 — 원자성과 멱등성은 다르다. - dual-write(커밋 성공/이벤트 실패)와 대비되는 반대 실패(재시도 과다 실행)이며, 공통 해법은 아웃박스 + 멱등.