← 문제로

16. TCP 연결 종료(4-way), TIME_WAIT/CLOSE_WAIT, SO_REUSEADDR/REUSEPORT, 포트 고갈

난이도 중
내 답안
모범답안

난이도: 중

1. 4-way handshake 와 close/shutdown

TCP 는 전이중(full-duplex)이라 양방향 스트림을 각각 닫는다.

  1. A → B: FIN (A 가 "더 보낼 것 없음"). A: FIN_WAIT_1.
  2. B → A: ACK (B 가 FIN 수신 확인). A: FIN_WAIT_2, B: CLOSE_WAIT.
  3. B → A: FIN (B 도 다 보냈으면 자기 방향을 닫음). B: LAST_ACK.
  4. A → B: ACK. A: TIME_WAIT → (2×MSL 후) CLOSED, B: CLOSED.

2단계와 3단계 사이에 B 는 아직 데이터를 더 보낼 수 있다(A→B 방향만 닫힌 half-close).

  • close(): 소켓을 완전히 닫고 파일 디스크립터를 반납한다. 양방향 종료 시작.
  • shutdown(SHUT_WR): 송신 방향만 닫아 FIN 을 보내되 수신은 계속 한다. "마지막 응답을 보낸 뒤 상대의 남은 데이터/ACK 를 마저 읽고 닫기" 같은 graceful 종료에 쓴다. shutdown(SHUT_RD/RDWR) 도 있다.
  • half-close 를 안 쓰고 곧바로 close() 하면, 커널 수신 버퍼에 안 읽은 데이터가 남았을 때 FIN 대신 RST 가 나가 미전송 데이터가 잘릴 수 있다.

2. TIME_WAIT

  • 누가: 능동적으로 먼저 닫은(active close) 쪽이 TIME_WAIT 에 들어간다.
  • 왜 존재:
    1. 지연 세그먼트 격리: 네트워크에 떠도는 옛 연결의 늦은 세그먼트가, 같은 4-튜플로 새로 맺은 연결에 섞여 들어오는 것을 막는다(포트/시퀀스 재사용 오염 방지).
    2. 마지막 ACK 신뢰성: 4단계의 마지막 ACK 가 유실되면 상대가 FIN 을 재전송하는데, TIME_WAIT 동안 소켓 상태를 남겨 둬야 재전송된 FIN 에 다시 ACK 할 수 있다.
  • 왜 2×MSL: MSL(Maximum Segment Lifetime)은 세그먼트가 네트워크에 존재할 수 있는 최대 시간. 왕복(내 ACK 이 가고 + 상대 FIN 이 다시 올) 최악을 커버하려면 2×MSL(리눅스는 관례적으로 약 60초)이 필요.
  • 부담 위치: TIME_WAIT 은 능동 종료 측에 쌓인다. 서버가 매 연결을 능동적으로 닫으면 서버에 TIME_WAIT 소켓이 대량 누적된다. 반대로 클라이언트가 닫게 하면 부담이 클라이언트로 간다. → 가능하면 클라이언트가 먼저 닫게 설계하거나, 연결을 재사용해 종료 빈도 자체를 줄인다.

3. CLOSE_WAIT

  • CLOSE_WAIT 은 상대의 FIN 을 받았지만(수동 종료) 우리 애플리케이션이 아직 close() 를 호출하지 않은 상태다. 대량으로 쌓였다면 거의 항상 애플리케이션 버그: 상대가 끊었는데(EOF/recv==0) 소켓을 닫지 않고 방치(예외 경로에서 close 누락, FD 누수). FD 누수로 이어져 결국 "too many open files".
  • 차이: TIME_WAIT 은 정상적으로 능동 종료한 뒤 커널이 잠시 유지하는 상태(시간이 지나면 자동 소멸, 대개 튜닝·설계 문제). CLOSE_WAIT 은 앱이 닫지 않아 무기한 남는 상태(코드 버그). 대응이 다르다.

4. 포트 고갈

  • 아웃바운드 연결(클라이언트/서버-서버 호출)은 로컬 에페메랄 포트를 하나씩 소비한다(범위는 보통 3만~6만 개 수준). 짧은 연결을 초당 수천 개 맺고 능동적으로 닫으면 각 연결이 TIME_WAIT 에 60초가량 머물며 포트를 붙잡는다. (같은 목적지 IP:port 로의) 사용 가능한 4-튜플이 고갈되면 신규 연결 실패(connect EADDRNOTAVAIL).
  • 완화책:
    1. 연결 재사용(keep-alive / 커넥션 풀): 매 요청마다 새로 맺지 말고 지속 연결을 재사용 — 근본 해결. 종료 빈도 자체를 줄인다.
    2. 능동 종료 측 바꾸기: 서버-서버라면 응답 쪽이 아니라 요청 쪽이 닫게 하거나, 장수명 연결 유지.
    3. 에페메랄 포트 범위 확대 / 목적지 분산: ip_local_port_range 확대, 여러 목적지 IP·포트로 분산.
    4. OS 튜닝: 리눅스 tcp_tw_reuse(아웃바운드에서 TIME_WAIT 소켓 재사용) 활성화. (tcp_tw_recycle 은 NAT 환경에서 문제라 제거됨 — 쓰지 말 것.) MSL/타임아웃 조정은 신중히.

5. SO_REUSEADDR vs SO_REUSEPORT

  • SO_REUSEADDR:
    • 주 용도는 재시작 시 즉시 bind. 서버가 능동 종료하면 리스닝 주소와 연관된 소켓이 TIME_WAIT 에 남아, 재시작 시 같은 포트에 bind 하면 Address already in use 가 난다. SO_REUSEADDR 은 TIME_WAIT 상태의 주소에도 bind 를 허용해 이 문제를 없앤다.
    • 또한 특정 상황에서 와일드카드/특정 IP 바인딩 공존 등을 허용. 단 완전히 동일한 4-튜플로 활성 연결 두 개를 허용하는 건 아니다.
  • SO_REUSEPORT:
    • 여러 소켓(여러 프로세스/스레드)이 같은 IP:port 에 동시에 bind 하도록 허용한다. 커널이 들어오는 연결을 이 소켓들에 분산(로드 밸런싱) 한다. accept 를 여러 워커가 각자 자기 리스닝 소켓으로 받아 단일 accept 락/병목을 없애는 멀티 프로세스 스케일링 패턴에 쓴다(게임 게이트웨이/L4).
    • 주의: 워커가 죽으면 그 소켓 대기열의 연결 처리에 공백이 생길 수 있고, 커널 버전별 분산 해시 동작 차이가 있다.
  • 요약: SO_REUSEADDR = 재시작·TIME_WAIT 주소 재바인드, SO_REUSEPORT = 같은 포트를 여러 리스너가 병렬로 나눠 받기(스케일아웃). 목적이 다르므로 함께/따로 쓴다.

면접 포인트

  • 능동 종료 측이 TIME_WAIT 을 진다 → 서버에 TIME_WAIT 이 쌓이면 "누가 먼저 닫는가"·"연결 재사용"을 본다.
  • CLOSE_WAIT 누적 = 앱이 close 를 빠뜨린 버그(FD 누수), TIME_WAIT 과 원인·대응이 다르다.
  • 포트 고갈의 정체(에페메랄 포트 × TIME_WAIT)와 커넥션 풀·keep-alive 로의 근본 해결.
  • SO_REUSEADDR(재바인드) vs SO_REUSEPORT(병렬 accept 분산)의 목적 구분, tcp_tw_recycle 을 쓰면 안 되는 이유.