← 문제로

27. 세션 타이머/지연 콜백의 수명 관리 결함 (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 부담·동접 통계 오류로 번진다.
  • [종료-콜백 경합] (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 방어 확인을 함께 설명.