이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • TCP 3-way handshake, FIN, RST가 연결 성공/실패 증상을 어떻게 만드는가?
  • SYN-SENT, SYN-RECV, ESTABLISHED, TIME_WAIT, CLOSE_WAIT 상태를 장애 단서로 어떻게 읽는가?
  • backlog, connection pool, idle timeout, keep-alive가 TCP 상태와 어떻게 연결되는가?

개요

TCP는 HTTP 요청을 보내기 전에 연결을 만들고, 순서 보장과 재전송을 제공한다. 이 편리함에는 handshake 지연, kernel socket 상태, backlog, TIME_WAIT, connection pool 같은 비용이 따른다.

  • TCP는 SYN, SYN-ACK, ACK로 연결을 만들고 FIN/RST로 종료한다.
  • 연결 상태는 ESTABLISHED, TIME_WAIT, CLOSE_WAIT처럼 OS에 남는다.
  • 상태를 보면 애플리케이션이 연결을 닫지 않는지, 클라이언트가 과도하게 연결하는지 추정할 수 있다.

왜 백엔드 개발자가 알아야 하는가

HTTP client timeout, LB 502, DB 연결 고갈, outbound API 지연은 TCP 상태를 보면 원인 후보가 빠르게 줄어든다. 특히 앱 로그에 예외만 남고 네트워크 상태를 보지 않으면 재시작으로 잠깐 가려지는 문제를 반복하게 된다.

  • connect timeout은 SYN/SYN-ACK 단계에서 막힌 것일 수 있다.
  • read timeout은 연결은 되었지만 응답 byte가 오지 않는 상황일 수 있다.
  • CLOSE_WAIT 누적은 상대가 닫았는데 우리 앱이 socket을 닫지 못한다는 신호다.
  • TIME_WAIT 증가는 항상 나쁜 것이 아니라 짧은 연결을 많이 만들 때 자연스럽게 늘 수 있다.
  • RST는 proxy idle timeout, app crash, 잘못된 port, 중간 장비 정책 종료의 단서가 된다.

요청 흐름에서의 위치

TCP는 HTTP 요청 본문이 오가기 전에 연결을 만드는 구간이다.

client SYN
→ server SYN-ACK
→ client ACK
→ ESTABLISHED
→ HTTP request/response
→ FIN or RST
→ TIME_WAIT 등 종료 상태

handshake가 실패하면 HTTP request는 서버 앱까지 오지 않는다. 연결 후 실패하면 HTTP log, proxy log, TCP state를 함께 보아야 한다.

원리

주요 TCP 상태는 다음처럼 읽는다.

상태의미실무 해석
LISTEN서버가 연결을 기다림listener 확인
SYN-SENTclient가 SYN을 보냄connect 시도 중, 응답 없으면 timeout
SYN-RECVserver가 SYN을 받고 SYN-ACK 보냄handshake 미완료, backlog/SYN flood 의심
ESTABLISHED연결 성립active connection 수, pool 사용량
FIN-WAIT종료 절차 진행정상 종료 과정
CLOSE-WAIT상대가 닫았고 local app이 아직 닫지 않음앱/라이브러리 close 누락 의심
TIME-WAITlocal이 마지막 ACK 후 재사용 지연짧은 연결이 많으면 증가

RST는 정상 종료가 아니라 즉시 연결을 끊는 신호다. connection refused도 TCP RST로 보일 수 있고, 이미 맺어진 연결의 RST는 proxy idle timeout, app 재시작, upstream reset처럼 보일 수 있다.

핵심 판단 기준

  • connect timeout, TLS timeout, read timeout을 분리해 기록한다.
  • server LISTEN과 client SYN-SENT를 같이 보면 경로 차단인지 listener 문제인지 좁힐 수 있다.
  • CLOSE_WAIT 누적은 네트워크보다 앱이나 client library의 connection close 문제를 먼저 의심한다.
  • TIME_WAIT는 수치만으로 판단하지 말고 traffic 패턴, connection reuse, ephemeral port 사용률과 함께 본다.
  • backlog overflow는 앱 thread 문제처럼 보일 수 있지만 kernel accept queue 한계일 수 있다.

실무에서 자주 만나는 문제

  • CLOSE_WAIT 누적을 네트워크 장애로만 본다.
  • TIME_WAIT 증가를 무조건 비정상으로 본다.
  • RST를 애플리케이션 예외와 연결하지 못한다.
  • 새 배포 중 graceful shutdown 없이 연결을 끊어 client가 reset을 본다.
  • upstream idle timeout보다 client pool idle 시간이 길어 stale connection을 재사용한다.
  • accept queue가 가득 차서 SYN은 오는데 앱이 받아들이지 못한다.
  • outbound HTTP client가 connection reuse를 못 해 handshake 비용과 TIME_WAIT를 폭증시킨다.
  • packet capture 없이 “방화벽 문제”와 “앱이 reset”을 구분하지 못한다.

TCP 상태는 snapshot이므로 시점을 맞춰 보아야 한다. 장애가 끝난 뒤 ss를 보면 원인을 놓칠 수 있다. metric, packet capture, app log, proxy log를 같은 시간대로 맞추는 것이 중요하다.

디버깅 방법

  • ss -tan으로 TCP state 분포를 본다.
  • 패킷이 필요하면 tcpdump로 SYN/RST 흐름을 본다.
  • connection pool metric과 OS socket 상태를 함께 본다.
  • nstat으로 retransmit, listen overflow 같은 kernel counter를 본다.
  • proxy log에서 upstream reset, connect timeout, read timeout을 구분한다.
  • 배포 시점과 RST 증가가 겹치는지 본다.
ss -tan | awk 'NR>1 {count[$1]++} END {for (s in count) print s, count[s]}'
ss -tan state close-wait
ss -tan state time-wait | wc -l
nstat -az | egrep 'TcpRetransSegs|TcpExtListenOverflows|TcpExtListenDrops'

패킷이 필요할 때는 범위를 좁혀 캡처한다.

sudo tcpdump -nn -i any 'host 203.0.113.10 and tcp port 443 and (tcp[tcpflags] & (tcp-syn|tcp-rst|tcp-fin) != 0)'

SYN만 나가고 SYN-ACK가 없으면 경로/drop을, SYN-ACK 후 ACK가 없으면 client 쪽 경로를, RST가 보이면 누가 RST를 보냈는지를 본다.

코드로 확인하기

짧은 연결을 계속 만들면 handshake 비용과 TIME_WAIT가 늘어난다. HTTP client pool을 쓰는 이유가 여기에 있다.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
 
HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(2))
    .build();
 
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/health"))
    .timeout(Duration.ofSeconds(3))
    .GET()
    .build();
 
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());

위 예시는 connect timeout과 request timeout을 분리한다. 운영에서는 dependency별 timeout, connection pool, idle max age를 함께 맞춘다.

watch -n 1 "ss -tan | awk 'NR>1 {count[\\$1]++} END {for (s in count) print s, count[s]}'"

부하 테스트 중 이 명령으로 TIME_WAIT, CLOSE_WAIT, ESTABLISHED의 변화 방향을 같이 보면 pool 재사용이 되는지 감을 잡을 수 있다.

주니어가 자주 하는 오해

  • TIME_WAIT가 보이면 무조건 장애라고 생각한다.
    • TIME_WAIT는 정상 종료 과정에서도 생긴다. 양과 증가 속도, port 사용률을 함께 봐야 한다.
  • CLOSE_WAIT와 TIME_WAIT를 비슷하게 본다.
    • CLOSE_WAIT는 local app이 close를 마무리하지 못한 신호일 수 있어 더 위험하다.
  • HTTP 502는 항상 app exception이라고 생각한다.
    • upstream connect 실패나 reset도 proxy 502로 보일 수 있다.
  • packet capture는 인프라만 하는 일이라고 생각한다.
    • 백엔드도 SYN/RST/FIN 정도는 읽을 수 있어야 협업이 빨라진다.

시니어의 설계 판단 기준

  • 연결 재사용을 기본으로 설계하되 idle timeout mismatch를 피한다.
  • graceful shutdown에서 새 연결 차단, 기존 연결 drain, readiness 해제를 순서화한다.
  • TCP 상태 metric과 HTTP client pool metric을 함께 대시보드에 둔다.
  • rare reset을 완전히 없애기보다 retry/idempotency/timeout으로 사용자 영향을 줄인다.
  • backlog, max connection, worker thread, pool size를 서로 맞춘다.

인프라 협업 포인트

  • RST/timeout 이슈를 전달할 때 source/destination, port, 시각, tcpdump 요약, proxy upstream error를 함께 준다.
  • LB idle timeout, app keep-alive timeout, client pool max idle time을 인프라와 함께 맞춘다.
  • 배포 중 reset이 늘면 readiness, drain delay, terminationGracePeriod, preStop hook을 같이 본다.
  • backlog overflow가 보이면 OS backlog, app accept 속도, worker thread, LB surge를 함께 조정한다.

실전 팁

  • 부하 테스트를 할 때 p99 latency만 보지 말고 TCP state 분포를 같이 본다.
  • CLOSE_WAIT가 증가하면 thread dump와 client library resource close 코드를 함께 본다.
  • TIME_WAIT가 많으면 connection pool이 꺼져 있는지, keep-alive가 너무 짧은지, ephemeral port 범위가 충분한지 본다.
  • reset 원인은 한쪽 로그만으로 단정하지 말고 양쪽 tcpdump나 proxy error log를 맞춘다.

위험 신호!

  • CLOSE_WAIT가 계속 누적된다.
  • 배포 직후 client reset이 튄다.
  • connect timeout과 read timeout을 같은 timeout으로만 기록한다.
  • LB idle timeout과 client pool idle time이 문서화되어 있지 않다.
  • listen overflow/drop kernel counter가 증가한다.

확인 질문

확인 질문

  • CLOSE_WAIT가 계속 쌓이면 무엇을 의심해야 하는가?
    • 상대가 연결을 닫았는데 local application이나 library가 socket close를 마무리하지 못하는 문제를 의심한다.
  • TIME_WAIT가 많다는 사실만으로 장애라고 말하면 안 되는 이유는 무엇인가?
    • 정상 종료 과정에서도 생기며 traffic 패턴, 연결 재사용, ephemeral port 사용률과 함께 봐야 하기 때문이다.
  • connect timeout과 read timeout은 어떻게 다른가?
    • connect timeout은 연결 수립 단계의 지연이고, read timeout은 연결 후 응답 데이터를 기다리는 단계의 지연이다.

참고 문서