← 문제로

28. 연결 종료 시 graceful half-close 부재로 인한 송신 데이터 절단(RST) (C#)

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

해설 — 연결 종료 시 graceful half-close 부재로 인한 송신 데이터 절단(RST) (C#)

난이도: 중상

요약

마지막 ack 를 await SendAllAsync 로 보낸 직후 Socket.Dispose() 한다. 그러나 (1) await 완료는 소켓 송신 버퍼에 복사됐다는 뜻일 뿐 상대 수신 보장이 아니며, (2) 소켓 수신 버퍼에 읽지 않은 인바운드(파이프라이닝된 다음 요청)가 남은 채 Dispose/Close 하면 커널이 RST 를 보내 미ACK 송신분(=방금 보낸 ack)까지 폐기한다.

문제점

  • [조기 종료 / 전달 미보장] (A) CloseSession()Sock.Dispose();
    • 증상: Dispose() 는 기본 LingerState(off)에서 가능한 한 보내려 하지만, 수신 버퍼에 미독 데이터가 있으면 RST 로 끊긴다. await 완료를 "전달 완료"로 오해.
    • 근본원인: Socket.Shutdown(SocketShutdown.Send) 후 상대 FIN 확인이라는 half-close 절차 부재.
  • [미드레인 → RST] (B) OnReadableAsync 에서 CloseSession() 호출
    • 증상: 클라가 Logout 과 다음 패킷을 함께 보냈다면 그 바이트가 수신 버퍼에 남고, 이 상태의 Close/Dispose 는 RST 를 유발 → ack 절단. .NET 에서도 동일한 TCP 의미.
    • 재현: 클라가 Logout + 추가 패킷을 한 번에 전송 → 서버가 Logout 만 처리 후 즉시 Dispose → 잔여 수신 데이터로 RST → 클라 ack 수신 실패.
    • 근본원인: 종료 전 인바운드를 EOF까지 드레인하지 않음.

수정안

보낼 걸 다 보낸 뒤 Shutdown(Send) → 상대 FIN(Receive==0)까지 드레인(타임아웃 상한) → Close/Dispose.

public async Task GracefulCloseAsync(Socket sock)
{
    try
    {
        sock.Shutdown(SocketShutdown.Send);   // 우리 쪽 FIN: "더 보낼 것 없음"

        // 남은 인바운드를 EOF/타임아웃까지 비운다 — RST 유발 방지
        sock.ReceiveTimeout = 300;            // 동기 경로면; 비동기면 CTS 사용
        var buf = new byte[1024];
        while (true)
        {
            int n;
            try { n = await sock.ReceiveAsync(buf, SocketFlags.None); }
            catch (SocketException) { break; }   // 타임아웃/오류 시 중단
            if (n == 0) break;                    // 상대 FIN = 정상 종료 합의
        }
    }
    catch (SocketException) { /* 이미 끊김 */ }
    finally
    {
        sock.Close();                         // 또는 Dispose — 이제 안전
    }
}
  • 즉시 강제 종료(악성 클라)는 sock.LingerState = new LingerOption(true, 0);의도적 RST(자원 즉시 회수) — 단 마지막 데이터 전달은 포기.
  • 비동기 경로에선 ReceiveTimeout 대신 CancellationTokenSource(300ms) 로 드레인 상한.

더 나은 설계

  • 앱 레벨 종료 핸드셰이크: ack 에 "BYE" 를 싣고 클라의 BYE-ACK/연결종료로 확정. half-close 는 도달까지만 보장, 처리까지는 앱이 보장.
  • 종료 상태 머신: Active → Draining → Closed. Draining 중 신규 요청 무시·수신만 비움. session_network/problem10(graceful shutdown)·problem4(비동기 종료 순서)와 연결.
  • 드레인 상한 필수: 느린/악성 클라가 종료를 붙잡지 못하게 수백 ms 타임아웃 후 RST.
  • Dispose 의 함정: using/Dispose 패턴이 "즉시 닫음"이라 우아한 종료와 충돌. 종료 경로는 명시적 half-close 후에만 Dispose.

면접 포인트

  • await 송신 완료가 "전달"이 아닌 이유, 미독 수신 데이터가 RST 를 유발하는 메커니즘.
  • Socket.Shutdown(Send) + 드레인 + Close 의 순서와 LingerOption 의 의미.
  • TCP half-close 가 보장하는 것(도달) vs 못 하는 것(상대 처리) — 앱 ack 가 필요한 경우.