← 문제로

9. C# half-open 연결 감지: 단방향 경로 장애와 송신 결과 처리

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

해설 — C# half-open 연결 감지: 단방향 경로 장애와 송신 결과 처리

난이도: 상

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

요약

half-open/단방향 장애를 잡으려는 워치독이 잘못된 신호를 본다. 핵심 결함:

  1. SendAsync 결과(Task)를 무시(B) — TCP send 가 성공해도 "상대가 받았다"는 뜻이 전혀 아니다(커널 버퍼에만 넣은 것). 게다가 fire-and-forget 으로 예외/완료를 안 봐 끊김을 놓치고 unobserved exception 을 남긴다.
  2. 판정 기준이 "송신 시도 시각"(E) — 서버는 매 틱 무조건 송신하므로 LastSendAttempt항상 최신이다. 즉 워치독은 절대 죽었다고 판정 못 한다. 봐야 할 것은 수신/응답(생존 증거) 시각이다.
  3. 수신만으로 생존 단정(C) — 단방향 장애(서버→클라만 죽음)에서는 클라 입력이 잠깐 더 올라올 수 있어 오판. 양방향 왕복(ping/pong) 확인이 필요.
  4. DateTime.Now(벽시계) + Closed 비원자 — 경과 측정에 벽시계, 종료 플래그가 비원자라 가시성·역행 문제.

문제점

(E)+(A) 판정 기준이 거꾸로다 — "송신 시도"로 죽음 판정 (분류: 정확성, 치명)

  • 증상: LooksDeadnow - LastSendAttempt > limit 으로 판정. 그런데 서버는 매 틱 SendSnapshot 을 호출해 LastSendAttempt 를 계속 갱신한다. 따라서 이 값은 항상 직전 틱 → 차이가 limit 을 넘는 일이 사실상 없다. 워치독이 영원히 죽음을 못 잡는다(좀비 영속). half-open 이어도 서버는 계속 송신을 "시도"하니까.
  • 근본원인: 생존의 증거는 "내가 보냈다"가 아니라 "상대가 응답했다". 판정은 마지막 인바운드(수신/pong) 시각 기준이어야 한다.

(B) SendAsync 결과 무시 + fire-and-forget — 도달 보장 오해 (분류: 정확성/견고성)

  • 증상: _ = _socket.SendAsync(...) 로 던져두고 Task 를 await/관측하지 않는다.
    • SendAsync 가 성공해도 커널 송신 버퍼에 복사됐을 뿐, 상대 수신과 무관.
    • 단방향 장애가 누적되면 ACK 가 안 와 송신 버퍼가 가득 차 SendAsync 가 지연·예외가 나는데, fire-and-forget 이라 그 신호를 못 본다(unobserved task exception).
    • 한 소켓에 동시에 여러 SendAsync 를 던지면 직렬화 깨짐(데이터 인터리빙)도 가능.
  • 근본원인: "send 성공 = 전달 완료"라는 오해. TCP 는 종단 도달을 send 호출로 알려주지 않는다. 끊김 신호는 완료/예외 결과 또는 앱 레벨 ACK 로만 온다.

(C) 수신 사실만으로 생존 단정 (분류: 정확성)

  • 증상: OnRecv 에서 데이터를 받으면 살아있다고 본다. 하지만 단방향 장애 (서버→클라만 죽음)에서는 클라가 아직 보낸 입력이 버퍼/경로에 남아 잠깐 더 도착할 수 있어, 실제로는 서버 응답이 클라에 안 닿는 "반쪽 죽은" 세션을 살았다고 오판.
  • 근본원인: 단방향 검증으로는 부족. 양방향 왕복(서버 ping → 클라 pong)을 확인해야 "서버→클라" 경로의 생존까지 보장된다.

(A)/(분류: 동시성/정확성) DateTime.Now 벽시계 + 비원자 상태

  • 증상: 경과 시간을 DateTime.Now(벽시계)로 측정 → NTP/서머타임으로 역행하면 오판. LastSendAttempt(DateTime)와 Closed(volatile bool)를 송신/수신/워치독 스레드가 동기화 없이 만져 가시성·tearing 위험. Close 가 비원자(if(Closed) return)라 동시 호출 시 둘 다 통과할 수 있다(이중 정리).
  • 근본원인: 경과는 단조 시계(Stopwatch/Environment.TickCount64)로, 카운터/플래그는 Interlocked/Volatile로 다뤄야 한다.

(D) 앱 ping/pong·미응답 카운트·grace 부재 (분류: 정확성/견고성)

  • 증상: 능동 ping 이 없어 조용한 구간에서 half-open 을 능동 탐지하지 못한다. 또 단발 손실(일시 지터)로 바로 끊으면 오인 끊김 위험. 연속 미응답 카운트/grace 없음.
  • 근본원인: 능동 keepalive + 연속 실패 임계값 설계 부재.

수정안

핵심: 인바운드 시각 기준 + 능동 ping/pong + 송신 완료/예외 처리 + 연속 미응답 + 단조 시계

using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

public sealed class Conn
{
    private readonly Socket _socket;
    private int _closed = 0;

    // 생존 증거: 마지막 "인바운드(수신 또는 pong)" 시각 (단조 시계 ms)
    private long _lastInboundMs = Environment.TickCount64;
    private long _lastPingSentMs = 0;
    private int  _missedPongs = 0;

    public Conn(Socket socket) { _socket = socket; }

    public async Task SendSnapshotAsync(byte[] data)
    {
        if (Volatile.Read(ref _closed) == 1) return;
        try
        {
            // 완료를 await 해 에러/지연을 관측. 같은 소켓 동시 송신은 송신 루프로 직렬화.
            await _socket.SendAsync(data, SocketFlags.None);
        }
        catch (SocketException)         { Close(); }   // 에러 → 끊김 신호로 활용
        catch (ObjectDisposedException) { /* 종료 경합 */ }
    }

    public void OnRecv(byte[] data, int len)
    {
        if (len <= 0) { Close(); return; }
        Interlocked.Exchange(ref _lastInboundMs, Environment.TickCount64);  // 생존 증거 갱신
        if (IsPong(data, len))
        {
            Interlocked.Exchange(ref _missedPongs, 0);   // pong 받음 → 카운트 리셋
            return;
        }
        // 클라 입력 처리 ...
    }

    // 워치독: 주기 호출. 능동 ping + 연속 미응답으로 판정
    public void HeartbeatTick(long pingIntervalMs, int maxMissed)
    {
        if (Volatile.Read(ref _closed) == 1) return;
        long now = Environment.TickCount64;

        if (now - Interlocked.Read(ref _lastPingSentMs) > pingIntervalMs)
        {
            // 직전 ping 에 대한 pong 이 안 왔으면 미응답 누적
            if (now - Interlocked.Read(ref _lastInboundMs) > pingIntervalMs)
            {
                if (Interlocked.Increment(ref _missedPongs) >= maxMissed)
                {
                    Close();   // 연속 실패 → 끊김
                    return;
                }
            }
            _ = SendPingAsync();   // 완료를 await/관측하는 비동기 ping
            Interlocked.Exchange(ref _lastPingSentMs, now);
        }
    }

    public void Close()
    {
        if (Interlocked.Exchange(ref _closed, 1) == 1) return;  // 멱등
        // _socket.Close(), in-flight I/O 안전 회수 ...
    }

    private async Task SendPingAsync()
    {
        try { await _socket.SendAsync(/*ping*/ Array.Empty<byte>(), SocketFlags.None); }
        catch (SocketException) { Close(); }
        catch (ObjectDisposedException) { }
    }
    private static bool IsPong(byte[] data, int len) => false; // (구현 생략)
}

포인트

  • 판정 기준을 인바운드 시각으로 뒤집음: "상대가 응답했는가"가 생존 증거. 서버의 송신 시도는 생존 신호가 아니다.
  • 능동 ping/pong + 연속 미응답(_missedPongs) + 임계값: 양방향 경로 생존을 확인하고, 단발 손실로 오인 끊김 하지 않게 grace(여러 번 실패 누적)를 둔다.
  • SendAsync 를 await + 예외 처리: send 에러를 끊김 신호로 활용. fire-and-forget 대신 결과를 관측하고, 같은 소켓 동시 송신은 송신 루프로 직렬화(problem5 연계).
  • Environment.TickCount64(단조 시계) + 모든 카운터/플래그를 Interlocked/Volatile로 → 시계 역행·가시성·이중 정리 제거. CloseInterlocked.Exchange로 멱등.

더 나은 설계

1) 앱 레벨 시퀀스 + ACK(왕복 RTT 측정)

  • ping 에 시퀀스 번호를 실어 pong 에 echo. RTT/지터를 측정하고, 특정 ping 의 응답 여부를 정확히 추적(단순 시각 비교보다 정밀). RTT 급등을 사전 경고로 활용.
  • 트레이드오프: 패킷 오버헤드·상태 약간 증가.

2) TcpKeepAlive* / OS keepalive 보조

  • SocketOptionName.TcpKeepAliveTime/Interval/RetryCount 로 "ACK 안 오면 N 후 커널이 끊기"를 보조 설정해 송신 버퍼가 막힌 half-open 을 OS 가 보조 정리. 앱 ping 과 병행 (problem6 참고).

3) 송신 백프레셔와 연동

  • half-open 이면 ACK 미수신으로 송신 버퍼가 찬다. SendAsync 가 반복 지연되거나 앱 큐가 임계 초과면 그것도 "반쪽 죽음" 신호로 삼아 조기 판정(problem5 백프레셔와 연계).

4) 워치독을 타임휠로

  • O(N) 전수 스캔 대신 다음 ping/만료 예정 슬롯만 처리(problem1 과 동일 논리).

면접 포인트

  1. "SendAsync 가 성공 완료하면 상대가 받았다는 뜻인가?" → 아니다. 커널 송신 버퍼에 복사됐을 뿐. 종단 도달은 ACK(앱 레벨이면 pong)로만 확인. 그래서 죽음 판정을 "내가 보냈다"가 아니라 "상대가 응답했다"로 해야 한다.
  2. "half-open / 단방향 경로 장애를 어떻게 감지하나?" → 능동 ping/pong 으로 양방향 생존 확인 + 연속 미응답 임계값. 수신만으로는 서버→클라 경로 생존을 보장 못 한다. TCP keepalive/TcpKeepAlive* 보조.
  3. "이 코드의 워치독은 왜 절대 죽음을 못 잡나? 그리고 fire-and-forget send 의 위험은?" → 판정 기준이 매 틱 갱신되는 LastSendAttempt(송신 시도)라 항상 최신. 인바운드(수신 /pong) 시각 기준으로 바꿔야 한다. fire-and-forget 은 send 예외를 못 보고(unobserved), 같은 소켓 동시 송신으로 직렬화가 깨질 수 있어 await/직렬화가 필요하다.