27. 세션 타이머/지연 콜백의 수명 관리 결함 (C#)
난이도 상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 세션 타이머/지연 콜백의 수명 관리 결함 (C#)
난이도: 상
요약
세션 생성 시 System.Threading.Timer를 만들지만 Close()에서 이 타이머를 멈추지(Dispose) 않는다. 그 결과 (1) 종료된 세션의 타이머가 계속 발화해 닫힌 소켓에 Send를 시도하고, (2) 타이머가 콜백을 통해 세션을 루트로 잡고 있어 세션이 GC되지 못하고 누적되며, (3) 종료 처리와 콜백이 병행 실행되어 종료 직후 상태에 접근한다.
문제점
- [자원 수명 누수: 타이머 미취소] (B) —
Close()는 소켓을 닫고 매니저에서 제거하지만_idleTimer.Dispose()를 호출하지 않는다.- 증상 1(닫힌 소켓 접근): 다음 주기에
OnIdleCheck가 발화해_socket.Send(...)→ 이미 닫힌 소켓이라ObjectDisposedException/SocketException. 콜백이 ThreadPool에서 던지는 예외는 잡히지 않으면 프로세스를 위협하고, 매 5초마다 반복된다. - 증상 2(세션 누수/GC 불가):
Timer는 콜백 델리게이트(OnIdleCheck=this캡처)를 통해Session을 강하게 참조한다. 매니저에서 제거해도 살아있는Timer가 세션을 루트로 잡아 GC 대상이 되지 않는다. 닫힌 소켓·버퍼까지 함께 살아남아 누수가 누적되고, 활성 타이머가 계속 쌓여 ThreadPool 부담·동접 통계 오류로 번진다.
- 증상 1(닫힌 소켓 접근): 다음 주기에
- [종료-콜백 경합] (A) — 콜백이 ThreadPool에서 돌므로
Close()와 동시에 실행될 수 있다._closed플래그를 콜백이 확인하지 않아, 종료 도중/직후OnIdleCheck가 소켓·상태에 접근한다(check 누락). - 근본원인: 타이머(지연 콜백)의 수명이 세션 수명과 결합되어 있지 않다. 세션을 만들 때 타이머를 걸었으면, 세션을 닫을 때 반드시 타이머를 취소해야 한다.
수정안
종료 시 타이머를 Dispose로 확실히 멈추고(가능하면 콜백 완료까지 대기), 콜백 안에서도 종료 여부를 방어적으로 확인한다.
public void Close()
{
if (Interlocked.Exchange(ref _closedFlag, 1) == 1) return; // 중복 종료 방지(멱등)
_closed = true;
// 타이머를 먼저 멈춘다. WaitHandle 오버로드는 진행 중 콜백 완료까지 대기.
using (var done = new ManualResetEvent(false))
{
if (_idleTimer.Dispose(done)) // false면 이미 Dispose됨
done.WaitOne();
}
_socket.Close();
SessionManager.Remove(PlayerId);
}
private void OnIdleCheck(object? state)
{
if (_closed) return; // 종료 경합 방어
try
{
if ((DateTime.UtcNow - LastActivity).TotalSeconds > 30)
_socket.Send(_pingBytes);
}
catch (Exception) { /* 종료/소켓 오류는 로깅 후 종료 경로로 */ }
}
Timer.Dispose(WaitHandle)는 "진행 중인 콜백이 끝날 때까지" 보장하므로, 그 뒤에 소켓을 닫으면 콜백-소켓 경합이 사라진다.- 더 깔끔하게는
CancellationTokenSource+Task.Delay루프나PeriodicTimer(.NET 6+)를 쓰고,Close()에서cts.Cancel().PeriodicTimer는 루프 기반이라 수명·예외 처리가 명확하다.
더 나은 설계
- 수명 소유권을 한곳으로: 세션을
IAsyncDisposable로 만들고DisposeAsync에서 타이머 취소·소켓 종료·등록 해제를 원자적으로 수행. "열 때 잡은 자원은 닫을 때 모두 반납"을 한 메서드에 모은다.- 트레이드오프: 콜백 완료 대기(
done.WaitOne())는 종료 지연을 약간 늘릴 수 있다. 대량 종료(서버 드레이닝) 시에는 비동기 취소(PeriodicTimer+토큰)가 더 매끄럽다.
- 트레이드오프: 콜백 완료 대기(
- 중앙 타이머 휠: 세션마다
Timer를 두는 대신 중앙 휠에 (sessionId, deadline)만 등록하고 발화 시 약한 조회(매니저에서 sessionId로 lookup, 없으면 무시)로 처리하면, 세션 객체를 콜백이 강참조하지 않아 누수·UAF성 접근이 원천 차단된다.
면접 포인트
- "
Timer가 왜 세션을 메모리에 살려두나?" — 콜백 델리게이트가 인스턴스 메서드면 타깃(this)을 강참조하고, 활성Timer는 콜백을 루트로 잡는다. 그래서Dispose없이는 GC가 회수하지 못한다. - 콜백-종료 경합:
Timer.Dispose(WaitHandle)로 진행 중 콜백 완료를 보장한 뒤 자원을 닫는 패턴, 그리고 콜백 내_closed방어 확인을 함께 설명.
해설 · C++
해설 — 세션 타이머/지연 콜백의 수명 관리 결함 (C++)
난이도: 상
요약
세션 생성 시 타이머 휠에 세션 포인터를 캡처한 콜백을 등록하는데, closeSession()에서 그 타이머를 취소하지 않고 세션을 delete 한다. 이후 wheel_.tick()이 만기 콜백을 발화하면 **이미 해제된 Session* 을 역참조(use-after-free)**한다.
문제점
- [타이머 미취소 + 해제 객체 참조] (A)+(B) —
openSession은 람다[this, s]{ onIdleCheck(s); }를 휠에 등록(A)하지만,closeSession은closeSocket후 곧장delete s(B)만 하고 그 타이머를wheel_.cancel(...)하지 않는다.- 증상:
delete s이후 다음tick()에서 만기 콜백이onIdleCheck(s)→s->lastActivity/s->fd접근 = use-after-free. 운이 좋으면 즉시 크래시, 나쁘면 그 메모리에 재할당된 다른 세션을 오염시켜 신원 혼동·임의 동작(보안 사고). - 게다가
openSession은 타이머 id를 보관조차 하지 않아, 취소하려 해도 취소할 핸들이 없다(설계 단계의 누락).
- 증상:
- [수명 결합 부재] — 타이머(지연 콜백)가 세션 객체의 생존을 가정하는데, 세션 해제 시 그 가정을 무효화(취소)하는 단계가 없다. 콜백이 raw 포인터를 캡처해 소유권·생존 보장이 전혀 없다.
- 근본원인: 콜백이 참조하는 객체의 수명 > 콜백 등록의 수명이 보장되지 않음. raw
Session*캡처 + 취소 없는delete.
수정안
타이머 id를 세션이 보유하고, 소멸 시 반드시 취소한다. 더 견고하게는 콜백이 약한 참조(weak_ptr)를 캡처해, 발화 시 세션이 살아있을 때만 동작하게 한다.
// 1) 소유를 shared_ptr 로, 콜백은 weak_ptr 캡처
std::shared_ptr<Session> openSession(int fd, uint64_t pid) {
auto s = std::make_shared<Session>(Session{fd, pid, now(), false});
sessions_[pid] = s;
std::weak_ptr<Session> ws = s;
s->timerId = wheel_.schedule(now() + 5000, [this, ws]() {
if (auto sp = ws.lock()) onIdleCheck(sp.get()); // 살아있을 때만
});
return s;
}
void closeSession(uint64_t pid) {
auto it = sessions_.find(pid);
if (it == sessions_.end()) return;
auto s = it->second;
wheel_.cancel(s->timerId); // (핵심) 타이머 먼저 취소
closeSocket(s->fd);
sessions_.erase(it); // 마지막 shared_ptr 해제 → 안전하게 소멸
}
weak_ptr캡처는 "취소 누락"이 한 번 더 일어나도 UAF를 막는 2차 방어다(발화 시lock()실패 → no-op).- 다중 스레드면
wheel_.tick()이 콜백을 발화하는 동안closeSession이 동시에 돌 수 있으므로, 휠 자료구조와sessions_접근에 락이 필요하고, "취소했는데 이미 발화 직전"인 경합은weak_ptr/세대 토큰으로 흡수한다.
더 나은 설계
- 세대 토큰(generation/handle): 세션을 인덱스+세대로 식별(
{slot, gen})하고 콜백은 핸들만 캡처. 발화 시 슬롯의 현재 세대와 비교해 불일치면 무시. 포인터를 들고 다니지 않아 UAF가 구조적으로 불가능.- 트레이드오프:
shared_ptr/weak_ptr는 간단하지만 제어블록·원자적 refcount 비용. 핸들+세대는 추가 코드가 들지만 할당/원자연산을 피해 핫패스에 유리.
- 트레이드오프:
- 수명 소유 일원화:
openSession에서 잡은 모든 지연 작업(타이머·in-flight I/O)을 세션이 소유하고 소멸자/close에서 일괄 취소. "열 때 등록한 콜백은 닫을 때 모두 취소"를 불변식으로.
면접 포인트
- raw 포인터를 캡처한 지연 콜백의 위험: 콜백 발화 시점에 객체가 살아있다는 보장이 없으면 UAF. 해법은 ①취소(수명 결합) ②
weak_ptr/세대 토큰(발화 시 생존 확인) — 보통 둘을 함께. - "delete 했는데 왜 크래시가 아니라 더 위험한가?" — 해제된 메모리가 다른 세션으로 재할당되면 콜백이 엉뚱한 세션을 건드려 조용한 데이터 오염·권한 혼동이 된다. 즉시 크래시보다 추적이 어렵다.