29. 아웃바운드 연결의 완료·실패·타임아웃 미처리 (서버-서버, C#)
난이도 중내 리뷰 · 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가 호출되면 아직 연결 안 된 소켓에Send해SocketException(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을 명시하고,Send는Ready에서만.Connecting중 전송은 큐잉하거나 거절. - 타임아웃 계층화: 연결 타임아웃(수백 ms~수 초)과 요청 타임아웃을 분리. 지수 백오프 재연결 + 서킷브레이커로 죽은 다운스트림에 폭주 방지.
- 헬스체크·프리커넥트: 상시 연결 풀을 미리 데워 두고(warm pool) 주기 헬스핑으로 죽은 연결을 교체 — 요청 경로에서 연결 지연 제거.
- 관측: 연결 성공/실패/타임아웃 카운터, in-flight 연결 수 메트릭. 예외를 절대
_ =로 버리지 말 것.
면접 포인트
- 비동기 연결에서 "시작"과 "완료"를 분리해 완료 시점에 등록해야 하는 이유.
ConnectAsync(CancellationToken)로 연결 타임아웃을 거는 방법과, OS 기본 재전송 타임아웃에 의존하면 왜 위험한가.- fire-and-forget Task 의 미관측 예외 문제(
csharp_internalsTask 조합 참조)와 실패를 재시도/차단기로 잇는 설계.
해설 · C++
해설 — 논블로킹 아웃바운드 connect 의 완료·실패·타임아웃 미처리 (서버-서버, C++)
난이도: 중
요약
논블로킹 connect 후 (1) 소켓이 쓰기 가능해지면 무조건 "성공"으로 간주한다 — 그러나 쓰기 가능 이벤트는 성공과 실패(연결 거부/타임아웃) 양쪽에서 발생하므로 getsockopt(SO_ERROR) 로 실제 결과를 확인해야 한다. (2) 연결 타임아웃이 없어 black-hole 대상으로의 시도가 무한정 매달리고, (3) send 반환값(부분 전송·EAGAIN)을 제대로 다루지 않는다.
문제점
- [쓰기 가능=성공 오판] (A)
void onWritable() { conn_.ready = true; }- 증상: 논블로킹 connect 는 성공하든 실패하든 소켓을 쓰기 가능(또는 예외)으로 만든다. 대상이 RST 로 거부(ECONNREFUSED)하거나 언리처블(EHOSTUNREACH)이어도 이벤트가 뜨는데, 여기선 그걸 성공으로 처리해 실패한 소켓을 ready 로 등록한다. 이후
send가EPIPE/ECONNRESET로 깨진다. - 재현: 닫힌 포트로 connect → 커널이 RST → 쓰기 가능 이벤트 →
ready=true→ send 실패. - 근본원인: 완료 처리에서
getsockopt(fd, SOL_SOCKET, SO_ERROR, ...)로 연결 성패를 확인하지 않음.
- 증상: 논블로킹 connect 는 성공하든 실패하든 소켓을 쓰기 가능(또는 예외)으로 만든다. 대상이 RST 로 거부(ECONNREFUSED)하거나 언리처블(EHOSTUNREACH)이어도 이벤트가 뜨는데, 여기선 그걸 성공으로 처리해 실패한 소켓을 ready 로 등록한다. 이후
- [연결 타임아웃 부재] (A) 부근
- 증상: 방화벽이 SYN 을 드롭하면 쓰기 가능 이벤트가 영영 오지 않아 연결이 진행 중 상태로 매달린다. 타임아웃이 없으니 fd·엔트리가 누수되고, 반복 시도 시 FD 고갈.
- 근본원인: connect 시작 시 데드라인 타이머를 걸어 만료 시 소켓을 닫고 실패 처리해야 함.
- [send 반환값 오처리] (B)
return ::send(...) >= 0;- 증상: 논블로킹 소켓의
send는 커널 송신 버퍼가 차면-1/EAGAIN을 주거나 요청보다 적게 보낸다(부분 전송).>=0만 보면 부분 전송을 성공으로 처리해 프레임 절단, EAGAIN 을 실패로 오인. (protocol_version/problem20,session_network/problem26과 연계.)
- 증상: 논블로킹 소켓의
수정안
쓰기 가능 이벤트에서 SO_ERROR 로 성패 확인 → 성공만 ready. connect 시작 시 타임아웃 타이머. send 는 부분 전송·EAGAIN 을 커서로 관리.
// 쓰기 가능 이벤트 처리
void onWritable() {
int err = 0; socklen_t len = sizeof(err);
if (::getsockopt(conn_.fd, SOL_SOCKET, SO_ERROR, &err, &len) < 0 || err != 0) {
// err = ECONNREFUSED / ETIMEDOUT / EHOSTUNREACH ... → 연결 실패
::close(conn_.fd); conn_.fd = -1; conn_.ready = false;
onConnectFailed(err); // 재시도/서킷브레이커 훅
return;
}
conn_.ready = true; // 진짜 연결 성공
cancelConnectTimer();
}
// startConnect 에서: EINPROGRESS 후 데드라인 타이머 등록
void armConnectTimeout(std::chrono::milliseconds d) {
// 만료 시: close(fd); onConnectFailed(ETIMEDOUT);
}
// 부분 전송·EAGAIN 을 반영한 송신(논블로킹)
// n 바이트 중 보낸 만큼 offset 전진, EAGAIN 이면 쓰기 가능 재등록 후 이어보냄.
ssize_t sendSome(const uint8_t* p, size_t n) {
ssize_t s = ::send(conn_.fd, p, n, MSG_NOSIGNAL);
if (s < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return 0; // 나중에 재시도
return s; // <0(치명), 0.., 부분 전송 포함 — 호출자가 offset 관리
}
더 나은 설계
- 연결 상태 머신:
Connecting → Ready → Broken.Connecting중 전송은 큐잉/거절. 타이머로 Connecting→Broken. - 타임아웃 계층화 + 백오프: 연결 타임아웃(수백 ms~수 초)과 요청 타임아웃 분리. 실패 시 지수 백오프 재연결 + 서킷브레이커(
distributed_systems/problem7). - warm pool + 헬스핑: 상시 연결을 미리 맺어 두고 주기 핑으로 죽은 연결 교체 → 요청 경로에서 연결 지연 제거.
MSG_NOSIGNAL: 끊긴 소켓 send 시SIGPIPE로 프로세스가 죽지 않게(또는SO_NOSIGPIPE/signal(SIGPIPE, SIG_IGN)).
면접 포인트
- 논블로킹 connect 완료 판정: 쓰기 가능 이벤트 후
SO_ERROR확인이 필수인 이유(성공·실패 모두 이벤트를 낸다). - connect 타임아웃을 커널 재전송에 맡기면 왜 위험한가(수십 초 매달림·FD 누수).
- 논블로킹
send의 부분 전송·EAGAIN 처리와 송신 커서.