Posts WEB - HTTP 연결 흐름에 따른 소켓 상태 변화 (TIME_WAIT, CLOSE_WAIT)
Post
Cancel

WEB - HTTP 연결 흐름에 따른 소켓 상태 변화 (TIME_WAIT, CLOSE_WAIT)

특정 목적지로 향하는 TIME_WAIT 소켓이 많으면 ephemeral port가 부족해져 그 목적지로 새 요청을 못 보낼 수 있고, CLOSE_WAIT 소켓이 많으면 fd가 고갈돼 파일 처리·DB 연결·API 호출이 전부 막히는 전면 장애로 번질 수 있다고 한다.

아직 이 문제를 직접 겪어본 적은 없고 재현 실습도 하지 않았다. 다만 두 상태가 왜 생기고 언제까지 남는지는 알고 있어야 할 것 같아서, HTTP 연결 흐름에 따른 소켓 상태 변화부터 정리해 본다.

흐름에 따른 소켓 상태


소켓 상태는 connect()/accept()부터 close()까지의 흐름 위에 놓고 봐야 각 상태가 “무엇을 기다리는 중인지”가 드러난다.

아래는 클라이언트가 연결을 열고(3-way handshake), TLS 1.3 핸드셰이크를 거쳐 데이터를 주고받은 뒤, 클라이언트가 먼저 close()를 호출해 끊는(4-way handshake) 전 과정에서 양쪽 소켓 상태가 어떻게 바뀌는지를 그린 것이다.

HTTPS 연결의 소켓 상태 변화 — TCP 3-way handshake, TLS 1.3 핸드셰이크, 암호화 데이터 송수신, 4-way handshake 구간별로 클라이언트와 서버의 소켓 상태를 나열한 다이어그램

몇 가지만 짚어두면,

  • TLS 핸드셰이크가 오가는 동안에도 커널의 TCP 상태는 양쪽 모두 ESTABLISHED 그대로다.
    • TCP 입장에서 TLS는 그냥 데이터다. 커널이 아는 건 “이 연결로 몇 바이트가 오갔다”까지고, 그 바이트가 ClientHello인지 인증서인지 HTTP 요청인지는 알지도 못하고 관심도 없다.
    • 그 바이트를 해석하고 암호화·복호화하는 건 애플리케이션 프로세스 안에서 도는 라이브러리(OpenSSL 등)다. 커널이 아니라 앱 쪽에서 처리된다는 뜻으로 “유저 공간에서 처리된다”고 표현한다.
    • 그래서 ssnetstat으로는 TLS 핸드셰이크가 진행 중인지 끝났는지 알 수 없다. TCP 3-way handshake가 끝난 시점부터 이미 ESTABLISHED로 보인다.
  • LISTEN 소켓과 accept()로 만들어진 연결 소켓은 별개의 fd다.
    • 연결이 닫혀도 LISTEN fd는 그대로 유지된다.
  • 종료 구간의 ②(ACK)와 ③(FIN)이 한 패킷(FIN+ACK)으로 합쳐져 오는 경우도 많다. 이때는 FIN_WAIT_1에서 곧바로 TIME_WAIT으로 전이한다.
  • 양쪽 앱이 거의 동시에 close()를 호출하면 CLOSING을 거치는 동시 종료(simultaneous close) 경로로 빠진다. 드물다.
    • 4-way handshake가 ① FIN → ② ACK → ③ FIN → ④ ACK 순서로 보이는 건 한쪽이 먼저 close()를 했기 때문이지, 프로토콜이 순서를 강제해서가 아니다. FIN은 상대 FIN에 대한 응답이 아니라, 각자 “나는 더 보낼 게 없다”를 독립적으로 통보하는 신호다. (응답에 해당하는 건 ②·④의 ACK뿐이다)
    • 그래서 상대 FIN이 도착하기 전에 양쪽이 각자 FIN을 보내버릴 수 있다. 이때는 둘 다 FIN_WAIT_1에서 상대 FIN을 받게 되고, FIN_WAIT_2가 아니라 CLOSING으로 간다. 서로 ACK를 주고받은 뒤 양쪽 모두 TIME_WAIT 에 들어간다.

4-way handshake는 누가 시작하나

정해진 주체는 없다. “더 보낼 데이터가 없다”고 판단한 쪽이 먼저 close()를 호출하고, 그쪽이 첫 FIN을 보낸다.

상황먼저 끊는 쪽
HTTP/1.0, Connection: close서버 (관례)
keep-alive 환경idle timeout이 먼저 만료되는 쪽
탭 닫기, 앱 종료클라이언트
서버 재시작/배포서버

keep-alive를 쓰는 요즘 구성에서는 결국 양쪽 타임아웃 설정값의 대소 관계가 누가 먼저 끊을지를 결정한다. 그리고 먼저 끊는 쪽이 TIME_WAIT을 떠안는다. 이 대소 관계를 어떻게 잡아야 하는지는 실무에서 생각해볼 것들에서 다룬다.

TIME_WAIT은 왜 2MSL인가


TIME_WAIT은 능동 종료 측(먼저 close()를 호출한 쪽)이 마지막 ACK를 보낸 뒤 2MSL 동안 머무는 상태다. 리눅스에서는 60초다.

먼저 MSL(Maximum Segment Lifetime) 부터 짚어두면, 세그먼트 하나가 네트워크에서 살아있을 수 있는 최대 시간이다. “이 시간이 지났으면 그 세그먼트는 어딘가에서 폐기됐다고 봐도 된다”는 값이다.

측정해서 얻는 값이 아니라 구현이 정하는 가정값이다. 리눅스는 30초로 잡고, 그래서 TIME_WAIT은 2MSL = 60초다. 이 가정의 근거와 다른 OS의 값은 뒤에서 다룬다.

TIME_WAIT의 두 가지 목적

(1) 옛 세그먼트가 새 연결에 섞이는 것 차단

TCP 세그먼트가 곧장 도착한다는 보장은 없다. 라우터 큐 지연, 우회 경로, 재전송으로 생긴 복사본 때문에 옛 세그먼트가 네트워크를 떠돌 수 있다.

1
2
3
4
5
[연결 1] (10.0.0.5:50000 ↔ 10.0.0.9:8080)
  seq=1000 "AAAA" 전송  → 지연 (라우터에 갇힘)
  seq=1000 "AAAA" 재전송 → 정상 도착
  ... 통신 후 정상 종료 ...
  첫 번째 "AAAA"는 아직 네트워크에 잔류

TIME_WAIT 없이 같은 4튜플(출발지 IP, 출발지 포트, 목적지 IP, 목적지 포트)로 즉시 새 연결을 열면,

  • 커널은 세그먼트를 4튜플로 소켓에 매칭하므로 옛 연결의 것과 새 연결의 것을 구분할 수 없다
  • 유령 세그먼트의 seq가 새 연결의 수신 윈도우 범위에 우연히 들어오면 옛 데이터가 새 스트림에 섞인다

그래서 2MSL 동안 해당 4튜플을 예약석처럼 잠가둔다. 잠금 대상은 소켓 객체가 아니라 4튜플이라는 주소 공간이다.

시퀀스 번호 대역이 겹치지 않게 하는 ISN 랜덤화가 여기에 더해져, 시간 격리(TIME_WAIT) + 번호 공간 격리(ISN) 의 이중 방어가 된다.

(2) 신뢰성 있는 종료 — 마지막 ACK 유실 대비

4-way handshake의 마지막 ACK는 TCP에서 유일하게 “ACK에 대한 ACK”가 없는 패킷이다. 보낸 쪽은 상대가 받았는지 확인할 방법이 없다.

TIME_WAIT 없이 바로 닫았다면,

  1. 서버(LAST_ACK)는 ACK를 못 받아 FIN을 재전송한다
  2. 클라이언트는 이미 CLOSED라 연결 정보가 없다 → RST로 응답
  3. 서버는 정상 종료 중에 RST를 받아 에러로 종료(connection reset)

그런데 여기서 “에러로 종료되는 게 뭐가 문제인가” 싶을 수 있다. 데이터는 이미 다 오갔고 연결은 어차피 닫히는 중이다. 실제로 피해가 크지는 않지만, 다음 세 가지가 걸린다.

  • 종료가 확인되지 않는다. 서버는 “상대가 내 FIN을 받았다”를 끝내 확인하지 못한 채 끝난다. TCP가 보장하려던 “양쪽 합의 하의 종료”가 깨진다.
  • RST는 남은 데이터를 버린다. FIN은 “여기까지가 내 데이터의 끝”이라는 표시라 그 앞의 데이터는 모두 앱에 전달되지만, RST는 연결을 즉시 파기하며 수신 버퍼에 남아 있던 데이터를 폐기한다. 앱이 아직 읽지 않은 게 있었다면 그대로 유실된다.
  • 관측이 오염된다. 아무 문제 없이 끝난 요청인데도 reset 카운터(netstat -s)와 패킷 캡처에는 에러로 잡힌다. 진짜 장애로 인한 reset과 섞여 원인 추적이 어려워진다.

정리하면 “통신이 깨진다”기보다 “깨끗하게 끝났다는 걸 아무도 확인할 수 없게 된다” 에 가깝다. 뒤의 재전송 FIN까지 전부 유실되면?에서 보듯 TCP는 이 상황을 완전히 없애지 못하고, TIME_WAIT은 그 확률을 크게 낮추는 장치다.

TIME_WAIT이 있으면,

  1. 클라이언트가 2MSL 동안 소켓 정보를 유지한다
  2. 재전송된 FIN이 도착하면 연결을 여전히 인식하므로 ACK를 재전송한다
  3. 서버도 정상적으로 CLOSED에 도달한다

왜 하필 2MSL인가

세그먼트 하나의 수명이 MSL이라면, 유령 세그먼트가 사라지기를 기다리는 데는 1MSL로 충분할 것 같다. 왜 2배인가.

핵심은 재전송 FIN이다. 능동 종료 측이 마지막 ACK를 보낸 시점을 T=0이라 하면,

  • 케이스 A — 연결 중에 보내진 옛 데이터: TIME_WAIT 진입 이전에 이미 네트워크로 나간 세그먼트다. 진입 후 최대 1MSL이면 소멸한다.
  • 케이스 B — 상대는 TIME_WAIT 진입 이후에도 새 세그먼트를 내보낼 수 있다:
1
2
3
4
5
T=0      내가 마지막 ACK 전송 → TIME_WAIT 진입
         (이 ACK가 유실/지연될 수 있음, 최대 MSL)
T≈MSL    상대(LAST_ACK): ACK 미수신 → FIN 재전송
         ← 옛 연결에 속한, 이제 막 네트워크에 나온 세그먼트
T≈2MSL   이 재전송 FIN의 수명(MSL)까지 지나야 완전 소멸이 확정

상대는 내 ACK를 받기 전까지 계속 재전송하는 살아있는 발신자다. 옛 연결이 만들어낼 수 있는 마지막 세그먼트가 바로 이 재전송 FIN이다.

2MSL = 마지막 세그먼트의 출생 가능 시한(≈1MSL) + 그 세그먼트의 수명(1MSL)

앞의 두 목적이 이 하나의 계산으로 통합된다. 재전송 FIN이 도착할 수 있는 창을 다 덮으면(신뢰성 있는 종료), 그 창 안에서 태어난 유령 세그먼트의 수명까지 자동으로 덮인다(유령 차단).

덧붙여, FIN 재전송을 수신하면 타이머는 2MSL로 리셋된다. 반복 재전송에도 대응하기 위해서다.

리눅스는 왜 60초인가

RFC 793은 MSL을 2분으로 잡는다. Section 3.3 (Sequence Numbers)For this specification the MSL is taken to be 2 minutes. 라고 적혀 있다. 그런데 리눅스의 TIME_WAIT은 60초다.

구현MSL 가정TIME_WAIT (2MSL)
RFC 793 원문2분4분
리눅스30초60초 (커널 상수, sysctl로 변경 불가)
전통 BSD30초60초
Windows60초120초 (조정 가능)

RFC의 2분은 1981년 기준의 보수적인 공학적 선택이지 강제 상수가 아니다. 리눅스는 include/net/tcp.h#define TCP_TIMEWAIT_LEN (60*HZ)로 박아뒀다. 근거는 IP TTL(보통 64) 덕분에 현대 인터넷에서 패킷이 30초 이상 생존하기 어렵다는 것이다.

실제로 TIME_WAIT 길이를 바꾸는 sysctl은 존재하지 않는다. 이름이 비슷한 net.ipv4.tcp_fin_timeout은 뒤에서 볼 FIN_WAIT_2 타임아웃이지 TIME_WAIT과 무관하다.

1
2
3
4
5
6
7
8
9
$ ls /proc/sys/net/ipv4/ | grep -E 'tw|time'
ipfrag_time
tcp_fastopen_blackhole_timeout_sec
tcp_fin_timeout
tcp_keepalive_time
tcp_max_tw_buckets
tcp_thin_linear_timeouts
tcp_timestamps
tcp_tw_reuse

여기서 말하는 “수학적 보장”은 정확히는 가정한 MSL이 참이라는 전제 하의 보장이다.

재전송 FIN까지 전부 유실되면?

Q. 마지막 ACK가 유실되고, 재전송된 FIN마저 유실되면, TIME_WAIT 소켓이 먼저 정리되고 나중에 서버에서 connection reset이 날 수도 있지 않나?

맞다. TCP는 이걸 완벽하게 막지 못한다.

  1. 클라이언트의 마지막 ACK 유실
  2. 서버의 FIN 재전송도 반복 유실
  3. FIN이 도착하지 않으면 타이머 리셋도 없으므로, 클라이언트의 TIME_WAIT은 2MSL 후 CLOSED가 된다
  4. 이후 뒤늦게 FIN이 도착하면 연결 정보가 없어 RST로 응답 → 서버는 reset으로 끝난다

다만 서버도 무한정 기다리지는 않는다. FIN 재전송 역시 일반 재전송 메커니즘(지수 백오프 + 재시도 한도)을 따르고, 리눅스에서는 close() 후 고아 소켓이 되면 tcp_orphan_retries 계열 설정이 한도를 정한다.

상황서버의 결말
재전송 FIN이 TIME_WAIT 만료 후 도착RST 수신 → 에러성 종료
재전송 FIN이 전부 유실재시도 한도 초과 → 타임아웃으로 강제 정리

어느 쪽이든 우아한 종료 확인에는 실패하지만 자원이 영구 누수되지는 않는다. 애플리케이션은 이미 close()를 호출한 뒤이고 데이터 교환도 끝난 상태라 실질적 피해도 거의 없다.

이건 구현의 한계가 아니라 두 장군 문제(Two Generals Problem) 다. “마지막 메시지를 받았다는 확인”에는 그 확인에 대한 확인이 또 필요해 무한 회귀에 빠진다. 유실 가능한 채널 위에서 양쪽이 종료에 완벽히 합의하는 것은 이론적으로 불가능하다.

TIME_WAIT은 완벽한 보장이 아니라, 실패 확률을 무시 가능한 수준까지 낮추는 확률적 개선 장치다.

TIME_WAIT이 60초보다 오래 남을 수 있나

Q. ACK가 계속 유실되고 서버가 FIN 재전송을 반복하면, TIME_WAIT이 60초보다 훨씬 오래 남을 수 있나?

이론적으로 가능하다. FIN을 받을 때마다 타이머가 2MSL로 리셋되기 때문이다.

1
2
3
4
5
6
7
8
클라이언트 (TIME_WAIT)              서버 (LAST_ACK)
   │ ──── ACK ──X (유실)                │
   │ ◀─── FIN (재전송 1) ────────────── │
   타이머 60초로 리셋
   │ ──── ACK ──X (유실)                │
   │ ◀─── FIN (재전송 2) ────────────── │
   타이머 또 60초로 리셋
   │            ...                     │

다만 무한정 연장되지는 않는다. 상한이 둘 있다.

  1. 서버의 재전송 횟수 제한 — 서버가 재시도 한도에 도달하면 포기하고 연결을 정리한다. FIN 공급이 끊기면 리셋도 멈추므로 마지막 리셋 후 60초면 정리된다.
  2. 백오프 간격이 2MSL을 넘어서는 시점 — 재전송 간격은 RTO에서 시작해 2배씩 늘어난다(리눅스 상한 120초). 간격이 60초를 넘는 순간, 다음 FIN이 도착하기 전에 TIME_WAIT이 먼저 만료된다. 이후 도착하는 FIN에는 RST로 응답하고 상황이 끝난다.

연장은 가능하되 유한하게 수렴한다. 게다가 전제 자체가 특이하다. 클라이언트→서버 방향 ACK만 유실되고 서버→클라이언트 방향 FIN은 잘 도착하는 비대칭 유실이 지속되어야 하므로 실제로는 드물다.

설계 의도로 보면, “재전송 FIN이 온다 = 상대가 아직 종료 확인을 못 받았다”이므로 계속 응답해주는 대신 TIME_WAIT이 다소 연장될 수 있게 만든 것이다.

정리

항목내용
신뢰성 있는 종료마지막 ACK 유실 시 재전송 FIN에 ACK로 재응답 → 상대도 정상 종료
보장 수준완벽한 보장이 아닌 확률적 개선 (두 장군 문제)
실패 시 안전망서버 측 재시도 한도 → 타임아웃으로 강제 정리, 자원 누수 없음
TIME_WAIT 연장FIN 재수신 시 리셋으로 60초 초과 가능하나 유한하게 수렴
발생 위치능동 종료 측에만 발생 → 서버가 먼저 끊는 구조면 서버에 누적

CLOSE_WAIT은 언제 생기나


CLOSE_WAIT은 상대의 FIN은 받았지만 내 애플리케이션이 아직 close()를 호출하지 않은 상태다. 커널이 알아서 정리해주지 않는다.

서버가 close()를 안 하면 LAST_ACK가 아니라 CLOSE_WAIT

Q. 서버 앱이 close()를 안 하면 클라이언트는 FIN_WAIT_2, 서버는 LAST_ACK로 남나?

아니다. 서버는 LAST_ACK가 아니라 CLOSE_WAIT에 머문다.

LAST_ACK는 서버가 close()를 호출해 FIN(③)을 보낸 후 진입하는 상태다. close()를 안 하면 ③ 자체가 발생하지 않으므로 흐름이 ② ACK에서 멈춘다.

1
2
클라이언트: FIN_WAIT_2   ← 상대 FIN 대기 (영영 안 옴)
서버:       CLOSE_WAIT   ← 앱이 close()를 안 해서 커널이 못 넘어감

CLOSE_WAIT은 “상대 FIN은 받았고, 앱의 close() 호출을 기다리는” 상태다. 앱이 fd를 들고 있는 한 커널 입장에서는 정상 상태라 타임아웃이 없다. 프로세스가 close()를 하거나 프로세스 자체가 종료될 때까지 무한정 잔존한다.

그때 클라이언트는 FIN_WAIT_2

반대편 FIN_WAIT_2는 클라이언트가 어떻게 종료했느냐에 따라 갈린다.

클라이언트의 종료 방식FIN_WAIT_2 처리
close() 호출 (일반적)fd 해제 → 고아(orphan) 소켓 → 커널이 tcp_fin_timeout(기본 60초) 후 파기
shutdown(SHUT_WR) 후 fd 유지 (half-close)앱이 아직 수신 중일 수 있어 커널이 정리 불가 → 상대 FIN까지 무기한 가능 (keepalive로 상대 사망 감지는 가능)

공교롭게 둘 다 60초라 혼동 주의

Q. 일반적인 경우엔 TIME_WAIT 60초를 기다리는 거 아니었나?

맞다. 다만 위 상황과는 서로 다른 시나리오다. TIME_WAIT상대의 FIN을 받아야만 진입하는 상태다.

1
2
3
4
5
6
7
8
9
close()
  ↓
FIN_WAIT_1  (내 FIN에 대한 ACK 대기)
  ↓ ACK 수신
FIN_WAIT_2  (상대 FIN 대기)        ← 여기서 갈림
  ↓ FIN 수신                       ← 이게 와야만
TIME_WAIT   (2MSL = 60초)          ← 진입 가능
  ↓
CLOSED

서버가 close()를 안 하면 handshake가 미완성이라 TIME_WAIT 진입 조건 자체가 성립하지 않는다. 건너뛰는 게 아니라 애초에 도달할 수 없다. 그래서 이 시나리오에서는 FIN_WAIT_2의 고아 소켓 타임아웃이 대신 정리한다.

대기상태성격
TIME_WAIT 유지TIME_WAIT60초 (2MSL, 커널 상수)프로토콜이 요구하는 대기 — 지연 세그먼트 차단 + ACK 재응답
FIN_WAIT_2 타임아웃FIN_WAIT_260초 (tcp_fin_timeout, 변경 가능)진행 불가 시 포기 타이머 — 고아 소켓 강제 정리 안전장치

커널은 왜 CLOSE_WAIT을 못 걷어가나 — half-close

커널이 CLOSE_WAIT이나 (fd가 살아있는) FIN_WAIT_2를 임의로 정리하지 못하는 건, 한쪽 방향만 닫는 half-close가 버그가 아니라 의도된 기능이기 때문이다.

 close()shutdown(SHUT_WR)
FIN 전송OO
수신 방향닫힘열려 있음 (계속 read 가능)
fd해제유지
의미“연결 끝”“송신만 끝, 수신은 계속”

half-close는 “나는 다 보냈지만, 네가 보내는 건 계속 받겠다”는 뜻이다. 대표적인 쓰임은 “요청 끝”을 EOF로 알리는 것인데, 이게 무슨 상황인지부터 보자.

TCP는 바이트 스트림이라 메시지 경계라는 개념이 없다. 서버가 read()로 요청을 받는다고 할 때, 서버는 지금 읽은 데이터가 요청의 전부인지 아니면 뒷부분이 아직 오는 중인지를 TCP만 봐서는 알 수 없다. 그래서 경계는 앱 프로토콜이 따로 정해야 하고, 방법은 보통 둘이다.

  • 길이를 미리 알려주기 — HTTP의 Content-Length
  • 구분자를 정하기 — 개행 문자로 끝을 표시하는 식

half-close는 여기에 세 번째 방법을 준다. 송신 방향만 닫아서 상대의 read()가 0(EOF)을 반환하게 만드는 것이다. 서버 입장에서는 read()가 0을 돌려준 순간이 곧 “요청이 여기서 끝났다”가 된다. 길이를 미리 계산할 필요도, 구분자를 정할 필요도 없다.

그런데 이때 close()를 쓰면 안 된다. close()는 수신 방향까지 닫아버려서 정작 서버가 보낸 응답을 받을 수 없게 된다. 그래서 shutdown(SHUT_WR)로 송신만 닫고 응답은 계속 읽는다.

1
2
3
4
5
6
write(sock, request, len);      // 요청 전송
shutdown(sock, SHUT_WR);        // 송신만 닫음 → 서버의 read()가 0(EOF) 반환 = "요청 끝"
while ((n = read(sock, buf, sz)) > 0) {
    // 수신 방향은 열려 있으므로 응답은 끝까지 받을 수 있다
}
close(sock);                    // 진짜 종료

초기 HTTP/1.0, 프록시·릴레이의 방향별 종료 전파, 배치 업로드 후 결과 수신, rsh/ssh류의 stdin 종료 전달 등이 이 패턴이다.

그래서 커널 입장에서는,

  • close()를 한 경우: fd를 놓았으니 받을 주체가 없다 → 고아 소켓 → 파기해도 무방
  • shutdown(SHUT_WR)만 한 경우: 앱이 아직 read() 중일 수 있다 → 커널이 멋대로 정리하면 앱이 받아야 할 데이터를 끊는 것 → 타임아웃을 걸 수 없다

half-close 패턴을 쓴다면 커널 타임아웃에 기대지 말고 애플리케이션 레벨 read 타임아웃을 두는 것이 일반적이다.

참고로 shutdown의 세 모드는 다음과 같다.

  • SHUT_WR: 송신만 닫음 (실무에서 주로 사용)
  • SHUT_RD: 수신만 닫음 (로컬에서만 의미, 상대에게 통지되지 않음)
  • SHUT_RDWR: 양방향 닫되 fd는 유지

정리

위치 / 상황상태정리 시점
서버 (close 안 함)CLOSE_WAITclose() 또는 프로세스 종료까지 무한정 — 커널 타임아웃 없음
클라이언트 (close 후)FIN_WAIT_2고아 소켓 → tcp_fin_timeout(기본 60초) 후 커널 파기
클라이언트 (shutdown 후 fd 유지)FIN_WAIT_2상대 FIN 도착까지 무기한 가능
정상 종료 (양쪽 close)TIME_WAIT2MSL(60초) 후 CLOSED

이 시나리오의 실질적 위험은 클라이언트가 아니라 서버의 CLOSE_WAIT 누적이다.

실무에서 생각해볼 것들


도입부에서 언급한 두 고갈(ephemeral port, fd)이 실제로 어떤 계산에서 나오는지, 그리고 애초에 누가 먼저 끊게 만들어야 하는지를 정리한다.

TIME_WAIT과 ephemeral port 고갈

클라이언트가 connect()를 하면 커널이 출발지 포트를 자동으로 골라준다. 그 범위가 ip_local_port_range다.

1
2
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768   60999

60999 − 32768 + 1 = 28,232개다. TIME_WAIT이 4튜플을 60초간 잡고 있으므로, 동일한 (목적지 IP, 목적지 포트)로 새 연결을 계속 여는 경우의 산술적 상한은 대략

1
28,232 / 60초 ≈ 초당 470 커넥션

이 정도다. 여기서 중요한 건 잠기는 대상이 출발지 포트 단독이 아니라 4튜플이라는 점이다. 목적지가 다르면 같은 출발지 포트를 다시 쓸 수 있으므로, 이 제약은 한 upstream을 집중적으로 두드릴 때 문제가 된다. WAS가 특정 API 서버나 DB 하나로 매 요청마다 새 커넥션을 여는 구조가 전형적이다.

관련 sysctl은 다음과 같다.

설정이 환경의 값의미
net.ipv4.ip_local_port_range32768 60999ephemeral port 범위. 넓히면 상한이 올라간다
net.ipv4.tcp_tw_reuse2아웃바운드 연결에 한해 TIME_WAIT 소켓 재사용. 0=끔, 1=켬, 2=루프백만(기본). 타임스탬프 옵션 필요
net.ipv4.tcp_max_tw_buckets8192TIME_WAIT 소켓 개수 상한. 초과분은 즉시 파기되고 경고 로그가 남는다 (값은 메모리에 따라 환경마다 다름)

과거에 자주 언급되던 tcp_tw_recycle은 NAT 뒤 클라이언트의 연결을 깨뜨리는 문제가 있어 리눅스 4.12에서 제거됐다. 지금은 선택지가 아니다.

다만 근본 해법은 sysctl 튜닝이 아니라 커넥션 재사용이다. keep-alive와 커넥션 풀로 연결을 돌려쓰면 TIME_WAIT이 애초에 대량으로 생기지 않는다.

CLOSE_WAIT과 fd 고갈

netstat/ssCLOSE_WAIT이 다수 누적되어 있다면 거의 항상 애플리케이션 버그다. 전형적인 원인은 read()가 0(EOF)을 반환했는데 fd를 닫지 않는 것이다. 커넥션 풀 반환 누락, 에러 경로에서의 close() 누락 등이 여기 해당한다.

방치하면 소켓 fd와 커널 메모리를 계속 점유하다가 프로세스의 fd 상한(ulimit -n)에 도달한다. 이 시점부터는 소켓뿐 아니라 파일 열기, DB 커넥션 획득, HTTP 호출까지 전부 too many open files로 실패한다. 도입부에서 말한 전면 장애가 이것이다.

TIME_WAIT 누적은 커널이 관리하는 정상 동작이고, CLOSE_WAIT 누적은 고쳐야 할 코드 버그다.

누가 먼저 끊게 만들 것인가

앞에서 본 대로 keep-alive 환경에서는 idle timeout이 먼저 만료되는 쪽이 능동 종료 측이 된다. nginx(keepalive 60s)와 Tomcat(keepAliveTimeout 75s)을 예로 들면,

1
2
3
4
5
t=0     연결 idle 시작
t=60s   nginx 타임아웃 도달 → 먼저 FIN
        nginx : FIN_WAIT_1 → ... → TIME_WAIT (60초 점유)
        Tomcat: CLOSE_WAIT → close() → LAST_ACK → CLOSED (즉시)
t=75s   Tomcat 타이머는 발동할 일이 없음

항상 nginx가 능동 종료 측이므로 TIME_WAIT은 전부 nginx에 쌓인다. 이 방향이 의도된 권장 구성이다.

반대(Tomcat 쪽이 더 짧은 경우)가 위험한 이유는,

  • Tomcat이 FIN을 보내는 순간 nginx가 그 연결에 새 요청을 싣는 race가 발생할 수 있다
  • → RST → upstream prematurely closed connection → 502
  • POST 같은 비멱등 요청은 재시도도 어렵다

연결을 빌려 쓰는 쪽(클라이언트 역할)의 timeout < 서버 역할의 timeout

“죽어가는 커넥션에 요청을 싣는” 경쟁을 원천 차단한다.

같은 원리가 적용되는 곳들이다.

  • nginx upstream keepalive < Tomcat keepAliveTimeout
  • HTTP 커넥션 풀 idle TTL < 외부 API 서버 keep-alive
  • HikariCP maxLifetime < MySQL wait_timeout

부록 · TCP 기초 개념


위 본문에서 전제한 세그먼트·시퀀스 번호·수신 윈도우·ISN 개념을 모아둔다.

세그먼트

TCP가 데이터를 실어 나르는 단위 조각이다. 앱이 write()한 데이터를 MSS(보통 1460바이트 안팎) 크기로 잘라 전송한다.

계층별 명칭은 데이터/메시지(앱) → 세그먼트(TCP) → 패킷(IP) → 프레임(링크) 순이다.

시퀀스 번호

“이 세그먼트의 데이터가 전체 스트림에서 몇 번째 바이트부터인지”를 나타낸다. 세그먼트 단위가 아니라 바이트 단위 번호다.

1
2
3
4
"HELLO WORLD" (11바이트), 시작 seq=1000 가정
  세그먼트1: seq=1000, "HELLO"  (1000~1004)
  세그먼트2: seq=1005, " WORL"  (1005~1009)
  세그먼트3: seq=1010, "D"      (1010)

순서가 뒤바뀌면 버퍼에 대기시켰다 재조립하고, 중복이면 폐기하고, 유실되면 재전송을 요청한다. 순서도 보장하지 않고 중복도 허용하는 IP 위에서 “순서 보장된 바이트 스트림”이라는 추상을 만들어내는 장치다.

수신 윈도우 범위

수신 측이 “지금 받아줄 수 있는 시퀀스 번호 구간”이다. 다음 기대 바이트(rcv_nxt)부터 버퍼 여유분까지다.

1
2
3
4
1000까지 수신, 버퍼 여유 5000 → 윈도우 = [1001 ~ 6000]
  seq=1500 → 범위 안 ✓ 수락
  seq=800  → 이미 지남 ✗ 폐기 (ACK 재전송)
  seq=9999 → 너무 앞섬 ✗ 폐기

이 범위 체크는 확률적 방어다. 유령 세그먼트가 우연히 이 구간에 들어오면 통과해 버린다. 그래서 시간 격리(TIME_WAIT)가 추가로 필요하다.

같은 연결 안에서 지연 세그먼트가 늦게 도착하면?

문제없다. 같은 연결 안에서는 시퀀스 번호가 문맥을 유지하므로 중복·지연·역전이 모두 안전하게 걸러진다(이미 받은 번호면 조용히 폐기). 위험한 건 오직 연결이 죽고 같은 4튜플로 새 연결이 생겨 번호 문맥이 리셋된 이후다.

ISN(Initial Sequence Number)

시퀀스 번호는 1부터 시작하지 않는다. 연결마다, 그리고 방향마다 별개로 랜덤한 시작 번호(ISN)를 뽑는다. 3-way handshake에서 교환하는 것이 바로 이 값이다.

1
2
3
Client → Server:  SYN,     seq=x              (x = 클라이언트 ISN)
Server → Client:  SYN+ACK, seq=y, ack=x+1     (y = 서버 ISN)
Client → Server:  ACK,     ack=y+1
  • 32비트 공간(0 ~ 약 42억)에서 랜덤하게 뽑는다. 앞선 예시의 1000은 단순화한 가정값이다.
  • SYN과 FIN은 데이터가 없어도 시퀀스 번호 1을 소비한다. 그래서 첫 데이터는 ISN+1부터다. FIN(seq=m)ack=m+1로 응답하는 것도 같은 규칙이다.
  • Wireshark에 보이는 seq=0,1,2...는 ISN을 뺀 상대값 표시일 뿐이다.

랜덤인 이유는 두 가지다.

  1. 유령 세그먼트 방어 — 모든 연결이 seq=1부터 시작하면 옛 연결과 새 연결의 번호 대역이 반드시 겹친다. ISN 랜덤화로 번호 공간을 격리한다. TIME_WAIT(시간 격리)과 합쳐 이중 방어가 된다.
  2. 보안 — ISN이 예측 가능하면 blind spoofing으로 연결 위조·하이재킹이 가능하다(1994년 케빈 미트닉 사건). 현대 커널은 RFC 6528 방식(4튜플 + 비밀키 해시)을 쓴다.

참고 자료

This post is licensed under CC BY 4.0 by the author.