← 문제로

2. C# 룸 기반 게임의 좀비 세션 / 유령 플레이어

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

해설 — C# 룸 기반 게임의 좀비 세션 / 유령 플레이어

난이도: 중

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

요약

연결이 끊겨도 Room이 세션을 강한 참조로 계속 들고 있어 유령 플레이어가 남고, 끊김 시 이벤트 핸들러(OnBroadcast += s.Send) 구독을 해제하지 않아 세션이 GC되지 못하는 이벤트 핸들러 누수가 발생한다. 끊긴 세션에도 매 틱 Send가 호출되며, 락을 잡은 채 이벤트(미지의 코드)를 호출해 데드락/락 점유 위험이 있고, 디스커넥트(IO 스레드)와 Tick(로직 스레드) 사이 정리 순서가 정의돼 있지 않다.


문제점

(E) Room에서 멤버를 제거하지 않음 → 유령 플레이어 (분류: 수명관리/정확성)

  • 증상: OnDisconnectSessionManager 맵에서만 지우고 Room._members/구독은 손대지 않는다. 끊긴 플레이어가 룸에 계속 보이고, 다른 유저 화면에 유령으로 남는다.
  • 재현조건: 룸 입장 중 클라가 끊김. 매니저에선 사라지지만 룸엔 그대로.
  • 근본원인: 세션 정리가 "모든 참조 보유자"에 전파되지 않음. 매니저가 룸을 모르므로 단방향 정리만 일어난다.

(B) 이벤트 핸들러 누수 → 세션 GC 안 됨 (분류: 수명관리/메모리)

  • 증상: OnBroadcast += s.SendRoom의 멀티캐스트 델리게이트가 세션 s를 강하게 참조하게 만든다(델리게이트 타깃이 s). 끊겨도 OnDisconnectOnBroadcast -= s.Send를 하지 않으므로, 룸이 살아있는 한 세션은 GC 루트에 묶여 영원히 회수되지 않는다. C#에서 가장 흔한 메모리 누수 패턴(구독 해제 누락).
  • 재현조건: 입장/퇴장이 반복되는 장시간 운영. 룸의 인보케이션 리스트가 죽은 세션으로 계속 자라 GC가 못 줄임 → 힙 우상향.
  • 근본원인: 이벤트 구독의 수명이 객체 수명과 결합. publisher(Room)가 subscriber (Session)보다 오래 살면 명시적 -=가 필수.

(C) 끊긴 세션에 계속 Send + 락 보유 중 외부 호출 (분류: 정확성/성능)

  • 증상: TickConnected == false인 세션(아직 구독 해제 안 됨)에도 Send를 호출. 송신 큐가 쌓이고 CPU를 낭비. 또 _lock을 잡은 채 OnBroadcast?.Invoke미지의 코드(Send/외부 핸들러) 를 호출 → Send가 내부 락을 잡으면 데드락 위험, 느린 전송이 락을 오래 점유해 Enter가 막힘.
  • 근본원인: 끊김 상태를 Tick이 검사하지 않음 + "락 잡고 모르는 코드 호출" 원칙 위반.

(D)+(E) 디스커넥트와 Tick의 경합 / 정리 순서 미정의 (분류: 동시성)

  • 증상: IO 스레드가 OnDisconnectConnected=false를 (매니저 락 아래) 쓰는 동안 로직 스레드는 Tick에서 같은 세션을 (룸 락 아래) 순회. Connected 플래그가 서로 다른 락 아래 갱신·관측되어 가시성·순서 보장이 없다. 어느 쪽이 먼저인지 정의되지 않아 한 틱 동안 유령이 한 번 더 브로드캐스트됨. 또 이벤트 델리게이트를 락 밖에서 건드리면 멀티캐스트 리스트 자체의 동시성 문제가 생길 수 있다.
  • 근본원인: 세션 상태 전이와 멤버/구독 제거가 서로 다른 락(매니저 락 vs 룸 락) 아래 분산 수행되며 순서가 조율되지 않음.

수정안

핵심: 디스커넥트가 룸에서 즉시 제거 + 이벤트 구독 해제 + Tick은 끊김 검사 + 락 밖 송신

using System;
using System.Collections.Generic;
using System.Threading;

public sealed class Session
{
    public long Id;
    private int _connected = 1;
    public bool Connected => Volatile.Read(ref _connected) == 1;

    // 매니저가 룸을 찾아 Leave 를 호출할 수 있도록 약한 연결만 보관(역참조 최소화).
    public Room Room;

    public void MarkDisconnected() => Interlocked.Exchange(ref _connected, 0);
    public void Send(byte[] data) { /* 비동기 송신 */ }
}

public sealed class Room
{
    private readonly object _lock = new object();
    private readonly List<Session> _members = new List<Session>();   // 룸이 멤버를 "소유"

    public void Enter(Session s)
    {
        lock (_lock)
        {
            _members.Add(s);
            s.Room = this;
            // 이벤트 구독 대신 명시적 멤버 리스트로 관리 → 누수/구독 해제 누락 차단.
        }
    }

    public void Leave(long id)
    {
        lock (_lock)
        {
            _members.RemoveAll(m => m.Id == id);   // (E) 룸에서도 제거 → 유령 제거
        }
    }

    public void Tick(byte[] snapshot)
    {
        // 1) 락 안에서 살아있는 멤버 스냅샷만 추리고 죽은 멤버는 청소(lazy reaping).
        List<Session> alive;
        lock (_lock)
        {
            _members.RemoveAll(m => !m.Connected);
            alive = new List<Session>(_members);
        }
        // 2) 락 밖에서 송신(미지의 코드 호출을 락 밖으로)
        foreach (var s in alive)
            if (s.Connected) s.Send(snapshot);
    }
}

public sealed class SessionManager
{
    private readonly object _lock = new object();
    private readonly Dictionary<long, Session> _sessions = new Dictionary<long, Session>();

    public void Add(Session s) { lock (_lock) { _sessions[s.Id] = s; } }

    public void OnDisconnect(long id)
    {
        Session s;
        lock (_lock)
        {
            if (!_sessions.TryGetValue(id, out s)) return;
            _sessions.Remove(id);
        }
        // 매니저 락을 푼 뒤(데드락 회피) 상태 전이 + 룸에서 제거
        s.MarkDisconnected();
        s.Room?.Leave(id);   // (E) 룸에서도 제거 → 유령/구독 모두 정리
    }
}

포인트

  • 이벤트 구독 패턴 제거: OnBroadcast += s.Send 대신 명시적 _members 리스트로 관리. 이벤트를 꼭 써야 한다면 Leave/OnDisconnect에서 -=반드시 구독 해제 하거나 weak event 패턴을 써야 누수가 안 난다.
  • 디스커넥트가 룸에서 즉시 제거: s.Room.Leave(id)로 유령과 구독을 함께 제거.
  • Tick의 lazy reaping: 혹시 누락된 죽은 멤버도 다음 틱에 청소되어 누수 2차 방어.
  • 락 밖 송신 + Volatile/Interlocked 플래그로 데드락/가시성 문제 해소.
  • OnDisconnect에서 매니저 락을 먼저 풀고 룸 락을 잡아 락 중첩(데드락) 회피.

더 나은 설계

1) 단일 소유자 + ID 핸들 모델

  • 세션의 "유일한 소유자"는 SessionManager 하나로 두고, Room/다른 시스템은 sessionId(정수 핸들)만 보유. 참조하려면 매번 manager에서 조회.
  • 트레이드오프: 조회 비용·락이 늘지만 수명 규칙이 명확해지고 강참조 순환/누수가 원천 차단. 대규모 서버에서 "정수 핸들 + 세대(generation) 카운터"는 stale 참조를 잡는 표준 기법.

2) 세션 상태머신 + 명시적 정리 파이프라인

  • Active → Disconnecting → Removed. 디스커넥트는 한 번만 전이 성공 (Interlocked.CompareExchange), 그 스레드가 "모든 보유자에서 제거" 작업을 큐에 넣음.
  • 정리 순서를 한 곳(로직 스레드)에서 직렬화하면 IO 스레드와의 경합이 사라진다.

3) 룸 작업을 액터/잡 큐로 직렬화

  • Room의 Enter/Leave/Tick을 단일 스레드 잡 큐(Channel 등)로 처리하면 _lock 자체가 사라지고 "끊김 처리 vs 브로드캐스트" 순서가 큐 순서로 결정적이 된다.
  • 트레이드오프: 룸별 스레드 친화도(affinity) 설계 필요, 크로스룸 작업은 메시지 패싱.

면접 포인트

  1. "C#에서 이벤트(+=) 구독이 메모리 누수를 일으키는 원리는?" → publisher의 멀티캐스트 델리게이트가 subscriber를 강하게 참조한다. publisher가 더 오래 살면 subscriber가 GC 루트에 묶여 회수 안 됨. 해결: 객체 소멸 시 -=로 명시 구독 해제, 또는 weak event/약한 참조 패턴.
  2. "끊김 처리가 IO 스레드, 브로드캐스트가 로직 스레드면 race를 어떻게 막나?" → 상태 전이를 Interlocked로 단일 승자만 처리하게 하고, 실제 제거 작업은 로직 스레드 잡 큐로 넘겨 순서를 직렬화. Tick은 Volatile 플래그로 끊김 세션을 건너뜀.
  3. "좀비 세션을 운영 중에 어떻게 조기 발견하나?" → 세션/룸 객체 수(gauge) 메트릭, "끊김 이벤트 수 vs 활성 세션 수" 불일치 알람, 주기적 reaper. 메모리 누수는 dotnet-gcdump/dotnet-counters로 힙 증가와 델리게이트 인보케이션 리스트 길이를 확인.