2. Room/Session 이벤트 핸들러 누수와 상호 참조
난이도 중해설 — Room/Session 이벤트 핸들러 누수와 상호 참조
난이도: 중
요약
Room이 Session을 리스트로 보관하고, Session은 Room을 필드로 보관하며, 게다가 Session이 Room.OnBroadcast 이벤트에 구독한다. C#의 이벤트(델리게이트)는 구독자(Session)를 강하게 참조하므로, Room이 살아있는 한 구독한 Session도 GC되지 않는다. 동시에 Session도 Room을 강하게 붙잡아 상호 강참조 + 이벤트 구독 누수가 생긴다. 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 조건 연산자로 종료 후 호출을 안전하게.
더 나은 설계
-
구독 수명 일원화(권장): "이벤트를 구독한 객체가 자신의 해지 책임을 진다"를 컨벤션으로.
JoinRoom에서 구독했으면LeaveRoom/Dispose에서 반드시 해지.using/IDisposable패턴으로 강제. -
약한 이벤트(weak event) 패턴: 구독자를 약하게 참조하는 이벤트 관리자(
WeakReference<>기반, WPF의WeakEventManager류)를 두면 구독자가 GC될 때 자동 정리. 트레이드오프: 구현 복잡도 + 약참조 조회 비용. 게임서버에선 보통 명시적 해지가 더 예측 가능. -
핸들/ID 기반 참조: Session이
Room참조 대신RoomId만 들고, 사용 시RoomManager.Get(id)로 조회. 수명 결합을 끊어 비동기 환경에서 가장 견고. 트레이드오프: 조회 비용 + 매니저 동시성 보호. -
누수 진단:
dotnet-gcdump/dotnet-counters로 Gen2 객체 수 증가 관찰, 메모리 프로파일러(dotMemory, PerfView)에서 "GC 루트로부터의 보존 경로(retention path)"를 보면 이벤트 델리게이트가 객체를 붙잡는 사슬이 그대로 보인다. 누수의 1차 의심은 항상 "구독했는데 해지 안 한 이벤트".
면접 포인트
- C#에서 이벤트(델리게이트) 구독이 왜 메모리 누수를 일으키나? "장수명 publisher + 단명 subscriber"가 위험한 이유를 GC 도달 가능성 관점에서 설명하라. C++의
shared_ptr순환참조 누수와 무엇이 같고 무엇이 다른가(추적 GC vs refcount)? += handler후-= handler로 정확히 해지하려면 무엇을 주의해야 하나? 람다/캡처를 구독하면 왜 해지가 까다로운가?- C# GC는 객체 사이클(A↔B 순수 참조)을 수거할 수 있는데도 이 코드가 누수인 이유는? (이벤트 소스가 GC 루트 사슬에 도달 가능). 약한 이벤트 패턴과 명시적 해지의 트레이드오프는?
해설 — Room/Session 순환참조 누수와 dangling 위험
난이도: 중
요약
Room이 Session을 shared_ptr로 소유하고, Session도 Room을 shared_ptr로 소유한다. 강한 참조 순환(reference cycle) 이 생겨 RemoveRoom으로 맵에서 지워도 참조 카운트가 0이 되지 않는다 → ~Room/~Session이 호출되지 않고 메모리/리소스가 영구 누수된다. enable_shared_from_this로 누수가 더 견고하게 굳는다. 또한 SendToRoom은 룸 수명을 확인하지 않고 역참조해 잠재적 dangling이다.
문제점
(A) Session::room_ 가 shared_ptr<Room> — 순환참조 (분류: 메모리)
- 증상: 루프를 돌릴수록 메모리가 단조 증가.
~Room/~Session이 한 번도 출력되지 않는다(맨 마지막mgr소멸 시점에도 안 풀림). - 재현조건:
Room.AddSession이s->SetRoom(shared_from_this())를 호출하는 순간부터 사이클 성립. - 근본원인:
Room→sessions_(shared) →Session→room_(shared) →Room으로 강한 참조가 원을 그린다.shared_ptr는 참조 카운트 기반이라 순환을 회수하지 못한다.RemoveRoom이 맵의 참조를 지워도 Room의 use_count는 여전히 ≥1(각 Session이 붙잡음), Session의 use_count도 ≥1(Room이 붙잡음). 서로가 서로를 살려둔다.
(B) Session::SendToRoom 의 무방비 역참조 — dangling 가능성 (분류: 정확성/수명)
- 증상: (A)를 weak_ptr로 고치고 나면, 룸이 이미 파괴된 상태에서 비동기 콜백이
SendToRoom을 호출할 때room_이 null/소멸 객체가 되어 널 역참조 또는 use-after-free. - 재현조건: 네트워크 스레드의 지연된 콜백이 룸 제거 이후 도착.
- 근본원인: 약참조로 바꾸면
room_이 가리키던 객체는 사라질 수 있는데, 코드는 항상 살아있다고 가정한다.weak_ptr::lock()으로 승격 후 null 검사해야 한다.
(C) RoomManager::RemoveRoom 이 맵에서만 erase (분류: 메모리/유지보수)
- 증상: 맵에서 지워도 객체가 안 죽는다(=A의 직접적 발현 지점).
- 근본원인: 진짜 소유 그래프가 맵 하나로 끝나지 않고 Session들에 분산되어 있다. erase는 "맵이 들고 있던 한 개의 강참조"만 줄일 뿐, 사이클이 남으면 무의미하다. 소유권 구조 자체가 잘못됐다는 신호다.
수정안
원칙: 소유 방향은 한쪽만 강하게(Room → Session), 역방향은 weak_ptr. 비동기 사용 지점은 lock()으로 승격.
class Session : public std::enable_shared_from_this<Session>
{
public:
explicit Session(int id) : id_(id) {}
~Session() { std::printf(" ~Session(%d)\n", id_); }
// (A) 약참조로 보관 → 사이클 차단
void SetRoom(const std::shared_ptr<Room>& room) { room_ = room; }
void SendToRoom(const std::string& msg)
{
// (B) 승격 후 생존 확인
if (auto room = room_.lock())
room->Broadcast(msg);
// else: 룸이 이미 종료됨 → 조용히 무시(또는 로깅)
}
int Id() const { return id_; }
private:
int id_;
std::weak_ptr<Room> room_; // 핵심 변경
};
Room/RoomManager는 강참조 소유를 유지하되, 제거 시 사이클이 없으므로 정상 회수된다.
void RoomManager::RemoveRoom(int id)
{
// (C) 이제 맵의 강참조 하나만 제거하면 use_count가 0이 되어 룸/세션 연쇄 해제됨.
// (외부에서 임시로 잡고 있지 않다는 전제. 필요하면 명시적 Room::Close()로 세션 정리.)
rooms_.erase(id);
}
추가 권장: Room에 명시적 Close()를 두어 sessions_.clear()로 결정적 해제 시점을 제어하면, 외부에 떠도는 임시 shared_ptr<Room>이 있어도 룸 내부 자원(소켓 등)은 즉시 정리할 수 있다.
더 나은 설계
-
소유권 방향 일원화(권장): "컨테이너가 소유, 자식은 부모를 약하게 본다"를 코드 컨벤션으로 못박는다. RoomManager → Room(강) → Session(강), Session → Room(약). dangling/순환을 구조적으로 차단.
-
핸들/ID 기반 참조: Session이
Room*이나shared_ptr대신RoomId만 들고, 사용 시RoomManager::Find(id)로 조회. 수명 결합을 끊어 비동기 환경에서 가장 견고. 트레이드오프: 조회 비용 + 매니저 동시성 보호 필요. -
enable_shared_from_this사용 시 주의: 객체는 반드시shared_ptr로 생성·관리돼야shared_from_this()가 유효하다. 스택/unique_ptr로 만든 객체에서 호출하면std::bad_weak_ptr. 본 코드는make_shared로 만들어 OK지만, 팀 컨벤션으로 강제할 것. -
순환 탐지/디버깅: 의심되면
use_count()를 찍거나 ASan/LeakSanitizer로 누수 위치를 확인. shared_ptr 사이클은 LeakSanitizer가 "여전히 도달 가능"으로 보고 누수로 안 잡힐 수도 있으므로(서로가 살려둠) 그래프 설계 리뷰가 1차 방어선.
트레이드오프: weak_ptr는 매 사용 시 lock()(원자적 카운트 증가)으로 약간의 비용이 있으나, 정확성과 비교하면 무시할 수준이다.
면접 포인트
shared_ptr순환참조가 왜 GC 없는 C++에서 누수가 되는가?weak_ptr는 control block을 어떻게 다루는가(weak count vs strong count, 객체 소멸과 control block 소멸 시점 차이)?weak_ptr::lock()이 스레드 안전한 이유와, 그래도 "lock 성공 후 사용 중 객체가 죽지 않는" 보장은 무엇 때문인가?enable_shared_from_this의 동작 원리와, 생성자 내부에서shared_from_this()를 호출하면 안 되는 이유는?