← 문제로

28. 일괄 지급의 all-or-nothing 트랜잭션 (한 건 중복이 전체를 롤백)

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

해설 — 일괄 지급의 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 트랜잭션 분리의 트레이드오프(락 보유 시간, 성능).