28. 일괄 지급의 all-or-nothing 트랜잭션 (한 건 중복이 전체를 롤백)
난이도 하해설 — 일괄 지급의 all-or-nothing 트랜잭션 (한 건 중복이 전체를 롤백)
난이도: 하
요약
GrantToAll이 명단 전체를 하나의 트랜잭션으로 묶어 순차 Insert 한다 (A). 명단에 이미 받은 유저가 한 명이라도 있으면 그 Insert에서 UniqueViolationException이 던져지고, (B)의 Commit에 도달하지 못한다. 예외로 메서드를 빠져나가면 트랜잭션이 커밋되지 않아(대개 롤백) 그 전에 성공적으로 넣은 정상 유저들의 보상까지 전부 사라진다. 요구사항은 "각 플레이어 독립 지급 + 중복은 건너뛰기"인데, 코드는 "전원 성공 아니면 전원 실패"로 동작한다. 게다가 예외 경로에 Rollback이 없어 트랜잭션/커넥션이 열린 채 샐 수 있다.
문제점
- 분류태그: 트랜잭션 경계 오설계(all-or-nothing) + 중복 예외 미처리 + 롤백 누락
- 증상: 이미 받은 유저가 섞이면 나머지 유저도 미지급. 재시도해도 같은 중복에서 또 실패 → 영구 미지급. 예외 시 커넥션 누수.
- 재현조건: 명단에 기존 수령자 1명 이상 포함(재시도된 잡, 중복 명단 등).
- 근본원인: 서로 독립적이어야 할 지급을 하나의 원자 단위로 묶었고, "정상 중복(이미 지급됨)"을 오류로 취급해 배치를 중단시킨다.
수정안
지급을 플레이어 단위로 독립화하고, 중복은 정상 흐름으로 흡수한다(멱등 삽입 또는 건별 예외 처리).
// 방법 1) 멱등 삽입: 중복이면 조용히 무시 (권장)
public void GrantToAll(int[] playerIds, int itemId, int eventId)
{
foreach (var pid in playerIds)
{
_db.Begin();
try
{
_db.InsertIfNotExists(pid, itemId, eventId); // ON CONFLICT DO NOTHING
_db.Commit();
}
catch
{
_db.Rollback(); // 해당 유저만 실패 처리, 배치는 계속
throw; // 또는 로깅 후 continue (정책에 따라)
}
}
}
한 트랜잭션 안에서 처리하고 싶다면 세이브포인트를 건별로 잡아, 한 행 실패가 배치 전체가 아니라 그 행만 되돌리게 한다:
BEGIN;
SAVEPOINT sp;
INSERT ... ; -- 실패 시
ROLLBACK TO SAVEPOINT sp; -- 그 행만 취소, 나머지는 유지
-- 성공 시 RELEASE SAVEPOINT sp;
COMMIT;
더 나은 설계
- 멱등 지급:
INSERT ... ON CONFLICT (player_id,item_id,event_id) DO NOTHING. 잡 재시도·중복 명단에 강건. - 트랜잭션 립 방지: 트랜잭션은
using/RAII로 감싸 예외에도 반드시 정리. - 배치 정책 명시: "부분 성공 허용" vs "전원 성공 필요"를 요구사항으로 못박고 그에 맞는 트랜잭션 경계 선택. 보상 지급은 보통 부분 성공 허용 + 멱등.
- 대량이면 실패 건만 모아 별도 리포트/재처리 큐로.
면접 포인트
- 트랜잭션 원자성 경계를 "논리적 작업 단위"에 맞추기 — 독립 작업을 하나로 묶으면 왜 위험한가.
- "정상적인 중복"(이미 처리됨)을 오류로 취급하지 않기 — 멱등 설계.
- 세이브포인트 vs 트랜잭션 분리의 트레이드오프(락 보유 시간, 성능).
해설 — 일괄 지급 all-or-nothing + 예외 시 트랜잭션 누수 (C++)
난이도: 하
요약
C# 트윈과 동일한 논리 결함: 명단 전체를 하나의 트랜잭션으로 묶어 (A)에서 순차 Insert하므로, 한 건이라도 UniqueViolation을 던지면 (B)의 Commit에 못 가고 정상 유저 지급까지 전부 무효화된다. C++에서는 추가로 예외가 스택을 되감을 때 db_.Rollback()이 호출되지 않아 트랜잭션이 열린 채로 남는다(커넥션/락 누수). RAII로 트랜잭션 정리를 자동화하지 않은 것이 원인.
문제점
- 분류태그: 트랜잭션 경계 오설계(all-or-nothing) + 중복 예외 미처리 + RAII 부재로 인한 트랜잭션 누수
- 증상: 기존 수령자 1명이 배치 전체 실패를 유발, 재시도해도 반복 실패, 예외 시 트랜잭션이 정리되지 않아 커넥션/락 누수·풀 고갈.
- 재현조건: 명단에 중복 유저 포함 +
Insert예외. - 근본원인: 독립 작업을 하나의 원자 단위로 묶음, 정상 중복을 오류로 취급, 예외 안전(트랜잭션 정리) 미보장.
수정안
(1) 지급을 건별 독립 트랜잭션 + 멱등 삽입으로, (2) 트랜잭션을 RAII 가드로 감싸 예외에도 반드시 정리.
struct TxGuard {
IDb& db; bool committed = false;
explicit TxGuard(IDb& d) : db(d) { db.Begin(); }
void Commit() { db.Commit(); committed = true; }
~TxGuard() { if (!committed) db.Rollback(); } // 예외에도 안전
};
void GrantToAll(const std::vector<int>& playerIds, int itemId, int eventId) {
for (int pid : playerIds) {
TxGuard tx(db_);
try {
db_.InsertIfNotExists(pid, itemId, eventId); // ON CONFLICT DO NOTHING
tx.Commit();
} catch (const std::exception&) {
// tx 소멸자가 Rollback. 이 유저만 실패, 배치는 계속.
}
}
}
한 트랜잭션 유지가 필요하면 세이브포인트를 건별로(성공 시 RELEASE, 실패 시 ROLLBACK TO). RAII 가드 스니펫은 g++ -std=c++17 -fsyntax-only로 검증.
더 나은 설계
- 멱등 삽입(
ON CONFLICT DO NOTHING)으로 재시도·중복 명단에 강건. - 예외 안전은 RAII로: 트랜잭션/락/커넥션은 스코프 가드로 자동 정리(basic exception safety 이상).
- 부분 성공 허용 여부를 요구사항으로 명시, 실패 건은 재처리 큐로.
면접 포인트
- C++ 예외 안전성과 트랜잭션 수명 관리 — 왜 RAII 가드가 필수인가(예외 경로 롤백 보장).
- 트랜잭션 원자성 경계를 논리 작업 단위에 맞추기, 정상 중복을 오류로 취급하지 않기(멱등).
- 세이브포인트 vs 트랜잭션 분리 트레이드오프.