16. 존 간 플레이어/아이템 이전 (서버-서버 분산 트랜잭션)
난이도 최상해설 — 존 간 플레이어/아이템 이전 (서버-서버 분산 트랜잭션)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
이 코드는 분산 환경에서 두 서버에 걸친 상태 이동을 비원자적으로 처리한다. A 는
SendTransfer 후 무조건 _players.Remove 하는데, fire-and-forget 메시지라 B 가 받았는지
확인하지 않는다. 메시지가 유실되면 A 는 지웠고 B 엔 없어 자산 영구 유실. 반대로 A 가
보내고 죽기 전에 클라가 재시도하거나 메시지가 중복 도달하면 B 가 두 번 생성해 골드/
아이템 복제(dupe). (C)에서 제거가 (B) 전송과 분리돼 있어, 전송 성공·제거 실패 시 양쪽
공존(복제), 전송 실패·제거 성공 시 유실. B 의 OnTransferReceived 는 멱등성이 없어 같은
이전을 두 번 받으면 덮어쓰며(또는 진행 중 상태와 충돌) 복제·롤백 불가. 정답 한 줄:
소유권 이전을 2단계(준비/커밋) + 멱등 transferId + 아웃박스/재시도로 설계해, 어떤 순간에도
자산이 정확히 한 소유자에게만 있게 하고 실패 시 안전하게 한 방향으로 수렴시킨다.
문제점
(B)+(C) 전송과 제거가 비원자 — 복제 또는 유실 (분산 정합성) ★간판
- 증상:
- 전송 성공 → 제거 직전 A 크래시 → 재기동 후 A 에도 있고 B 에도 있음 → 복제.
- 전송 메시지 유실 → A 는 제거 완료 → B 엔 없음 → 영구 유실.
- B 가 받아 처리 중 실패(예외) → A 는 이미 제거 → 유실.
- 재현 조건: 네트워크 유실/지연, A 또는 B 의 크래시가 전송~제거 사이에 발생.
- 근본 원인: 분산 노드 간 상태 이동에 원자성/합의가 없다. 로컬
Remove와 원격 생성이 단일 트랜잭션이 아니며, B 의 수신·적용을 확인(ack) 하지 않는다.
(D) B 수신 멱등성 없음 — 중복 메시지로 복제 (분산 정합성) ★간판
- 증상: 같은 이전이 재전송/중복되면
_players[id] = state가 두 번 실행. 이미 그 플레이어가 활동을 시작했다면 진행분을 덮어써 롤백 불가, 또는 두 세션이 생겨 복제. - 근본 원인:
transferId같은 멱등키로 "이미 적용한 이전"을 식별/거부하지 않는다.
at-least-once 메시징인데 멱등 미설계 — 중복 처리 (분산)
- 증상: 신뢰 메시징은 보통 at-least-once(중복 가능). 멱등 없이는 재시도가 곧 복제.
- 근본 원인: 정확히 한 번(effectively-once)을 멱등키 + dedup 으로 만들어야 한다.
Dictionary 동시 접근 + 입력 검증 — 견고성 (동시성)
- 증상:
_players[playerId]키 없으면KeyNotFoundException. 양쪽Dictionary가 다른 스레드(게임 로직/네트워크 수신)와 락 없이 공유되면 손상. 이전 중에도 플레이어가 거래/전투로 상태를 바꾸면 보낸 스냅샷과 실제가 어긋난다. - 근본 원인: 이전 중 상태 동결(freeze)·임계 구역·존재 검증 부재.
수정안
핵심: ① 이전 중 플레이어를 Locked(동결) 로 두고, ② transferId 멱등키로 2단계
(Prepare→Commit) 핸드오프, ③ B 의 ack 를 받은 뒤에만 A 가 제거, ④ 아웃박스+재시도로
유실 복구, ⑤ 미결 이전은 단일 권위(소유 레코드)로 수렴.
// 소유권 레코드(영속): 한 플레이어는 항상 정확히 한 ownerZone 을 가진다.
// transfer 테이블: (transferId PK, playerId, fromZone, toZone, state, payload)
public async Task TransferTo(long playerId, int targetZoneId)
{
var transferId = Guid.NewGuid(); // 멱등키 (재시도해도 동일하게 유지/영속)
PlayerState snapshot;
lock (_sync)
{
if (!_players.TryGetValue(playerId, out var st)) return;
if (st.Locked) return; // 이미 이전 중 — 재진입 차단
st.Locked = true; // (1) 동결: 이전 중 상태 변경 금지
snapshot = st.Clone();
}
// (2) 아웃박스에 PREPARE 기록(영속). 메시지는 여기서 신뢰 전송 + 재시도.
await _outbox.Enqueue(transferId, playerId, targetZoneId, snapshot, Phase.Prepare);
// (3) B 의 ACCEPTED ack 를 기다림(타임아웃 시 재시도; 멱등키라 중복 무해).
var ack = await WaitAck(transferId); // B 가 영속적으로 받았음을 확인
if (!ack.Accepted)
{
lock (_sync) _players[playerId].Locked = false; // 이전 취소 → 동결 해제
return;
}
// (4) B 가 소유권을 확정(commit)했음을 안 뒤에야 A 가 제거.
lock (_sync) _players.Remove(playerId);
await _outbox.MarkCommitted(transferId);
}
// 존 B
private readonly HashSet<Guid> _applied = new(); // (영속 권장) 멱등 dedup
public AckResult OnTransferReceived(Guid transferId, PlayerState state)
{
lock (_sync)
{
if (!_applied.Add(transferId)) // 이미 적용 → 멱등 무시
return AckResult.AcceptedDuplicate; // 같은 ack 재전송
_players[state.PlayerId] = state; // 정확히 한 번 생성
}
return AckResult.Accepted; // A 에게 ack (커밋 신호)
}
불변식: "A 가 제거하기 전에 B 가 확정 수신(ack)했다" 를 보장하면 유실이 없고, transferId 멱등키로 중복 적용을 막으면 복제가 없다. 어느 순간에도 소유자는 한 곳.
더 나은 설계
1) 소유권을 단일 권위에 (single source of truth)
- 플레이어의
ownerZone을 공유 저장소(DB/Redis)에 두고, 이전은 그 레코드를 CAS 로A→B전이. 메모리 공존이 생겨도 "권위 owner 아닌 쪽"은 읽기전용/거부로 처리. 복제 방지를 메모리 타이밍이 아니라 권위 레코드로 보장. 트레이드오프: 매 이전마다 저장소 왕복 vs 강한 일관성.
2) Saga / 2PC 트레이드오프
- 2PC(준비-커밋)는 코디네이터 장애 시 블로킹·홀딩 위험. 게임에선 보통 Saga + 보상 트랜잭션(실패 시 A 로 롤백) 또는 소유권 토큰 핸드오프를 선호. 핵심은 "한 방향 수렴 + 멱등".
3) 아웃박스 + at-least-once + dedup = effectively-once
- 상태 변경과 메시지 발행을 같은 로컬 트랜잭션(아웃박스)으로 묶어 유실 없이 재전송, 수신측 멱등키로 중복 제거. 네트워크가 신뢰 불가라는 전제에서의 표준 패턴.
4) 미결 이전 복구(리컨실리에이션)
- 서버 재기동 시 transfer 테이블의
Prepare(미커밋) 항목을 스캔해, B 적용 여부를 조회해 커밋 또는 롤백으로 수렴. 크래시가 영구 불일치로 남지 않게 한다.
5) 이전 중 상태 동결 + 클라 게이팅
- Locked 동안 거래/전투/아이템 변경 거부, 클라엔 "이동 중" UI. 보낸 스냅샷과 실제의 괴리를 원천 차단.
면접 포인트
- 면접관이 듣고 싶은 핵심: 분산 환경에서 자산을 복제도 유실도 없이 이동하는 법 — ack 기반 순서(B 확정 후 A 제거) + 멱등키(effectively-once) + 아웃박스/리컨실리에이션, 그리고 2PC vs Saga 트레이드오프.
- 예상 질문:
- "왜 보내고 바로 지우면 안 되나?" → fire-and-forget 은 B 수신 보장이 없다. 유실 시 영구 소실. B ack 후 제거가 원칙.
- "그럼 ack 후 제거인데, ack 가 두 번 오면?" → transferId 멱등으로 B 는 한 번만 적용, ack 는 몇 번 와도 무해.
- "2PC 쓰면 되지 않나?" → 코디네이터/참여자 장애 시 블로킹·자원 홀딩. 게임은 Saga + 보상/소유권 토큰 + 멱등이 흔하다. 트레이드오프를 말할 것.
- "이전 중 플레이어가 아이템을 쓰면?" → Locked 로 동결, 스냅샷·실제 괴리 차단.
변별 메모: concurrency12(분산 락 펜싱/만료)는 단일 자원 상호배제 + 펜싱 토큰이 축이고, concurrency13(캐시-DB 이중 쓰기)은 한 노드의 두 저장소 일관성이 축이다. 본 문제는 두 노드에 걸친 자산 소유권 이동(분산 트랜잭션) 의 복제/유실/멱등으로, 분산 정합성의 종합·최상편이다. session13(split-brain 동시 활성화)과는 "한 플레이어 한 소유자"라는 불변식을 공유하나, 본 문제는 그 소유권을 의도적으로 한 노드에서 다른 노드로 넘기는 핸드오프 트랜잭션 에 초점이 있다.
해설 — 존 간 플레이어/아이템 이전 (서버-서버 분산 트랜잭션, C++)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
분산 환경에서 두 서버에 걸친 상태 이동을 비원자적으로 처리한다. A 는
SendTransfer(fire-and-forget) 후 무조건 players_.erase 하므로 B 의 수신을 확인하지
않는다. 메시지 유실 시 A 는 지웠고 B 엔 없어 영구 유실, 메시지 중복/재시도 시 B 가
두 번 생성해 복제(dupe). (C)의 제거가 (B) 전송과 분리돼 전송 성공·제거 실패면 양쪽
공존(복제), 전송 실패·제거 성공이면 유실. B 의 OnTransferReceived 는 멱등성이 없어 중복
수신을 덮어쓴다. C++ 특유로, (A)의 players_[playerId] 는 키 없으면 기본값을 묵시 삽입
하고, 전송에 const& 를 쓰지만 만약 std::move 로 보냈다면 moved-from 잔존 상태를 erase
전까지 다른 스레드가 읽는 위험도 있다. 정답 한 줄: 소유권 이전을 ack 기반 2단계 + 멱등
transferId + 아웃박스/재시도로 설계해, 어느 순간에도 자산이 정확히 한 소유자에게만 있게
하고 실패 시 한 방향으로 수렴시킨다.
문제점
(B)+(C) 전송과 제거가 비원자 — 복제 또는 유실 (분산 정합성) ★간판
- 증상:
- 전송 성공 → erase 직전 A 크래시 → A·B 공존 → 복제.
- 전송 메시지 유실 → A erase 완료 → B 엔 없음 → 영구 유실.
- B 수신 처리 중 예외 → A 이미 erase → 유실.
- 재현 조건: 네트워크 유실/지연, A 또는 B 크래시가 전송~erase 사이 발생.
- 근본 원인: 분산 노드 간 상태 이동에 원자성/합의가 없다. 로컬 erase 와 원격 생성이 단일 트랜잭션이 아니고, B 의 수신·적용 ack 를 확인하지 않는다.
(D) B 수신 멱등성 없음 — 중복 메시지로 복제 (분산 정합성) ★간판
- 증상: 같은 이전 재전송/중복 시
players_[id] = state가 두 번. 이미 활동 시작했다면 진행분 덮어쓰기/복제. - 근본 원인:
transferId멱등키로 적용 이력 식별·거부가 없다. 신뢰 메시징은 보통 at-least-once(중복 가능)라 멱등 없이는 재시도가 곧 복제.
(A) operator[] 묵시 삽입 + 이전 중 상태 변경 — 견고성 (C++/동시성)
- 증상:
players_[playerId]는 키가 없으면 빈PlayerState{}를 삽입하고 그걸 전송 →존재하지 않는 플레이어를 빈 상태로 B 에 만든다. 두unordered_map을 게임 로직/네트워크 수신 스레드가 락 없이 공유하면 손상. 이전 중 플레이어가 거래/전투로 상태를 바꾸면 보낸 스냅샷과 어긋난다. - 근본 원인:
find로 존재 검증 + 이전 중 동결 + 임계 구역 부재.
부분 적용/롤백 불가 — 분산 정합성
- 증상: 골드만 옮기고 아이템 전송이 끊기면 부분 이전. 보상 트랜잭션/롤백 경로가 없다.
- 근본 원인: 이전을 단일 멱등 단위로 묶고 실패 시 수렴시키는 설계 부재.
수정안
핵심: ① 이전 중 Locked 동결, ② transferId 멱등키로 ack 기반 2단계, ③ B ack 후에만
A erase, ④ 아웃박스+재시도+리컨실리에이션.
struct PlayerState {
int64_t playerId; int64_t gold; std::vector<int> items;
bool locked = false;
};
// 존 A
void ZoneTransferOut::TransferTo(int64_t playerId, int targetZoneId) {
uint64_t transferId = NextTransferId(); // 멱등키(영속/재시도시 동일 유지)
PlayerState snapshot;
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = players_.find(playerId); // 묵시삽입 회피
if (it == players_.end()) return;
if (it->second.locked) return; // 이미 이전 중 — 재진입 차단
it->second.locked = true; // 동결: 이전 중 변경 금지
snapshot = it->second; // 스냅샷 복사
}
outbox_.Enqueue(transferId, playerId, targetZoneId, snapshot, Phase::Prepare); // 신뢰 전송+재시도
AckResult ack = WaitAck(transferId); // B 의 확정 수신 대기(타임아웃→재시도, 멱등)
if (!ack.accepted) {
std::lock_guard<std::mutex> lk(mtx_);
if (auto it = players_.find(playerId); it != players_.end())
it->second.locked = false; // 취소 → 동결 해제
return;
}
{
std::lock_guard<std::mutex> lk(mtx_);
players_.erase(playerId); // B 확정 후에만 제거
}
outbox_.MarkCommitted(transferId);
}
// 존 B
AckResult ZoneTransferIn::OnTransferReceived(uint64_t transferId, const PlayerState& state) {
std::lock_guard<std::mutex> lk(mtx_);
if (!applied_.insert(transferId).second) // 이미 적용 → 멱등 무시
return AckResult::AcceptedDuplicate; // 같은 ack 재전송
players_[state.playerId] = state; // 정확히 한 번 생성
return AckResult::Accepted; // A 에 커밋 신호
}
불변식: A 가 erase 하기 전에 B 가 확정 수신(ack) → 유실 없음. transferId 멱등키 → 복제 없음. 어느 순간에도 소유자는 한 곳.
더 나은 설계
1) 소유권을 단일 권위(DB/Redis ownerZone 레코드)로
- 이전을
ownerZone레코드의 CASA→B전이로. 메모리 공존이 생겨도 권위 owner 아닌 쪽은 읽기전용/거부. 복제 방지를 타이밍이 아니라 권위 레코드로. 트레이드오프: 저장소 왕복 vs 강한 일관성.
2) Saga vs 2PC
- 2PC 는 코디네이터/참여자 장애 시 블로킹·홀딩. 게임은 보통 Saga + 보상 트랜잭션 또는 소유권 토큰 핸드오프 + 멱등을 선호. 핵심은 "한 방향 수렴".
3) 아웃박스 + at-least-once + dedup = effectively-once
- 상태 변경과 메시지 발행을 같은 로컬 트랜잭션(아웃박스)으로 묶어 유실 없는 재전송, 수신측 멱등키로 중복 제거.
4) 미결 이전 리컨실리에이션
- 재기동 시 transfer 로그의 미커밋 항목을 스캔해 B 적용 여부 조회 → 커밋/롤백 수렴.
5) C++ 자원/수명
- 큰 인벤토리는 복사 대신
shared_ptr<const PlayerState>로 스냅샷 공유. moved-from 상태를 노출하지 않도록 "동결 후 스냅샷, ack 후 제거" 순서를 지킨다.
면접 포인트
- 면접관이 듣고 싶은 핵심: 분산에서 자산을 복제도 유실도 없이 이동 — ack 순서(B 확정 후 A 제거) + 멱등키 + 아웃박스/리컨실리에이션 + 2PC vs Saga.
- 예상 질문:
- "보내고 바로 erase 가 왜 안 되나?" → fire-and-forget 은 수신 보장 없음. 유실 시 영구 소실. B ack 후 erase.
- "ack 가 두 번 오면?" → transferId 멱등으로 B 한 번만 적용, ack 중복은 무해.
- "
players_[id]의 함정?" → 키 없으면 빈 상태 묵시 삽입·전송.find로 존재 검증. - "2PC 면 되지 않나?" → 코디네이터 장애 시 블로킹. Saga/소유권 토큰 + 멱등 트레이드오프.
변별 메모: concurrency12(분산 락 펜싱)는 단일 자원 상호배제, concurrency13(캐시-DB)은 한 노드 두 저장소 일관성, 본 문제는 두 노드 간 소유권 이동(분산 트랜잭션)의 복제/유실/멱등 종합편. C++ 트윈은
operator[]묵시삽입·스냅샷 복사/shared_ptr수명 같은 언어 특성을 C# 의 멱등키/아웃박스 논의와 함께 다룬다.