← 문제로

48. 결투(듀얼) 신청 수락과 만료 타이머의 경합 (C#)

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

해설 — 결투(듀얼) 신청 수락과 만료 타이머의 경합 (C#)

난이도: 중상

요약

결투 성립은 "두 플레이어가 모두 Idle인가"를 확인하고 "두 플레이어를 InDuel로 바꾸는" 복합 연산인데, 검사와 전이가 원자적이지 않고 신청서 소비(Consumed)도 CAS가 아니다. 게다가 만료 타이머가 자기가 만들지 않은 상태(플레이어 State)를 되돌려 놓기 때문에, 수락과 만료가 겹치면 이미 시작된 결투의 참가자를 Idle로 리셋해 상태 짝이 깨진다.

문제점

(A) check-then-act — 상태 검사와 결투 전이의 비원자성 / 신청서 이중 소비

  • 분류: 경쟁 조건(TOCTOU) · 원자성 위반
  • 증상: 한 플레이어가 동시에 두 개의 결투에 참가한 것으로 기록된다(CurrentDuelId가 마지막 것으로 덮여, 먼저 만들어진 결투는 참가자를 잃은 채 _duels에 남는다). 또는 InCombat으로 막아야 할 플레이어가 결투에 들어간다.
  • 재현 조건: B에게 A와 C가 각각 신청 → B의 클라이언트가 두 수락 패킷을 연달아 보내 서로 다른 IOCP 워커 스레드에서 Accept가 동시에 실행. 두 호출 모두 a.State/b.State를 Idle로 읽고 통과.
  • 근본 원인:
    1. if (a.State != Idle || b.State != Idle) 검사와 그 아래 a.State = InDuel 대입 사이에 어떤 락도 없다. 플레이어에 Sync 락 필드가 있는데도 Accept는 쓰지 않는다.
    2. req.Consumed를 평범한 bool 읽기·쓰기로 다루므로 "신청서는 한 번만 소비"라는 불변식이 두 스레드 동시 진입을 막지 못한다. Interlocked 계열의 단일 승자 선정이 없다.
    3. Request 시점의 Idle 검사는 수락 시점의 보증이 아니다. 신청 후 30초 사이에 상태는 얼마든지 바뀐다.
    4. PlayerState/CurrentDuelId는 락 없이 여러 스레드가 읽고 쓰는데 volatile도 아니어서 가시성 보장도 없다.

(B) 만료 경로가 남의 상태를 되돌린다 — 소유권 없는 롤백 + 경합

  • 분류: 상태 소유권 위반 · 경쟁 조건
  • 증상: 결투가 막 시작됐는데 두 플레이어가 Idle로 리셋된다. 반대로 한쪽만 리셋되면 A는 InDuel, B는 Idle이 되어 짝이 안 맞는 유령 결투가 남고, A는 영영 다른 결투를 신청할 수 없다.
  • 재현 조건: 30초 경계에서 Accept와 Expire가 동시 실행. Expire가 req.Consumed를 false로 읽은 직후 Accept가 상태를 InDuel로 올리고 Consumed = true로 바꾼다. Expire는 이미 통과한 분기를 계속 진행해 Idle로 덮어쓴다.
  • 근본 원인:
    1. Expire의 Consumed 확인 → 상태 되돌리기 역시 검사와 조치가 비원자적이다.
    2. 더 근본적으로 만료 경로가 플레이어 상태를 건드릴 권한이 없다. 신청서는 플레이어 State를 바꾸지 않았으므로(Request는 State를 읽기만 한다) 만료가 State를 Idle로 되돌리는 것 자체가 잘못된 보상 동작이다. 신청서 만료는 신청서만 지워야 한다.
    3. Accept에서 req.ExpireTimer.Dispose()를 호출해도 이미 실행 중인 콜백은 멈추지 않는다(Dispose()는 향후 실행만 막는다). Timer.Dispose(WaitHandle) 또는 상태 기반 방어가 필요하다.
    4. Request 안에서 req.ExpireTimer를 대입하기 전에 타이머가 생성·발화될 수 있다. 30초라 실제로는 거의 안 나지만, 짧은 만료로 바꾸면 Expire에서 req.ExpireTimer?.Dispose()가 null을 보게 되는 발행 경합이다.

수정안

핵심은 세 가지다. ① 결투 성립을 두 플레이어 락(정규 순서) 안에서 원자적으로 수행, ② 신청서 소비를 CAS 단일 승자로, ③ 만료는 신청서만 지운다.

public sealed class Player
{
    public long Id;
    private int _state = (int)PlayerState.Idle;   // Interlocked 대상
    public long CurrentDuelId;
    public readonly object Sync = new object();

    public PlayerState State => (PlayerState)Volatile.Read(ref _state);
    public void SetState(PlayerState s) => Volatile.Write(ref _state, (int)s);
}

public sealed class DuelRequest
{
    public long Id;
    public long FromId;
    public long ToId;
    public Timer ExpireTimer;
    private int _consumed;                        // 0 = 미소비
    public bool TryConsume() => Interlocked.CompareExchange(ref _consumed, 1, 0) == 0;
}
public bool Accept(long requestId, long accepterId)
{
    if (!_requests.TryGetValue(requestId, out var req)) return false;
    if (req.ToId != accepterId) return false;

    // ① 신청서를 먼저 원자적으로 소비 — 동시 수락/만료 중 단 한 명만 통과
    if (!req.TryConsume()) return false;
    _requests.TryRemove(requestId, out _);
    req.ExpireTimer?.Dispose();

    var a = _players[req.FromId];
    var b = _players[req.ToId];

    // ② 정규 락 순서(Id 오름차순)로 두 플레이어를 함께 잠근다 — ABBA 데드락 방지
    var first  = a.Id < b.Id ? a : b;
    var second = a.Id < b.Id ? b : a;

    lock (first.Sync)
    lock (second.Sync)
    {
        // ③ 락 안에서 재확인 후 전이 (check-and-act 원자화)
        if (a.State != PlayerState.Idle || b.State != PlayerState.Idle)
            return false;                          // 소비된 신청서는 그대로 폐기

        var duel = new Duel
        {
            Id = Interlocked.Increment(ref _nextDuelId),
            P1 = a.Id, P2 = b.Id, StartedUtc = DateTime.UtcNow,
        };
        _duels[duel.Id] = duel;

        a.SetState(PlayerState.InDuel); a.CurrentDuelId = duel.Id;
        b.SetState(PlayerState.InDuel); b.CurrentDuelId = duel.Id;
        return true;
    }
}

private void Expire(long requestId)
{
    if (!_requests.TryRemove(requestId, out var req)) return;
    // 만료는 신청서만 소비하고 끝. 플레이어 상태는 건드리지 않는다.
    if (!req.TryConsume()) return;   // 이미 수락됐다면 아무 일도 하지 않는다
    req.ExpireTimer?.Dispose();
    NotifyExpired(req.FromId, req.ToId);
}

Request의 타이머 생성 경합은 타이머를 정지 상태로 만든 뒤 필드에 대입하고 나서 시작해 없앤다.

var timer = new Timer(_ => Expire(req.Id), null, Timeout.Infinite, Timeout.Infinite);
req.ExpireTimer = timer;
timer.Change(30_000, Timeout.Infinite);

또한 같은 사람에게 온 여러 신청을 관리하려면 _requests 외에 수신자 → 신청서 집합 역인덱스를 두고, 결투 성립 시 두 참가자에게 걸린 나머지 신청서를 모두 TryConsume()으로 무효화해야 한다. 그러지 않으면 결투가 끝난 직후 오래된 신청서가 되살아난다.

더 나은 설계

(1) 플레이어 상태를 상태 기계 + CAS 전이로. TryBeginDuel(long duelId)가 Interlocked.CompareExchange(ref _state, InDuel, Idle)로 전이하고, 둘 중 하나만 성공하면 성공한 쪽을 되돌리는 방식이면 락 없이 처리할 수 있다.

  • 트레이드오프: 부분 성공 롤백 로직이 필요하고, "두 플레이어를 원자적으로 잡는다"는 의미가 코드에 드러나지 않아 읽기 어렵다. 참가자가 3명 이상(파티 대전)으로 늘면 관리가 급격히 어려워진다.

(2) 결투 매니저를 단일 액터(직렬 큐)로. 결투 신청/수락/만료/종료를 전부 하나의 메시지 큐에 넣고 한 스레드가 순차 처리하면 이 유형의 경합이 설계상 사라진다. 만료도 "만료 메시지"로 같은 큐에 들어오므로 수락과의 순서가 자연히 결정된다.

  • 트레이드오프: 결투 매니저가 단일 스레드 병목이 된다. 다만 결투 성립은 초당 수백 건 수준이라 실제로는 문제되지 않고, 락 순서/재진입 고민이 통째로 사라지는 이득이 훨씬 크다. 존 단위로 샤딩하면 병목도 해소된다.

(3) 신청서를 만료 시각 기반 lazy 검증으로. 타이머 스레드를 없애고 ExpiresAtUtc만 저장한 뒤, 수락 시점에 만료 여부를 판정하고 주기적 청소만 배치로 돌린다.

  • 트레이드오프: 타이머 콜백과의 경합이 원천 제거되고 타이머 객체 수천 개를 안 만들어도 된다. 대신 "만료됐습니다" 알림이 즉시 가지 않아 UX가 약간 나빠진다. 실무에서는 이 방식 + 저해상도 타임휠 조합이 흔하다.

(4) 결투 종료 경로도 같이 설계. 지금 코드에는 종료가 없는데, 종료 역시 "두 플레이어를 Idle로"라는 복합 연산이므로 동일한 락 순서·CurrentDuelId 일치 검증(a.CurrentDuelId == duelId)이 필요하다. 이 검증이 없으면 늦게 도착한 옛 결투의 종료 처리가 새 결투를 끊는다.

면접 포인트

  • "여러 객체의 상태를 원자적으로 바꿔야 할 때 락 순서를 어떻게 정하는가?" — 고유 ID 오름차순 같은 전역 정규 순서. 인자 순서대로 잠그면 반대 방향 요청과 ABBA 데드락이 난다.
  • "Timer.Dispose()를 호출하면 콜백이 안 오는가?" — 이미 시작된 콜백은 못 막는다. 취소는 항상 "타이머 정지 + 상태 기반 멱등 방어" 두 겹으로 해야 한다.
  • "만료/타임아웃 처리가 상태를 롤백해도 되는가?" — 자신이 만든 상태만 되돌려야 한다. 남이 만든 상태를 되돌리는 보상 로직은 정상 경로와 반드시 경합한다. 이는 사가(saga) 보상 트랜잭션 설계와 같은 원칙이다.