← 문제로

29. 아웃바운드 연결의 완료·실패·타임아웃 미처리 (서버-서버, C#)

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

해설 — 아웃바운드 연결의 완료·실패·타임아웃 미처리 (서버-서버, C#)

난이도: 중

요약

아웃바운드 연결을 (1) 완료를 기다리지 않고 준비 맵에 넣어 아직 연결되지 않은(혹은 이미 실패한) 소켓으로 전송하고, (2) ConnectAsync성패를 관측하지 않아(fire-and-forget) 예외가 조용히 삼켜지며, (3) 연결 타임아웃이 없어 응답 없는 다운스트림(black-hole)으로의 시도가 OS 기본치까지(수십 초) 매달리고 실패한 소켓이 맵에 남아 누수된다.

문제점

  • [완료 전 준비 등록] (A)(B) _ = sock.ConnectAsync(remote); _ready[key] = sock;
    • 증상: ConnectAsync 는 즉시 반환하고 연결은 백그라운드에서 진행된다. 그런데 바로 _ready[key]=sock 으로 등록하므로, 연결이 끝나기 전(또는 실패한 뒤)에 Send 가 호출되면 아직 연결 안 된 소켓SendSocketException(NotConnected)이 나거나, 실패로 이미 닫힌 소켓에 전송한다.
    • 재현: Dial 직후 Send — 3-way handshake 완료 전이면 실패. 대상이 죽어 RST 로 실패해도 맵엔 그대로 남는다.
    • 근본원인: 연결 완료를 성공 신호로 삼아 그때 등록해야 하는데, 시작과 등록을 붙여 버림.
  • [성패 미관측 / 예외 삼킴] (A) _ = sock.ConnectAsync(remote);
    • 증상: _ = 로 Task 를 버려 연결 실패 예외가 아무 데서도 관측되지 않는다. 실패했는지 알 길이 없어 재시도/회로차단도 못 건다. (관측 안 된 Task 예외 → 로그 없이 사라짐.)
  • [연결 타임아웃 부재] (B)
    • 증상: 방화벽이 SYN 을 드롭하면(응답 없음) 연결 시도가 OS 재전송 정책대로 수십 초~분 매달린다. 그동안 소켓·엔트리가 쌓여(black-hole 대상에 반복 Dial) FD/메모리 누수, 연쇄 지연.
    • 근본원인: 애플리케이션 레벨 연결 데드라인 부재(distributed_systems/problem7 타임아웃/차단기와 연결).

수정안

연결을 await 하고, 성공했을 때만 준비 맵에 등록. CancellationToken 으로 연결 타임아웃을 걸고, 실패/취소 시 소켓을 닫고 맵을 오염시키지 않는다.

public async Task<bool> DialAsync(string key, IPEndPoint remote, TimeSpan timeout)
{
    var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    using var cts = new CancellationTokenSource(timeout);
    try
    {
        await sock.ConnectAsync(remote, cts.Token);   // 완료까지 대기(타임아웃/취소 포함)
        _ready[key] = sock;                            // 성공했을 때만 등록
        return true;
    }
    catch (Exception ex)   // SocketException, OperationCanceledException(타임아웃) 등
    {
        sock.Dispose();                                // 실패 소켓 정리 → 누수 방지
        // 로그 + 재시도/서킷브레이커 훅
        return false;
    }
}

public bool Send(string key, byte[] frame)
{
    if (!_ready.TryGetValue(key, out var sock) || !sock.Connected) return false;
    try { sock.Send(frame); return true; }
    catch (SocketException) { _ready.TryRemove(key, out _); sock.Dispose(); return false; }
}

더 나은 설계

  • 연결 상태 머신: Connecting → Ready → Broken 을 명시하고, SendReady 에서만. Connecting 중 전송은 큐잉하거나 거절.
  • 타임아웃 계층화: 연결 타임아웃(수백 ms~수 초)과 요청 타임아웃을 분리. 지수 백오프 재연결 + 서킷브레이커로 죽은 다운스트림에 폭주 방지.
  • 헬스체크·프리커넥트: 상시 연결 풀을 미리 데워 두고(warm pool) 주기 헬스핑으로 죽은 연결을 교체 — 요청 경로에서 연결 지연 제거.
  • 관측: 연결 성공/실패/타임아웃 카운터, in-flight 연결 수 메트릭. 예외를 절대 _ = 로 버리지 말 것.

면접 포인트

  • 비동기 연결에서 "시작"과 "완료"를 분리해 완료 시점에 등록해야 하는 이유.
  • ConnectAsync(CancellationToken) 로 연결 타임아웃을 거는 방법과, OS 기본 재전송 타임아웃에 의존하면 왜 위험한가.
  • fire-and-forget Task 의 미관측 예외 문제(csharp_internals Task 조합 참조)와 실패를 재시도/차단기로 잇는 설계.