← 문제로

2. Room/Session 이벤트 핸들러 누수와 상호 참조

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

해설 — Room/Session 이벤트 핸들러 누수와 상호 참조

난이도: 중

요약

RoomSession을 리스트로 보관하고, SessionRoom을 필드로 보관하며, 게다가 SessionRoom.OnBroadcast 이벤트에 구독한다. C#의 이벤트(델리게이트)는 구독자(Session)를 강하게 참조하므로, Room이 살아있는 한 구독한 Session도 GC되지 않는다. 동시에 SessionRoom을 강하게 붙잡아 상호 강참조 + 이벤트 구독 누수가 생긴다. RemoveRoom으로 맵에서 지워도 매니저 외에 서로가 서로를 도달 가능(reachable)하게 만들어, 외부에서 둘 중 하나라도 참조가 남으면(혹은 정적/장수명 객체가 이벤트 소스를 잡으면) 메모리 누수다. 특히 "장수명 이벤트 소스에 단명 객체가 구독하고 해지하지 않는" 것이 .NET의 대표적 누수 패턴이다. 또한 SendToRoom은 룸 종료 여부를 확인하지 않고 사용해 비동기 콜백에서 문제가 될 수 있다.

C++판은 같은 시나리오를 shared_ptr 순환참조(refcount가 0이 안 됨) 로 다룬다. C#은 추적 GC라 순수 객체 사이클 자체는 GC가 수거하지만, 이벤트 구독으로 장수명 객체가 단명 객체를 붙잡으면 GC 루트에서 도달 가능해져 수거되지 않는다. 이것이 C#에서의 "사이클/누수"의 진짜 형태다.

문제점

(A) room.OnBroadcast += HandleBroadcast — 이벤트 구독 누수 (분류: 메모리, 핵심)

  • 증상: 룸을 반복 생성/제거할수록 메모리 단조 증가. Session/Room이 GC되지 않는다.
  • 재현조건: Session이 Room 이벤트에 구독하고 해지(-=)하지 않는 순간부터. Room(또는 그 이벤트 델리게이트 체인)이 어딘가 도달 가능하면 모든 구독 Session이 함께 살아남는다.
  • 근본원인: C#의 event는 내부적으로 멀티캐스트 델리게이트(Action<string>)이고, 델리게이트는 타깃 객체(Session 인스턴스)에 대한 강한 참조를 보유한다. Room.OnBroadcast가 각 Session.HandleBroadcast를 붙잡으므로 Room → 델리게이트 → Session 강참조가 생긴다. Session은 또 _room으로 Room을 강참조해 양방향이 된다. 구독을 해지하지 않으면 이 그래프가 GC 루트에서 도달 가능한 한 통째로 살아남는다. (전형적인 "이벤트 핸들러 누수".)

(B) Session._room 강참조 + SendToRoom 무방비 사용 (분류: 메모리/정확성)

  • 증상: (A)와 함께 상호 참조를 완성. 또한 (A)를 고쳐 구독을 끊어도, 룸이 이미 종료된 뒤 비동기 콜백이 SendToRoom을 호출하면 종료된 룸을 건드린다(잘못된 브로드캐스트, 또는 null 참조).
  • 재현조건: 네트워크 스레드의 지연된 콜백이 룸 제거 이후 도착.
  • 근본원인: Session이 Room을 직접 강참조로 들고 항상 살아있다고 가정한다. 약한 참조(WeakReference<Room>)나 ID 기반 조회로 바꾸고, 사용 시 생존을 확인해야 한다.

(C) RoomManager.RemoveRoom 이 맵에서만 Remove (분류: 메모리/유지보수)

  • 증상: 맵에서 지워도 객체가 안 죽는다(=A·B의 직접 발현 지점).
  • 근본원인: 진짜 참조 그래프가 맵 하나로 끝나지 않고 Session들의 이벤트 구독·역참조에 분산되어 있다. Remove는 "맵이 들고 있던 한 개의 참조"만 줄일 뿐, 이벤트 구독과 상호 참조가 남으면 무의미하다. 소유권/구독 수명 관리가 비대칭이라는 신호다.

수정안

원칙: 이벤트 구독은 반드시 해지하고(IDisposable/명시적 Leave), 역참조는 약하게 또는 ID로. 룸 종료 시 구독을 끊어 그래프를 분리한다.

public sealed class Session : IDisposable
{
    private readonly int _id;
    private Room _room;
    private Action<string> _handler;   // 해지용으로 보관

    public Session(int id) { _id = id; }
    public int Id => _id;

    public void JoinRoom(Room room)
    {
        _room = room;
        _handler = HandleBroadcast;     // 동일 델리게이트 인스턴스로 +=/-= 짝 맞춤
        room.OnBroadcast += _handler;   // (A) 구독
    }

    public void LeaveRoom()
    {
        if (_room != null && _handler != null)
        {
            _room.OnBroadcast -= _handler;  // (A) 구독 해지 → 강참조 끊김
            _handler = null;
            _room = null;
        }
    }

    private void HandleBroadcast(string msg) => Console.WriteLine($"[session {_id}] {msg}");

    public void SendToRoom(string msg) => _room?.Broadcast(msg); // (B) null 안전
    public void Dispose() => LeaveRoom();
}
public sealed class Room
{
    private readonly int _id;
    private readonly List<Session> _sessions = new();
    public event Action<string> OnBroadcast;

    public Room(int id) { _id = id; }
    public int Id => _id;

    public void AddSession(Session s) { _sessions.Add(s); s.JoinRoom(this); }

    public void Broadcast(string msg) => OnBroadcast?.Invoke($"room {_id}: {msg}");

    // (C) 종료 시 모든 세션 구독 해지 → 그래프 분리
    public void Close()
    {
        foreach (var s in _sessions) s.LeaveRoom();
        _sessions.Clear();
        OnBroadcast = null;   // 남은 델리게이트 체인도 끊음
    }
}

public void RemoveRoom(int id)   // RoomManager
{
    // (C) 맵에서 지우기 전에 구독/참조를 끊는다 → 이제 GC 가능
    if (_rooms.TryGetValue(id, out var room))
    {
        room.Close();
        _rooms.Remove(id);
    }
}

핵심 변경:

  • (A) += 한 곳마다 반드시 -=. 동일 델리게이트 인스턴스(_handler)를 보관해 짝을 맞춘다(+= HandleBroadcast-= HandleBroadcast는 매번 새 델리게이트라 동작하지만, 캡처가 있으면 인스턴스 보관이 안전).
  • (C) Room.Close()로 결정적 시점에 모든 구독을 해지하고 상호 참조를 끊는다.
  • (B) _room?. null 조건 연산자로 종료 후 호출을 안전하게.

더 나은 설계

  1. 구독 수명 일원화(권장): "이벤트를 구독한 객체가 자신의 해지 책임을 진다"를 컨벤션으로. JoinRoom에서 구독했으면 LeaveRoom/Dispose에서 반드시 해지. using/IDisposable 패턴으로 강제.

  2. 약한 이벤트(weak event) 패턴: 구독자를 약하게 참조하는 이벤트 관리자(WeakReference<> 기반, WPF의 WeakEventManager 류)를 두면 구독자가 GC될 때 자동 정리. 트레이드오프: 구현 복잡도 + 약참조 조회 비용. 게임서버에선 보통 명시적 해지가 더 예측 가능.

  3. 핸들/ID 기반 참조: Session이 Room 참조 대신 RoomId만 들고, 사용 시 RoomManager.Get(id)로 조회. 수명 결합을 끊어 비동기 환경에서 가장 견고. 트레이드오프: 조회 비용 + 매니저 동시성 보호.

  4. 누수 진단: dotnet-gcdump/dotnet-counters로 Gen2 객체 수 증가 관찰, 메모리 프로파일러(dotMemory, PerfView)에서 "GC 루트로부터의 보존 경로(retention path)"를 보면 이벤트 델리게이트가 객체를 붙잡는 사슬이 그대로 보인다. 누수의 1차 의심은 항상 "구독했는데 해지 안 한 이벤트".

면접 포인트

  1. C#에서 이벤트(델리게이트) 구독이 왜 메모리 누수를 일으키나? "장수명 publisher + 단명 subscriber"가 위험한 이유를 GC 도달 가능성 관점에서 설명하라. C++의 shared_ptr 순환참조 누수와 무엇이 같고 무엇이 다른가(추적 GC vs refcount)?
  2. += handler-= handler로 정확히 해지하려면 무엇을 주의해야 하나? 람다/캡처를 구독하면 왜 해지가 까다로운가?
  3. C# GC는 객체 사이클(A↔B 순수 참조)을 수거할 수 있는데도 이 코드가 누수인 이유는? (이벤트 소스가 GC 루트 사슬에 도달 가능). 약한 이벤트 패턴과 명시적 해지의 트레이드오프는?