← 문제로

2. 장애 후 재기동 시 배치 작업의 중복 처리/이중 실행 방지

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

해설 — 장애 후 재기동 시 배치 작업의 중복 처리/이중 실행 방지

난이도: 중상

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

요약

배치가 멱등성도, 원자적 클레임도, 트랜잭션 경계도 없다. (A) 여러 워커가 같은 _jobs 를 동시에 돌면 두 워커가 같은 작업을 Processed==false 로 보고 둘 다 지급한다. (B) if (Processed) continue; ... Processed = true; 는 검사와 표시 사이가 원자적이지 않다(check-then-act). (C)→(D) 순서가 부수효과(지급) 먼저, 표시 나중이다 — AddGoldProcessed=true 가 별개 단계라, 지급 직후 표시 전에 죽으면 재기동 시 다시 Processed==false 로 보여 재지급된다(at-least-once + 비멱등 부수효과). 게다가 AddGold 자체가 가산(누적) 이라 호출할 때마다 늘어난다(멱등 아님). 정답 한 줄: 작업을 원자적으로 클레임(lease/조건부 UPDATE)하고, 지급과 완료표시를 한 트랜잭션으로 묶거나, 멱등성 키로 중복 적용을 차단하라.

변별: concurrency_memory/problem17(결제 콜백 멱등성)이 단일 서버 콜백 중복이라면, 본 문제는 분산/재기동 맥락의 배치 이중 실행 — 다중 워커 클레임 경쟁 + crash-recovery 의 at-least-once 가 핵심이다. distributed_systems/problem1(글로벌 거래소 정합성)과도 다르다.


문제점

(A)(B) 원자적 클레임 부재 — 다중 워커 이중 처리 (분산/동시성) ★간판

  • 분류 태그: missing atomic claim / check-then-act across workers.
  • 증상: 워커 둘이 동시에 같은 작업을 집어 둘 다 AddGold. Processed 플래그도 보호가 없어 경쟁.
  • 재현 조건: 워커 ≥ 2, 같은 목록 공유, 동시 RunPending.
  • 근본 원인: "이 작업은 내 것" 을 원자적으로 선점(lease/lock/조건부 UPDATE)하지 않음.

(C)→(D) 부수효과 먼저, 완료표시 나중 — 재기동 시 재지급 (멱등성/복구) ★간판

  • 분류 태그: at-least-once side effect / non-atomic do-then-mark / non-idempotent op.
  • 증상: AddGold 성공 후 Processed=true 전에 프로세스가 죽으면, 재기동 시 그 작업이 미처리로 남아 다시 지급된다. AddGold 가 가산이라 두 번이면 두 배.
  • 재현 조건: 지급과 표시 사이의 크래시/배포/타임아웃.
  • 근본 원인: 분산/장애 환경에서 "정확히 한 번"은 보장 불가에 가깝다. 보통 at-least-once 전달 + 멱등 소비자로 등가 효과를 만든다. 여기엔 멱등성이 없다.

(보강) 비멱등 AddGold

  • 지급이 "현재값 + amount" 라 재호출마다 누적. 멱등하려면 "이 jobId 를 이 플레이어에 적용했는가"를 기록·검사하거나, 지급을 키 기반 1회성으로 만들어야 한다.

수정안 (정확한 코드)

원자적 클레임 + (지급 + 완료표시) 한 트랜잭션 + 멱등성 원장. 아래는 의사 SQL/락 모델.

// 1) 원자적 클레임: 한 워커만 한 작업을 in_progress 로 가져간다
//    UPDATE reward_job SET status='IN_PROGRESS', owner=@me, lease_until=now()+30s
//    WHERE job_id=@id AND status='PENDING';   -- 영향 행수 1이면 내가 클레임 성공
//
// 2) 지급 + 완료를 같은 트랜잭션으로(부분 적용 불가)
//    BEGIN;
//      INSERT INTO reward_ledger(job_id) VALUES(@id);   -- UNIQUE(job_id): 멱등성 게이트
//      UPDATE wallet SET gold = gold + @amt WHERE player_id=@pid;
//      UPDATE reward_job SET status='DONE' WHERE job_id=@id;
//    COMMIT;   -- ledger UNIQUE 위반 시 이미 처리됨 → 롤백/스킵(중복 지급 0)

public bool ProcessOne(RewardJob job, ILedger ledger, ITx tx)
{
    using var t = tx.Begin();
    if (!ledger.TryInsert(job.JobId))   // 이미 있으면 false → 멱등 스킵
    {
        t.Commit();
        return false;
    }
    _wallet.AddGold(job.PlayerId, job.Gold);   // 같은 트랜잭션 안
    job.Processed = true;
    t.Commit();   // ledger insert + 지급 + 표시가 함께 커밋되거나 함께 롤백
    return true;
}

핵심 두 가지: ① 멱등성 키(job_id UNIQUE 원장)로 두 번째 적용을 거부, ② 지급과 완료표시를 한 트랜잭션으로 묶어 "지급은 됐는데 표시는 안 된" 중간 상태를 없앤다. 다중 워커는 조건부 UPDATE(또는 분산 락/큐의 visibility timeout)로 한 명만 클레임.

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

  • 메시지 큐 + visibility timeout: SQS/Kafka 류로 작업을 분배하면 클레임이 내장된다. 소비자는 멱등해야 한다(at-least-once 전제). 트레이드오프: 인프라 의존.
  • 리스(lease)/펜싱 토큰: 워커가 죽어 리스가 만료되면 다른 워커가 이어받되, 펜싱 토큰으로 좀비 워커의 늦은 커밋을 거부(concurrency_memory/problem12 와 연계).
  • 트랜잭셔널 아웃박스: 지급과 "완료 이벤트"를 한 트랜잭션으로 기록하고 별도 발행기가 전송 → 이중 발행/유실 방지.
  • 멱등성 키 설계: job_id 또는 (player_id, reward_batch_id) 를 키로. 외부 결제/지급은 거의 항상 멱등성 키가 필수.

면접 포인트

  1. 분산에서 "정확히 한 번"은 사실상 불가 — at-least-once 전달 + 멱등 소비자로 등가 효과를 만든다. 멱등성 키/원장이 핵심.
  2. "do-then-mark" 의 위험: 부수효과와 완료표시가 따로면 그 사이 크래시가 재실행을 부른다. 둘을 한 트랜잭션으로, 또는 표시를 멱등 게이트로.
  3. 다중 워커 동시 처리 → 원자적 클레임(조건부 UPDATE/큐 visibility/분산 락)이 없으면 동일 작업을 함께 집는다.