← 문제로

6. C# 죽은 연결 감지: TCP KeepAlive 설정 vs 앱 레벨 Ping

난이도 하
내 리뷰 · C#
해설 · C#

해설 — C# 죽은 연결 감지: TCP KeepAlive 설정 vs 앱 레벨 Ping

난이도: 하

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

"OS의 TCP KeepAlive 만 켜면 죽은 연결을 알아서 정리해 준다"는 잘못된 가정이 핵심이다.

  1. KeepAlive 의 기본 주기는 분~시간 단위 — 게임에 필요한 수십 초 감지를 못 한다.
  2. KeepAliveValues IOControl 의 단위·플랫폼 의존성 — 코드의 의도(10s/5s)와 실제 설정값/동작이 어긋난다.
  3. KeepAlive 는 half-open 의 일부만 잡는다 — NAT 가 프로브를 통과시키거나, 단방향 경로 고장은 못 잡는다. 또 게임은 RTT 측정·상태 점검 등 앱 레벨 정보가 필요하다.
  4. 정리 트리거가 "다음 수신/송신 시도"에 묶여 있다 — 조용한 연결은 능동 트래픽이 없어 에러를 영영 안 볼 수 있다(좀비 영속).

결론: 앱 레벨 ping/pong(하트비트) 이 1차 방어선이어야 하고, TCP KeepAlive 는 보조다.


문제점

(A)(B) KeepAlive 기본 주기가 너무 길다 (분류: 정확성/운영)

  • 증상: SO_KEEPALIVE만 켜면 OS 기본값(예: Windows 2시간 idle 후 시작, Linux tcp_keepalive_time 7200초)이 적용된다. 운영팀이 원한 "수십 초 내 정리"와 동떨어진다.
  • 재현조건: 단말이 사라진 뒤 1~2시간 동안 연결이 그대로 살아있음.
  • 근본원인: KeepAlive 는 연결 유지/극단적 사망 감지용이지, 게임용 빠른 장애 감지용이 아니다. 그래서 (C)에서 파라미터를 직접 만진 것인데, 거기에 또 함정이 있다.

(C) KeepAliveValues 단위·플랫폼 의존 (분류: 이식성/정확성)

  • 증상: Windows 의 tcp_keepalive 구조체(IOControl KeepAliveValues)는 keepalivetime/keepaliveinterval 이 "밀리초" 단위다(여기서는 우연히 맞다). 하지만 재시도 횟수는 이 구조체로 제어할 수 없고 OS 전역값을 따른다 → 실제 "끊김까지 걸리는 총 시간"은 time + interval × (전역 probe count) 로 결정된다. 의도한 "10초+5초면 곧 끊긴다"가 성립하지 않는다.
  • 이식성: 이 코드는 Windows 전용이다. Linux/.NET 에서는 IOControl KeepAliveValues 가 동작하지 않거나 의미가 다르다. .NET Core 3.0+ 라면 TcpKeepAliveTime/Interval/Retrycount 소켓 옵션(초 단위)을 써야 이식성이 있다.
  • 근본원인: OS/런타임 버전마다 keepalive 설정 API·단위·세부 동작이 제각각.

(B)/(A) half-open 을 KeepAlive 만으로는 못 잡는다 (분류: 정확성)

  • 증상: 단말이 갑자기 사라지면 한쪽(서버→클라)으로는 데이터가 안 빠지지만, KeepAlive 프로브가 NAT/방화벽에서 통과(또는 응답 위조) 되거나, 클라 OS 가 살아있어 ACK 만 돌려주면 연결이 "살아있다"고 오판될 수 있다. 또 앱이 멈췄지만 TCP 스택은 살아있는 경우(앱 데드락)도 KeepAlive 로는 못 잡는다.
  • 근본원인: TCP KeepAlive 는 TCP 스택 수준 신호다. "앱이 실제로 반응하는가"는 앱 레벨 ping/pong 으로만 알 수 있다.

(D) 정리 트리거가 능동 I/O 에 의존 (분류: 수명관리)

  • 증상: 죽음 판정이 "다음 recv/send 에서 에러가 나면" 으로 묶여 있다. 조용한 연결은 보낼 것도 받을 것도 없어 그 시도 자체가 안 일어난다 → 에러를 영영 못 봄. KeepAlive 가 RST 를 유발해도, 읽기를 걸어두지 않으면(pending recv 없음) 에러를 관측할 경로가 없을 수 있다.
  • 근본원인: 장애 감지를 수동적(passive)으로 설계. 능동적(active) 하트비트 부재.
  • 부수: OnRecvError → Cleanup 이 멱등하지 않다. KeepAlive RST + 앱 타임아웃이 동시에 정리를 부르면 이중 정리/이중 Close 가능.

수정안

핵심: 앱 레벨 하트비트를 1차로, KeepAlive 는 보조로, 정리는 멱등으로

public sealed class LobbyConnection
{
    private readonly Socket _socket;
    private long _lastInboundTicks = Environment.TickCount64;   // 단조 시계
    private int _cleaned = 0;

    // 앱 레벨 파라미터: 5초마다 ping, 15초 무응답이면 죽음 판정
    private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(5);
    private static readonly TimeSpan DeadAfter    = TimeSpan.FromSeconds(15);

    public LobbyConnection(Socket socket)
    {
        _socket = socket;
        EnableKeepAliveBackup();   // 보조 안전망
    }

    private void EnableKeepAliveBackup()
    {
        _socket.SetSocketOption(SocketOptionLevel.Socket,
                                SocketOptionName.KeepAlive, true);
        // .NET Core 3.0+ : 이식성 있는 초 단위 API
        _socket.SetSocketOption(SocketOptionLevel.Tcp,
            SocketOptionName.TcpKeepAliveTime, 30);      // 30초 idle 후 시작
        _socket.SetSocketOption(SocketOptionLevel.Tcp,
            SocketOptionName.TcpKeepAliveInterval, 5);   // 5초 간격
        _socket.SetSocketOption(SocketOptionLevel.Tcp,
            SocketOptionName.TcpKeepAliveRetryCount, 4); // 4회 실패면 끊음
    }

    // 어떤 인바운드(앱 데이터 또는 pong)든 받으면 갱신
    public void OnAnyInbound()
        => Interlocked.Exchange(ref _lastInboundTicks, Environment.TickCount64);

    // 별도 타이머 스레드가 모든 연결을 주기적으로 점검 (active heartbeat)
    public void HeartbeatTick(long nowTicks)
    {
        long last = Interlocked.Read(ref _lastInboundTicks);
        var idle = TimeSpan.FromMilliseconds(nowTicks - last);

        if (idle > DeadAfter) { Cleanup("heartbeat timeout"); return; }
        if (idle > PingInterval) SendAppPing();   // 능동 ping (pong 받으면 OnAnyInbound)
    }

    private void SendAppPing() { /* 앱 레벨 PING 패킷 비동기 송신 */ }

    public void OnRecvError(SocketException ex) => Cleanup($"recv error {ex.SocketErrorCode}");

    public void Cleanup(string reason)
    {
        if (Interlocked.Exchange(ref _cleaned, 1) == 1) return;   // 멱등
        Console.WriteLine($"cleanup: {reason}");
        try { _socket.Shutdown(SocketShutdown.Both); } catch { }
        _socket.Close();
    }
}

포인트

  • 앱 레벨 ping/pong 이 주 감지 수단 → 수 초 단위로 죽음을 능동 감지하고, RTT 도 덤으로 얻는다(앱 데드락도 잡힘). KeepAlive 는 "극단적 케이스의 안전망"으로 둔다.
  • 정리 트리거가 더 이상 능동 I/O 에 묶이지 않는다 → 조용한 연결도 타이머가 본다.
  • TcpKeepAlive* 소켓 옵션은 초 단위·이식성 있고 retry count 까지 제어 가능.
  • Cleanup 멱등화로 KeepAlive RST 와 하트비트 타임아웃의 이중 정리 차단.

더 나은 설계

1) 하트비트 정책을 클라 환경별로

  • 모바일은 배터리 때문에 ping 주기를 늘리고 싶지만, 그러면 감지 지연. 절충으로 "adaptive heartbeat" — RTT/네트워크 품질에 따라 ping 주기를 동적 조정.
  • 트레이드오프: 구현 복잡도 ↑. 단순함이 더 중요하면 고정 주기 + 넉넉한 timeout.

2) 단일 타이머가 N 세션을 훑는 O(N) 대신 타임휠

  • problem1 과 동일 논리. 다음 ping/만료 예정 시각을 타임휠에 넣어 "곧 만료될 것"만 처리. 수십만 동접에서 매초 전수 스캔 회피.

3) KeepAlive 는 "유지" 목적으로만

  • KeepAlive 의 진짜 효용은 NAT 매핑 유지(주기적 패킷으로 NAT 타임아웃 회피)와 최후의 좀비 정리. 빠른 장애 감지를 KeepAlive 에 기대하지 말 것.

4) 양방향 검증

  • ping 은 서버→클라뿐 아니라 클라→서버도. 단방향 경로 고장(한쪽만 죽은 라우트)을 잡으려면 양쪽이 서로의 생존을 독립적으로 판정해야 한다.

면접 포인트

  1. "TCP KeepAlive 만으로 게임의 죽은 연결을 정리하면 안 되는 이유는?" → 기본 주기가 분~시간 단위라 너무 느리고, half-open/NAT 통과/앱 데드락을 못 잡으며, 정리 트리거가 능동 I/O 에 묶이면 조용한 연결을 놓친다. 앱 레벨 ping/pong 이 1차 방어선이고 KeepAlive 는 보조.
  2. "앱 레벨 ping 이 TCP KeepAlive 대비 추가로 주는 것은?" → 앱 프로세스가 실제로 반응하는지(스택만 살아있는 데드락 감지), RTT/지터 측정, 양방향 독립 판정, OS·플랫폼 비의존 일관된 동작.
  3. "half-open 연결이란? 무엇이 못 잡히나?" → 한쪽은 닫혔는데 다른 쪽은 모르는 상태. 송신이 없으면 RST/FIN 을 받을 일이 없어 오래 좀비로 남는다. 능동 송신(ping)을 해봐야 비로소 끊김을 관측한다.