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

난이도 중 해설 보기 →

결함을 모두 찾고 원인·수정안·더 나은 설계를 제시하라. 마커 (A)(B) 는 주목 위치 힌트다.

결함 코드 · C#
// ============================================================================
// 시나리오
// ----------------------------------------------------------------------------
// 캐주얼 게임의 매치 룸(Room) 시스템이다.
//  - Room 은 자기 안의 Session(플레이어 접속) 들을 관리한다.
//  - 각 Session 은 "룸 이벤트(브로드캐스트 등)"를 받아야 해서, 룸의 이벤트에
//    구독(이벤트 핸들러 등록)한다.
//  - 매치가 끝나면 RoomManager 가 Room 을 제거하고, Room 객체와 그 안의
//    모든 Session 이 깔끔하게 GC 회수되어야 한다.
//  - 접속 종료 콜백은 네트워크 스레드에서 비동기로 들어올 수 있다.
//
// 운영 중 증상:
//  - 룸을 반복 생성/제거하는 워크로드에서 시간이 갈수록 메모리가 단조 증가한다.
//  - 매니저 맵에서 룸을 지워도 Room/Session 이 GC 되지 않는다(파이널라이저 미실행).
//  - 가끔 이미 종료된 룸으로 메시지를 보내려다 예외/잘못된 동작이 난다.
//
// 요구사항
// ----------------------------------------------------------------------------
//  - 룸이 RoomManager 에서 제거되면 Room/Session 이 GC 가능해야 한다.
//  - 세션은 자기 룸으로 메시지를 보낼 수 있어야 한다(Broadcast).
//  - 비동기 콜백에서 룸을 다뤄도 안전해야 한다.
//
// 과제
// ----------------------------------------------------------------------------
// 이 코드를 코드리뷰하라.
//  1) 룸을 반복 생성/제거하면 메모리가 어떻게 되는가? 왜?
//  2) (A)(B)(C) 지점의 결함을 각각 설명하라.
//  3) 수명(lifetime)이 안전하도록 이벤트 구독/참조를 수정하라.
// ============================================================================

using System;
using System.Collections.Generic;

namespace MatchRoom
{
    public sealed class Session
    {
        private readonly int _id;
        // (A) 세션이 자기 룸을 강한 참조로 보관
        private Room _room;

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

        public void JoinRoom(Room room)
        {
            _room = room;
            // (A) 룸의 이벤트에 구독 → Room 이 Session 핸들러를 강하게 붙잡는다
            room.OnBroadcast += HandleBroadcast;
        }

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

        public void SendToRoom(string msg)
        {
            // (B) room 이 살아있다고 가정하고 그냥 사용
            _room.Broadcast(msg);
        }
    }

    public sealed class Room
    {
        private readonly int _id;
        // (B) 룸도 세션들을 강하게 보관
        private readonly List<Session> _sessions = new();

        // Session 들이 여기에 구독한다(델리게이트가 Session 인스턴스를 강하게 캡처)
        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); // (A)
        }

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

    public sealed class RoomManager
    {
        private readonly Dictionary<int, Room> _rooms = new();

        public Room CreateRoom(int id)
        {
            var room = new Room(id);
            _rooms[id] = room;
            return room;
        }

        // 매치 종료 시 호출
        public void RemoveRoom(int id)
        {
            // (C) 맵에서만 지운다. 세션↔룸 상호 참조/구독은 그대로 남는다.
            _rooms.Remove(id);
        }

        public Room Get(int id) => _rooms.TryGetValue(id, out var r) ? r : null;
    }

    // 데모용 구동 코드
    public static class Demo
    {
        public static void Run()
        {
            var mgr = new RoomManager();

            for (int r = 0; r < 3; r++)
            {
                var room = mgr.CreateRoom(r);
                for (int s = 0; s < 2; s++)
                {
                    var sess = new Session(r * 10 + s);
                    room.AddSession(sess);
                }
                room.Broadcast("welcome");
                mgr.RemoveRoom(r); // 매치 종료
                // 여기서 room/session 이 GC 가능해져야 정상
            }

            GC.Collect();
            GC.WaitForPendingFinalizers();
            Console.WriteLine("done");
        }
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

작성 후 위 해설 보기에서 모범 해설과 대조하세요.