환경  Ubuntu 22.04 · Linux 6.8 · iproute2 5.15 · 2026-09-13 확인

외부 API 를 호출하는 배치가 어느 날부터 Cannot assign requested address 를 뱉으며 죽습니다. 서버에 들어가 ss -s 를 치니 timewait 이 2만을 넘고, netstat 에도 TIME_WAIT 이 화면을 가득 채웁니다. 다급하게 tcp_fin_timeout 을 10 으로 내렸는데 숫자는 꿈쩍도 안 합니다. 이 상태가 무엇을 기다리는 자리인지 모른 채 커널 값을 건드리면 여기서부터 꼬입니다.

먼저 끊은 쪽이 잠시 문을 잡고 서 있는 상태

TIME_WAIT 은 TCP 연결을 먼저 끊은 쪽이 마지막에 거치는 상태입니다. 상대에게 FIN 을 보내고, 상대의 FIN 을 받아 ACK 까지 돌려준 다음, 곧바로 소켓을 지우지 않고 한동안 붙들고 있습니다. 이 한동안이 TIME_WAIT 입니다.

기다리는 이유는 두 가지입니다. 하나는 내가 보낸 마지막 ACK 가 유실됐을 때 상대가 FIN 을 다시 보내올 텐데, 그걸 받아 줄 자리가 있어야 합니다. 소켓을 이미 지웠으면 그 FIN 에 RST 로 답하게 되고, 상대는 정상 종료가 아니라 오류로 끝난 것으로 봅니다.

다른 하나는 늦게 도착하는 옛 세그먼트입니다. 같은 네 쌍(내 주소·내 포트·상대 주소·상대 포트)으로 새 연결을 바로 열면, 네트워크 어딘가를 떠돌던 이전 연결의 패킷이 새 연결에 끼어들 수 있습니다. 그래서 그 패킷이 죽을 때까지 그 네 쌍을 비워 두지 않고 막아 둡니다.

핵심은 방향입니다. 이 상태는 close 를 먼저 부른 쪽에만 생깁니다. 서버가 응답을 주고 먼저 끊는 구조면 서버에 쌓이고, 클라이언트가 요청마다 연결을 새로 맺고 끊으면 클라이언트에 쌓입니다. 앞의 상황에서 배치 서버에 쌓인 것도 배치가 먼저 끊는 쪽이어서입니다.

ss -s | grep -i timewait
# TCP:   21874 (estab 12, closed 21830, orphaned 0, timewait 21803)

# 어느 상대·어느 포트에 몰려 있는지 세어 본다
ss -tan state time-wait | awk 'NR>1 {print $4}' | sort | uniq -c | sort -rn | head
#  21790 203.0.113.10:443

TIME_WAIT 이 한 상대 주소로 몰려 있으면, 그 상대에게 내가 먼저 끊는 연결을 짧게 반복하고 있다는 뜻입니다

TIME_WAIT 은 고장이 아니라 먼저 끊은 쪽이 치르는 정상 절차입니다. 문제는 그게 어디에 얼마나 쌓이느냐입니다.

왜 생겼나, 그리고 왜 하필 60초인가

TCP 규격은 이 대기 시간을 2×MSL 로 정합니다. MSL 은 세그먼트가 네트워크에서 살아 있을 수 있는 최대 시간이고, 규격은 이 값을 2분으로 잡아 두었습니다. 계산대로면 4분을 기다려야 합니다. 그래도 규격 스스로 이건 공학적 선택이라 경험에 따라 바꿔도 된다고 덧붙여 놓았습니다.

리눅스는 그 여지를 써서 60초로 굳혔습니다. 커널 소스의 TCP_TIMEWAIT_LEN 이 60×HZ 로 박혀 있고, 이 값은 sysctl 로 바꿀 수 없습니다. 이게 첫 번째 함정입니다. tcp_fin_timeout 이라는 이름만 보고 이걸 줄이면 TIME_WAIT 이 짧아질 것 같지만, 그 값은 FIN_WAIT_2 에서 고아가 된 연결을 얼마나 기다릴지 정하는 다른 항목입니다. 기본값이 똑같이 60초라서 더 헷갈립니다.

60초가 짧은 것 같아도 규모가 붙으면 순식간입니다. 한 상대에게 초당 500번 연결을 맺고 끊으면 60초 동안 3만 개가 쌓입니다. 나가는 연결이 쓸 수 있는 로컬 포트는 ip_local_port_range 기본값 32768 부터 60999 까지 2만 8천 개 남짓이고, 같은 상대 주소·포트로 나가는 연결끼리는 이 범위를 나눠 씁니다. 그 포트가 전부 TIME_WAIT 에 묶이면 connect 가 EADDRNOTAVAIL, 곧 Cannot assign requested address 로 실패합니다. 앞의 배치가 죽은 자리가 정확히 여기입니다.

반대로 서버 쪽에 쌓이는 TIME_WAIT 은 포트를 고갈시키지 않습니다. 서버는 listen 포트 하나로 받고 있고, 네 쌍 중 상대 주소·포트가 매번 다르니까요. 대신 개수 자체가 메모리를 먹기 때문에 tcp_max_tw_buckets 라는 천장이 있고, 넘어서면 커널이 경고를 찍고 그 자리에서 소켓을 없애 버립니다.

sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_fin_timeout net.ipv4.tcp_max_tw_buckets
# net.ipv4.ip_local_port_range = 32768	60999
# net.ipv4.tcp_fin_timeout = 60
# net.ipv4.tcp_max_tw_buckets = 65536

# 포트가 바닥났는지 재현: 한 상대에게 짧은 연결을 빠르게 반복
for i in $(seq 1 30000); do
  timeout 1 bash -c 'exec 3<>/dev/tcp/203.0.113.10/443' 2>/dev/null; exec 3>&- 2>/dev/null
done
curl -s -o /dev/null https://203.0.113.10/
# curl: (7) Failed to connect ... Cannot assign requested address

tcp_fin_timeout 은 여기 나오지만 TIME_WAIT 길이와는 무관합니다. 60초는 코드에 박힌 값입니다

커널이 실제로 하는 일, 그리고 tcp_tw_reuse 가 손대는 자리

TIME_WAIT 에 들어간 소켓은 원래 소켓 구조체를 그대로 두지 않습니다. 커널은 네 쌍과 타임스탬프 몇 개만 남긴 작은 구조체로 바꿔 치우고, 타이머를 걸어 60초 뒤에 지웁니다. 그래서 수만 개가 쌓여도 메모리는 생각보다 적게 듭니다. 문제는 메모리보다 포트입니다.

tcp_tw_reuse 는 나가는 연결에 한해 그 60초를 기다리지 않고 TIME_WAIT 에 있는 네 쌍을 다시 쓰게 해 줍니다. 조건이 있습니다. 상대가 TCP 타임스탬프를 켜고 있어야 하고, 옛 연결이 마지막으로 받은 타임스탬프보다 시간이 지나 옛 세그먼트와 구분이 돼야 합니다. 그래서 공식 설명이 '프로토콜 관점에서 안전할 때'라고 못 박습니다. 값은 0 이 끔, 1 이 전체 허용, 2 가 루프백만 허용이고 요즘 커널의 기본값은 2 입니다.

기본값이 2 라는 걸 모르면 또 한 번 헛수고를 합니다. 같은 서버 안의 프록시와 앱 사이, 곧 127.0.0.1 로 도는 연결은 이미 재사용이 되고 있습니다. 외부 상대에게 나가는 연결에서 포트가 바닥나는 상황이면 1 로 올려야 효과가 납니다. 반대로 서버가 받는 쪽에 쌓인 TIME_WAIT 에는 이 값이 아무 영향도 주지 않습니다. 재사용은 connect 하는 쪽 얘기입니다.

그리고 더 근본적인 조치는 커널이 아니라 연결을 맺는 방식에 있습니다. 한 상대에게 초당 수백 번 새 연결을 여는 코드라면 keep-alive 로 연결을 재사용하는 게 먼저입니다. 연결 수가 줄면 TIME_WAIT 은 저절로 줄어듭니다. sysctl 은 그다음에 만지는 값입니다.

sysctl net.ipv4.tcp_tw_reuse
# net.ipv4.tcp_tw_reuse = 2   ← 루프백만 재사용 중

# 외부 목적지로 나가는 연결에서 포트가 바닥날 때만 1 로 올린다
sudo sysctl -w net.ipv4.tcp_tw_reuse=1
echo 'net.ipv4.tcp_tw_reuse = 1' | sudo tee /etc/sysctl.d/90-tw-reuse.conf

# 다시 재현해 보면 connect 가 더는 실패하지 않는다
curl -s -o /dev/null -w '%{http_code}\n' https://203.0.113.10/
# 200

1 로 올리는 건 나가는 연결에만 듣습니다. 받는 쪽 TIME_WAIT 을 줄이려는 목적이면 값을 바꿔도 달라지지 않습니다

tcp_tw_reuse 는 connect 하는 쪽의 60초를 줄이는 스위치이고, 기본값 2 는 이미 루프백에서 켜져 있습니다.

이름이 비슷해서 자꾸 섞이는 것들

CLOSE_WAIT 과 TIME_WAIT 은 이름만 닮았지 성격이 반대입니다. CLOSE_WAIT 은 상대가 먼저 FIN 을 보냈는데 내 쪽 프로그램이 아직 close 를 안 부른 상태입니다. 커널이 기다리는 게 아니라 내 코드가 소켓을 안 닫고 있는 겁니다. 이건 시간이 지나도 안 사라지고, 늘어난다면 응답 객체를 안 닫는 코드 어딘가가 범인입니다. TIME_WAIT 은 60초면 알아서 사라집니다. ss 에서 CLOSE_WAIT 이 쌓이면 커널 값이 아니라 애플리케이션을 봐야 합니다.

tcp_tw_recycle 은 tcp_tw_reuse 와 짝처럼 오래 언급돼 왔지만 지금 커널에는 없습니다. 상대 주소별로 타임스탬프가 단조 증가한다는 가정 위에서 TIME_WAIT 을 빨리 지우는 기능이었는데, NAT 뒤에 여러 장비가 있으면 그 가정이 깨져 멀쩡한 연결이 끊겼습니다. 4.12 에서 아예 제거됐고, ip-sysctl 문서에서도 항목이 사라졌습니다. 오래된 튜닝 글을 그대로 옮겨 sysctl 에 넣으면 그 줄은 조용히 무시되거나 오류가 납니다.

SO_REUSEADDR 는 또 다른 층의 얘기입니다. 이건 bind 단계의 규칙이라, 서버를 재시작할 때 방금 닫은 listen 포트에 아직 TIME_WAIT 이 남아 있어도 다시 bind 할 수 있게 해 줍니다. 서버 프레임워크 대부분이 기본으로 켜고 있어 요즘은 Address already in use 를 보기 어렵습니다. 이 옵션이 TIME_WAIT 을 지우는 게 아니라, TIME_WAIT 이 있어도 bind 를 허용하는 것뿐입니다.

SO_REUSEPORT 는 이름은 한 글자 차이인데 용도가 다릅니다. 같은 주소·포트에 소켓 여러 개를 동시에 bind 해서 accept 부하를 여러 스레드나 프로세스에 나누는 옵션입니다. 첫 소켓부터 전부 이 옵션을 켜야 하고, 같은 실효 UID 여야 합니다. TIME_WAIT 과는 아예 무관합니다.

정리하면 이렇습니다. TIME_WAIT 은 먼저 끊은 쪽이 60초 치르는 정상 절차이고, sysctl 로 그 길이를 못 줄입니다. 나가는 연결에서 포트가 바닥나면 tcp_tw_reuse=1, 그보다 먼저 연결 재사용. 받는 쪽에 쌓인 건 그냥 두되 tcp_max_tw_buckets 만 확인. CLOSE_WAIT 이 쌓이면 코드를 보고, tcp_tw_recycle 은 잊으면 됩니다.

# 상태별 개수를 한 번에 본다
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
#  21803 TIME-WAIT
#     12 ESTAB
#      3 LISTEN
#      0 CLOSE-WAIT      ← 여기가 늘면 커널이 아니라 내 코드

# 옛 튜닝 글의 흔적이 남아 있는지
grep -rn tw_recycle /etc/sysctl.conf /etc/sysctl.d/ 2>/dev/null
sudo sysctl -w net.ipv4.tcp_tw_recycle=1
# sysctl: cannot stat /proc/sys/net/ipv4/tcp_tw_recycle: No such file or directory

tcp_tw_recycle 은 4.12 이후 커널에 없습니다. sysctl.d 에 남아 있으면 부팅 로그에 오류만 남깁니다

조치 후 확인할 것

  • ss -tan state time-wait 로 TIME_WAIT 이 어느 상대 주소·포트에 몰려 있는지 세어 본다. 한 곳에 몰려 있으면 그 상대에게 연결을 재사용하지 않는 코드가 있다.
  • Cannot assign requested address 가 났다면 나가는 연결의 로컬 포트 고갈이다. tcp_tw_reuse 값이 2(루프백만)인지 확인하고, 외부 목적지라면 1 로 올린 뒤 connect 가 되는지 다시 본다.
  • tcp_fin_timeout 을 건드렸다면 원래 값 60 으로 되돌린다. TIME_WAIT 길이와 무관하고, 낮추면 FIN_WAIT_2 고아 연결만 일찍 끊긴다.
  • /etc/sysctl.conf 와 /etc/sysctl.d/ 에 tcp_tw_recycle 이 남아 있으면 지운다. 4.12 이후 커널에서는 존재하지 않는 키다.
  • CLOSE_WAIT 이 0 이 아니고 시간이 지나도 줄지 않으면 커널이 아니라 애플리케이션에서 close 를 빠뜨린 자리를 찾는다.

TIME_WAIT 이 수만 개라는 숫자만 보고 커널 값을 이것저것 만지면 대개 아무 일도 안 일어나거나, 엉뚱한 곳이 짧아집니다. 어느 쪽이 먼저 끊고 있는지, 그 연결이 나가는 것인지 들어오는 것인지 두 가지만 가려내면 손댈 자리는 하나로 좁혀집니다.