← 문제로

2. 보상 지급을 여러 독립 커밋으로 쪼갠 부분 커밋/이중 지급

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

해설 — 보상 지급을 여러 독립 커밋으로 쪼갠 부분 커밋/이중 지급

난이도: 상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

한 논리적 작업(아이템+골드+로그)을 세 개의 독립 커밋으로 쪼갰고, 멱등성 게이트를 맨 마지막에 둔다. (B)(C)(D) 가 각각 autocommit 이라 사이에 예외/크래시가 나면 부분 커밋이 남는다: 아이템만 들어가고 골드는 안 들어가거나, 둘 다 들어갔는데 로그(D)가 없거나. (A) 멱등성 검사는 quest_reward_log 를 보는데 그 로그는 가장 마지막(D)에 쓰인다. 그래서 (B)/(C)까지만 하고 죽으면, 재시도 시 (A)가 "아직 안 받음"으로 통과 → 아이템·골드를 다시 지급(이중 지급). 정답 한 줄: 세 작업을 한 트랜잭션으로 묶어 all-or-nothing 으로 만들고, 멱등성 키 (quest_reward_log UNIQUE)를 같은 트랜잭션 안에서 — 가능하면 먼저 — 기록하라.

변별: persistence_data/problem1(read-your-writes/복제 지연)과 concurrency_memory/problem13 (캐시-DB 이중 쓰기)과 달리, 본 문제는 단일 DB 안에서의 트랜잭션 원자성/멱등성 경계 — 다중 커밋으로 인한 부분 반영과 dedup 마커 배치 순서가 핵심이다.


문제점

(B)(C)(D) 다중 autocommit — 부분 커밋 (영속화/원자성) ★간판

  • 분류 태그: non-atomic multi-statement / partial commit.
  • 증상: (B) 성공 후 (C)에서 예외/크래시 → 아이템은 영구히 남고 골드는 없음. (C)까지 성공하고 (D) 실패 → 보상은 다 줬는데 "받았다는 기록"이 없음.
  • 재현 조건: 단계 사이의 예외/타임아웃/프로세스 종료.
  • 근본 원인: 각 Execute 가 즉시 커밋되어 롤백 단위가 없다. 한 논리 작업 = 한 트랜잭션 이어야 한다.

(A)/(D) 멱등성 마커를 마지막에 — 재시도 시 이중 지급 (멱등성/순서) ★간판

  • 분류 태그: idempotency gate ordering / at-least-once double grant.
  • 증상: dedup 기준인 로그(D)가 맨 끝이라, (B)/(C)만 하고 중단된 케이스를 (A)가 못 걸러 재지급한다.
  • 근본 원인: "받았다" 표식이 부수효과 뒤에 따로 커밋되면, 부분 완료를 멱등성 검사로 잡을 수 없다. 표식은 같은 트랜잭션 안에서(이상적으로 UNIQUE 제약으로 먼저) 박혀야 한다.

(A) 검사-후-행동 경쟁(보강)

  • 분류 태그: check-then-act / race on dedup.
  • 증상: 더블클릭/동시 두 요청이 (A)를 동시에 통과해 둘 다 지급. UNIQUE 제약 + 충돌 처리로 막아야 한다.

수정안 (정확한 코드)

세 작업 + 멱등성 마커를 하나의 트랜잭션으로. quest_reward_log(player_id,quest_id) 에 UNIQUE 제약을 두고, 충돌 시 "이미 지급"으로 처리.

public interface IDbTx : System.IDisposable
{
    void Execute(string sql, params object[] args);
    void Commit();
    // Dispose 시 미커밋이면 롤백
}
public interface IDb2 { IDbTx BeginTransaction(); }

public void GrantQuestReward(IDb2 db, long playerId, long questId, long itemId, int gold)
{
    using var tx = db.BeginTransaction();
    try
    {
        // 멱등성 게이트: UNIQUE(player_id, quest_id) 위반이면 이미 지급 → 중단
        tx.Execute("INSERT INTO quest_reward_log(player_id,quest_id) VALUES(?,?)",
                   playerId, questId);   // 중복이면 여기서 예외 → 롤백, 재지급 0

        tx.Execute("INSERT INTO inventory(player_id,item_id,count) VALUES(?,?,1)",
                   playerId, itemId);
        tx.Execute("UPDATE wallet SET gold = gold + ? WHERE player_id=?",
                   gold, playerId);

        tx.Commit();   // 넷이 함께 커밋되거나, 예외 시 Dispose 에서 함께 롤백
    }
    catch (DuplicateKeyException)
    {
        // 이미 받은 보상 — 정상 종료(멱등). tx 는 롤백됨.
    }
}

핵심: ① 한 트랜잭션 = all-or-nothing(부분 커밋 제거), ② 멱등성 마커를 트랜잭션 안에서 먼저 INSERT 해 재시도/동시요청을 UNIQUE 제약으로 차단. 부분 완료 자체가 사라지므로 "받았는지" 판단이 항상 정확하다.

더 나은 설계 (+ 트레이드오프)

  • 멱등성 키를 클라/요청 단위로: (player_id, quest_id) 또는 요청 토큰(request id)을 키로. 외부 결제/지급은 멱등성 키가 사실상 필수.
  • 여러 저장소에 걸치면 단일 트랜잭션 불가: 인벤토리/지갑이 다른 DB·샤드면 2PC 는 비싸고 취약 → Saga + 보상 트랜잭션 또는 트랜잭셔널 아웃박스로 최종 일관성. 트레이드오프: 일시적 불일치 허용 vs 강한 일관성.
  • 재시도 안전성: 호출자가 타임아웃 후 재시도해도 멱등 키로 안전. "지급은 됐는데 응답을 못 받은" 회색지대를 항상 가정.
  • 관측성: 부분 상태/보상 트랜잭션을 모니터링·알림. 이중 지급/누락을 사후 탐지하는 정합성 검사 배치.

면접 포인트

  1. "한 논리 작업 = 한 트랜잭션." autocommit 다중 문장은 부분 커밋을 부른다. 롤백 단위를 의식하라.
  2. 멱등성 마커는 부수효과와 같은 트랜잭션 안에서, 가능하면 먼저 박아 부분 완료를 잡는다. UNIQUE 제약이 가장 견고한 dedup.
  3. 여러 저장소에 걸치면 단일 트랜잭션이 불가 → Saga/아웃박스/2PC 의 트레이드오프를 설명.