2. C# 룸 기반 게임의 좀비 세션 / 유령 플레이어
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — C# 룸 기반 게임의 좀비 세션 / 유령 플레이어
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
연결이 끊겨도 Room이 세션을 강한 참조로 계속 들고 있어 유령 플레이어가 남고,
끊김 시 이벤트 핸들러(OnBroadcast += s.Send) 구독을 해제하지 않아 세션이 GC되지
못하는 이벤트 핸들러 누수가 발생한다. 끊긴 세션에도 매 틱 Send가 호출되며, 락을
잡은 채 이벤트(미지의 코드)를 호출해 데드락/락 점유 위험이 있고, 디스커넥트(IO 스레드)와
Tick(로직 스레드) 사이 정리 순서가 정의돼 있지 않다.
문제점
(E) Room에서 멤버를 제거하지 않음 → 유령 플레이어 (분류: 수명관리/정확성)
- 증상:
OnDisconnect는SessionManager맵에서만 지우고Room._members/구독은 손대지 않는다. 끊긴 플레이어가 룸에 계속 보이고, 다른 유저 화면에 유령으로 남는다. - 재현조건: 룸 입장 중 클라가 끊김. 매니저에선 사라지지만 룸엔 그대로.
- 근본원인: 세션 정리가 "모든 참조 보유자"에 전파되지 않음. 매니저가 룸을 모르므로 단방향 정리만 일어난다.
(B) 이벤트 핸들러 누수 → 세션 GC 안 됨 (분류: 수명관리/메모리)
- 증상:
OnBroadcast += s.Send는Room의 멀티캐스트 델리게이트가 세션s를 강하게 참조하게 만든다(델리게이트 타깃이s). 끊겨도OnDisconnect가OnBroadcast -= s.Send를 하지 않으므로, 룸이 살아있는 한 세션은 GC 루트에 묶여 영원히 회수되지 않는다. C#에서 가장 흔한 메모리 누수 패턴(구독 해제 누락). - 재현조건: 입장/퇴장이 반복되는 장시간 운영. 룸의 인보케이션 리스트가 죽은 세션으로 계속 자라 GC가 못 줄임 → 힙 우상향.
- 근본원인: 이벤트 구독의 수명이 객체 수명과 결합. publisher(Room)가 subscriber
(Session)보다 오래 살면 명시적
-=가 필수.
(C) 끊긴 세션에 계속 Send + 락 보유 중 외부 호출 (분류: 정확성/성능)
- 증상:
Tick이Connected == false인 세션(아직 구독 해제 안 됨)에도Send를 호출. 송신 큐가 쌓이고 CPU를 낭비. 또_lock을 잡은 채OnBroadcast?.Invoke로 미지의 코드(Send/외부 핸들러) 를 호출 → Send가 내부 락을 잡으면 데드락 위험, 느린 전송이 락을 오래 점유해 Enter가 막힘. - 근본원인: 끊김 상태를 Tick이 검사하지 않음 + "락 잡고 모르는 코드 호출" 원칙 위반.
(D)+(E) 디스커넥트와 Tick의 경합 / 정리 순서 미정의 (분류: 동시성)
- 증상: IO 스레드가
OnDisconnect로Connected=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) 설계 필요, 크로스룸 작업은 메시지 패싱.
면접 포인트
- "C#에서 이벤트(
+=) 구독이 메모리 누수를 일으키는 원리는?" → publisher의 멀티캐스트 델리게이트가 subscriber를 강하게 참조한다. publisher가 더 오래 살면 subscriber가 GC 루트에 묶여 회수 안 됨. 해결: 객체 소멸 시-=로 명시 구독 해제, 또는 weak event/약한 참조 패턴. - "끊김 처리가 IO 스레드, 브로드캐스트가 로직 스레드면 race를 어떻게 막나?"
→ 상태 전이를
Interlocked로 단일 승자만 처리하게 하고, 실제 제거 작업은 로직 스레드 잡 큐로 넘겨 순서를 직렬화. Tick은Volatile플래그로 끊김 세션을 건너뜀. - "좀비 세션을 운영 중에 어떻게 조기 발견하나?" → 세션/룸 객체 수(gauge) 메트릭, "끊김 이벤트 수 vs 활성 세션 수" 불일치 알람, 주기적 reaper. 메모리 누수는 dotnet-gcdump/dotnet-counters로 힙 증가와 델리게이트 인보케이션 리스트 길이를 확인.
해설 · C++
해설 — C++ 룸 기반 게임의 좀비 세션 / 유령 플레이어
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
연결이 끊겨도 Room이 세션을 강한 참조로 계속 들고 있어 유령 플레이어가 남고,
Session ↔ Room 양방향 shared_ptr 순환참조로 메모리가 영원히 회수되지 않는다.
끊긴 세션에도 매 틱 Send가 호출되며, 디스커넥트(IO 스레드)와 Tick(로직 스레드)
사이 정리 순서가 정의돼 있지 않다.
문제점
(D) Room에서 멤버를 제거하지 않음 → 유령 플레이어 (분류: 수명관리/정확성)
- 증상:
OnDisconnect는SessionManager맵에서만 지우고Room::members_는 손대지 않는다. 끊긴 플레이어가 룸에 계속 보이고, 다른 유저 화면에 유령으로 남는다. - 재현조건: 룸 입장 중 클라가 끊김. 매니저에선 사라지지만 룸엔 그대로.
- 근본원인: 세션 정리가 "모든 참조 보유자"에 전파되지 않음. 매니저가 룸을 모르므로 단방향 정리만 일어난다.
(A) Session ↔ Room 순환참조 → 메모리 누수 (분류: 수명관리/메모리)
- 증상:
Session.room이shared_ptr<Room>,Room.members_가shared_ptr<Session>. 서로의 refcount를 1 이상으로 묶어 둘 다 절대 0이 안 됨. - 재현조건: 룸이 비어 해제돼야 할 시점에도 멤버가 룸을, 룸이 멤버를 붙잡고 있음.
- 근본원인: 소유 방향이 양방향. 누수가 룸/세션 단위로 누적되어 장시간 운영 시 RSS가 우상향(전형적인 운영 중 메모리 누수 패턴).
(B) 끊긴 세션에 계속 Send + 락 보유 중 외부 호출 (분류: 정확성/성능)
- 증상:
Tick이connected == false인 세션에도Send를 호출. 송신 큐가 쌓이고 CPU를 낭비. 또mtx_를 잡은 채Send(미지의 코드)를 호출 → Send가 내부 락을 잡으면 데드락 위험, 느린 전송이 락을 오래 점유해 Enter가 막힘. - 근본원인: 끊김 상태를 Tick이 검사하지 않음 + "락 잡고 모르는 코드 호출" 원칙 위반.
(C)+(D) 디스커넥트와 Tick의 경합 / 정리 순서 미정의 (분류: 동시성)
- 증상: IO 스레드가
OnDisconnect로connected=false를 쓰는 동안 로직 스레드는Tick에서 같은 세션을 순회.connected플래그가 락 밖에서 갱신되어 가시성 보장 없음. 어느 쪽이 먼저인지 정의되지 않아 한 틱 동안 유령이 한 번 더 브로드캐스트됨. - 근본원인: 세션 상태 전이와 멤버 제거가 서로 다른 락(매니저 락 vs 룸 락) 아래 분산 수행되며 순서가 조율되지 않음.
수정안
핵심: 순환참조를 weak로 끊고, 디스커넥트가 룸에서 즉시 제거 + Tick은 끊김 검사
class Room;
class Session : public std::enable_shared_from_this<Session> {
public:
uint64_t id;
std::atomic<bool> connected{true}; // 원자적 상태 플래그
std::weak_ptr<Room> room; // (A) 역방향은 weak → 순환 끊기
void Send(const std::vector<char>& data) { /* 비동기 송신 */ }
};
class Room : public std::enable_shared_from_this<Room> {
std::mutex mtx_;
std::vector<std::shared_ptr<Session>> members_; // 룸이 멤버를 "소유"
public:
void Enter(const std::shared_ptr<Session>& s) {
std::lock_guard<std::mutex> lk(mtx_);
members_.push_back(s);
s->room = weak_from_this();
}
void Leave(uint64_t id) {
std::lock_guard<std::mutex> lk(mtx_);
std::erase_if(members_, [&](auto& s){ return s->id == id; }); // C++20
}
void Tick(const std::vector<char>& snapshot) {
// 1) 락 안에서 살아있는 멤버 스냅샷만 추린다.
std::vector<std::shared_ptr<Session>> alive;
{
std::lock_guard<std::mutex> lk(mtx_);
// 죽은 멤버는 이 김에 청소(지연 정리, lazy reaping)
std::erase_if(members_, [](auto& s){ return !s->connected.load(); });
alive.reserve(members_.size());
for (auto& s : members_)
if (s->connected.load()) alive.push_back(s);
}
// 2) 락 밖에서 송신(미지의 코드 호출을 락 밖으로)
for (auto& s : alive) s->Send(snapshot);
}
};
class SessionManager {
std::mutex mtx_;
std::unordered_map<uint64_t, std::shared_ptr<Session>> sessions_;
public:
void Add(const std::shared_ptr<Session>& s) {
std::lock_guard<std::mutex> lk(mtx_);
sessions_[s->id] = s;
}
void OnDisconnect(uint64_t id) {
std::shared_ptr<Session> s;
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = sessions_.find(id);
if (it == sessions_.end()) return;
s = std::move(it->second);
sessions_.erase(it);
}
// 매니저 락을 푼 뒤(데드락 회피) 상태 전이 + 룸에서 제거
s->connected.store(false);
if (auto r = s->room.lock()) // weak → shared 승격
r->Leave(id); // (D) 룸에서도 제거 → 유령 제거
}
};
포인트
- 소유 방향 정리: Room이 Session을 소유(
shared_ptr), Session→Room은weak_ptr. 순환이 끊겨 룸이 비면 멤버가 정상 해제되고, 세션도 매니저/룸에서 빠지면 해제됨. - 디스커넥트가 룸에서 즉시 제거:
room.lock()->Leave(id)로 유령 제거. - Tick의 lazy reaping: 혹시 누락된 죽은 멤버도 다음 틱에 청소되어 누수 2차 방어.
- 락 밖 송신 + atomic 플래그로 데드락/가시성 문제 해소.
OnDisconnect에서 매니저 락을 먼저 풀고 룸 락을 잡아 락 중첩(데드락) 회피.
더 나은 설계
1) 단일 소유자 + ID 핸들 모델
- 세션의 "유일한 소유자"는 SessionManager 하나로 두고, Room/다른 시스템은
weak_ptr또는sessionId(정수 핸들)만 보유. 참조하려면 매번 manager에서 조회. - 트레이드오프: 조회 비용·락이 늘지만 수명 규칙이 명확해지고 순환참조가 원천 차단. 대규모 서버에서 "정수 핸들 + 세대(generation) 카운터"는 dangling을 잡는 표준 기법.
2) 세션 상태머신 + 명시적 정리 파이프라인
Active → Disconnecting → Removed. 디스커넥트는 한 번만 전이 성공 (compare_exchange), 그 스레드가 "모든 보유자에서 제거" 작업을 큐에 넣는다.- 정리 순서를 한 곳(로직 스레드)에서 직렬화하면 IO 스레드와의 경합이 사라진다.
3) 룸 작업을 액터/잡 큐로 직렬화
- Room 의 Enter/Leave/Tick 을 단일 스레드 잡 큐로 처리하면
mtx_자체가 사라지고 "끊김 처리 vs 브로드캐스트" 순서가 큐 순서로 결정적이 된다. - 트레이드오프: 룸별 스레드 친화도(affinity) 설계 필요, 크로스룸 작업은 메시지 패싱.
면접 포인트
- "
shared_ptr순환참조는 어떻게 탐지/예방하나?" → 소유 방향을 단방향으로 강제(소유=shared, 역참조=weak). 코드리뷰/정적분석으로 양방향 shared 패턴 차단. 운영 중에는 RSS 우상향 + 객체 카운터로 누수 모니터링. - "끊김 처리가 IO 스레드, 브로드캐스트가 로직 스레드면 race를 어떻게 막나?" → 상태 전이를 atomic CAS로 단일 승자만 처리하게 하고, 실제 제거 작업은 로직 스레드 잡 큐로 넘겨 순서를 직렬화. Tick은 atomic 플래그로 끊김 세션을 건너뜀.
- "좀비 세션을 운영 중에 어떻게 조기 발견하나?" → 세션/룸 객체 수(gauge) 메트릭, "끊김 이벤트 수 vs 활성 세션 수" 불일치 알람, 주기적 reaper가 weak_ptr 만료 비율을 로깅. 메모리 누수는 heaptrack/ASAN으로 확인.