← 문제로

16. 존 간 플레이어/아이템 이전 (서버-서버 분산 트랜잭션)

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

해설 — 존 간 플레이어/아이템 이전 (서버-서버 분산 트랜잭션)

난이도: 최상

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

요약

이 코드는 분산 환경에서 두 서버에 걸친 상태 이동을 비원자적으로 처리한다. 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 트레이드오프.
  • 예상 질문:
    1. "왜 보내고 바로 지우면 안 되나?" → fire-and-forget 은 B 수신 보장이 없다. 유실 시 영구 소실. B ack 후 제거가 원칙.
    2. "그럼 ack 후 제거인데, ack 가 두 번 오면?" → transferId 멱등으로 B 는 한 번만 적용, ack 는 몇 번 와도 무해.
    3. "2PC 쓰면 되지 않나?" → 코디네이터/참여자 장애 시 블로킹·자원 홀딩. 게임은 Saga + 보상/소유권 토큰 + 멱등이 흔하다. 트레이드오프를 말할 것.
    4. "이전 중 플레이어가 아이템을 쓰면?" → Locked 로 동결, 스냅샷·실제 괴리 차단.

변별 메모: concurrency12(분산 락 펜싱/만료)는 단일 자원 상호배제 + 펜싱 토큰이 축이고, concurrency13(캐시-DB 이중 쓰기)은 한 노드의 두 저장소 일관성이 축이다. 본 문제는 두 노드에 걸친 자산 소유권 이동(분산 트랜잭션) 의 복제/유실/멱등으로, 분산 정합성의 종합·최상편이다. session13(split-brain 동시 활성화)과는 "한 플레이어 한 소유자"라는 불변식을 공유하나, 본 문제는 그 소유권을 의도적으로 한 노드에서 다른 노드로 넘기는 핸드오프 트랜잭션 에 초점이 있다.