2. 장애 후 재기동 시 배치 작업의 중복 처리/이중 실행 방지
난이도 중해설 — 장애 후 재기동 시 배치 작업의 중복 처리/이중 실행 방지
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
배치가 멱등성도, 원자적 클레임도, 트랜잭션 경계도 없다.
(A) 여러 워커가 같은 _jobs 를 동시에 돌면 두 워커가 같은 작업을 Processed==false 로 보고
둘 다 지급한다. (B) if (Processed) continue; ... Processed = true; 는 검사와 표시 사이가
원자적이지 않다(check-then-act). (C)→(D) 순서가 부수효과(지급) 먼저, 표시 나중이다 —
AddGold 와 Processed=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)를 키로. 외부 결제/지급은 거의 항상 멱등성 키가 필수.
면접 포인트
- 분산에서 "정확히 한 번"은 사실상 불가 — at-least-once 전달 + 멱등 소비자로 등가 효과를 만든다. 멱등성 키/원장이 핵심.
- "do-then-mark" 의 위험: 부수효과와 완료표시가 따로면 그 사이 크래시가 재실행을 부른다. 둘을 한 트랜잭션으로, 또는 표시를 멱등 게이트로.
- 다중 워커 동시 처리 → 원자적 클레임(조건부 UPDATE/큐 visibility/분산 락)이 없으면 동일 작업을 함께 집는다.
해설 — 장애 후 재기동 시 배치 작업의 중복 처리/이중 실행 방지 (C++)
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
논리적 멱등성 문제에 더해, C++ 에서는 데이터 레이스(UB) 가 겹친다.
(A) 여러 스레드가 같은 jobs_ 벡터와 (C) 같은 Wallet 맵을 보호 없이 동시에 읽고 쓴다 —
bool processed 와 unordered_map 모두 동기화 없는 동시 접근은 정의되지 않은 동작.
(B)→(D) 는 "검사 후 표시"가 원자적이지 않고, 지급(C) 먼저 표시(D) 나중 이라 그 사이
크래시 시 재기동에서 재지급된다. AddGold 는 누적이라 멱등도 아니다. 정답 한 줄: 작업을
원자적으로 클레임(CAS/락)하고, 지급과 완료표시를 한 단위로 커밋하며, jobId 멱등성 원장으로
두 번째 적용을 거부하라. 공유 상태에는 동기화를 둬라.
변별: C# 트윈이 분산/재기동의 멱등성에 초점이라면, C++ 트윈은 거기에 공유 컨테이너· 플래그의 데이터 레이스(UB) 가 더해진다.
문제점
(A)(C) 보호 없는 공유 상태 동시 접근 — 데이터 레이스 (동시성/UB) ★간판
- 분류 태그: data race on shared container/flag (UB).
- 증상: 여러 워커가
jobs_(벡터)와gold_(맵)를 동시에 읽고 쓴다.unordered_map동시 삽입/수정은 UB(리해시 중 구조 파괴),bool비원자 읽기/쓰기도 race. - 근본 원인: 표준 컨테이너·일반 변수는 동시 쓰기에 안전하지 않다. 동기화 필요.
(B)→(D) check-then-act + do-then-mark — 이중 지급 (멱등성/복구) ★간판
- 분류 태그: non-atomic claim / at-least-once side effect / non-idempotent.
- 증상: 두 워커가 같은 작업을
processed==false로 보고 둘 다 지급. 또는 지급 후 표시 전 크래시 → 재기동 시 재지급. - 근본 원인: 원자적 클레임 부재 + 부수효과와 완료표시 분리 + 비멱등 지급.
수정안 (정확한 코드)
원자적 클레임(CAS) + 락으로 보호된 지급 + 멱등성 원장. 인메모리 모델(실서비스는 DB 트랜잭션).
#include <atomic>
#include <mutex>
#include <unordered_set>
enum class JobState : int { Pending = 0, InProgress = 1, Done = 2 };
struct RewardJob2
{
int64_t jobId;
int64_t playerId;
int gold;
std::atomic<int> state{ (int)JobState::Pending };
};
class Wallet2
{
std::unordered_map<int64_t, int64_t> gold_;
std::mutex mtx_;
std::unordered_set<int64_t> ledger_; // 멱등성: 적용된 jobId
public:
// jobId 기준 멱등 지급: 이미 적용됐으면 무시
bool GrantOnce(int64_t jobId, int64_t playerId, int amount)
{
std::lock_guard<std::mutex> lk(mtx_);
if (!ledger_.insert(jobId).second) return false; // 이미 있음 → 멱등 스킵
gold_[playerId] += amount;
return true;
}
};
void RunPending(std::vector<RewardJob2>& jobs, Wallet2& wallet)
{
for (auto& job : jobs)
{
int expected = (int)JobState::Pending;
// 한 워커만 Pending→InProgress 클레임 성공
if (!job.state.compare_exchange_strong(expected, (int)JobState::InProgress))
continue;
wallet.GrantOnce(job.jobId, job.playerId, job.gold); // 멱등 지급
job.state.store((int)JobState::Done);
}
}
compare_exchange_strong 으로 작업을 한 워커만 선점하고, GrantOnce 의 ledger_ 가
재기동/재시도 시 두 번째 지급을 거부한다(부수효과+표시가 표시 실패해도 원장이 진실의
원천). 실서비스라면 이 원장·상태를 DB 트랜잭션/조건부 UPDATE 로 둔다.
더 나은 설계 (+ 트레이드오프)
- DB 조건부 UPDATE 로 클레임:
UPDATE ... WHERE status='PENDING'의 영향 행수로 선점. 인메모리 CAS 보다 다중 인스턴스에 안전(트레이드오프: DB 왕복). - 메시지 큐 + visibility timeout + 멱등 소비자: 분배·재시도가 내장. 소비자는 멱등 필수.
- 리스/펜싱: 워커가 죽으면 리스 만료로 다른 워커가 이어받되 펜싱 토큰으로 좀비의 늦은 커밋 거부.
- 트랜잭셔널 아웃박스: 지급과 완료 이벤트를 한 트랜잭션으로 기록 후 별도 발행.
면접 포인트
- 분산 "정확히 한 번" ≈ at-least-once + 멱등 소비자. 멱등성 키/원장이 본질.
compare_exchange_strong/weak의미와 ABA 주의(여기선 단조 상태라 안전). 공유 컨테이너는 외부 동기화 필수(표준 컨테이너 동시 쓰기는 UB).- do-then-mark 분리의 위험과 트랜잭션/원장으로의 해소. crash-recovery 를 항상 가정하라.