Fuwari Banner
지니제스트Tech Archive
7분 소요

TIME_WAIT 소켓 누적과 포트 고갈 해결

ggeniezst

대량의 트래픽 처리 중 발생하는 Cannot assign requested address 에러의 근본 원인을 분석하고, 커널 tcp_tw_reuse 튜닝과 Nginx upstream keepalive 설정을 통해 포트 고갈을 해결하는 실무 가이드입니다.

Sponsored

마이크로서비스 아키텍처나 대규모 트래픽을 처리하는 리버스 프록시 환경에서 백엔드 API를 호출할 때 connect() failed (99: Cannot assign requested address) 에러가 발생하며 간헐적으로 통신이 단절되는 현상이 발생합니다. netstatss 명령어로 소켓 상태를 확인하면 수만 개의 TIME_WAIT 소켓이 임시 포트(Ephemeral Port) 대역을 가득 채우고 있는 것이 일반적인 원인입니다.

이 문제는 단순한 일시적 트래픽 스파이크가 아니라 TCP 프로토콜의 정상적인 연결 종료 시퀀스와 리눅스 커널의 포트 할당 메커니즘이 맞물려 발생합니다. 운영체제 레벨의 커널 파라미터 최적화와 애플리케이션 레벨의 커넥션 재사용 설정을 통해 시스템 중단 없이 안전하게 포트 고갈을 해결하는 실전 방안을 공유합니다.


Cannot assign requested address 에러 분석

Cannot assign requested address 에러는 운영체제가 새로운 아웃바운드 TCP 커넥션을 생성하려고 할 때, 바인딩할 수 있는 로컬 포트가 남아있지 않을 때 커널이 반환하는 EADDRNOTAVAIL(에러 코드 99)입니다.

로그 분석과 소켓 상태 진단

애플리케이션 로그나 Nginx 에러 로그에서 흔히 다음과 같은 형태로 기록됩니다.

TEXT
2026/09/20 07:42:15 [error] 14201#14201: *894125 connect() failed (99: Cannot assign requested address) while connecting to upstream, client: 10.0.1.50, server: api.internal.local, request: "POST /v1/payments HTTP/1.1", upstream: "http://10.0.2.100:8080/v1/payments"

위 로그는 리버스 프록시가 업스트림 서버(10.0.2.100:8080)로 새로운 TCP 연결을 맺으려고 시도했으나 사용 가능한 로컬 소켓 자원이 없어 연결 자체가 거부되었음을 보여줍니다.

이 상태에서 터미널을 열고 소켓 통계를 확인하면 다음과 같이 대량의 TIME_WAIT가 관측됩니다.

BASH
# 전체 소켓 요약 정보 확인
ss -s

# TCP 상태별 소켓 개수 집계
ss -tan | awk '{print $1}' | sort | uniq -c | sort -nr

명령어 실행 결과 TIME_WAIT 상태의 소켓이 30,000개에서 60,000개 수준으로 치솟아 있다면 시스템의 가용 로컬 포트 대역이 완전히 소진된 상태입니다.


TCP 연결 종료와 TIME_WAIT 내부 메커니즘

TIME_WAIT는 비정상적인 버그나 누수가 아니라 TCP 표준 규격(RFC 793)에 정의된 필수적인 상태 머신(State Machine)의 한 단계입니다.

Active Close와 2MSL 타이머의 역할

TCP 4-Way Handshake 과정에서 먼저 연결 종료 패킷(FIN)을 전송하는 주체를 Active Close라고 부릅니다. Active Close를 수행한 종단은 상대방으로부터 마지막 FIN을 받고 이에 대한 최종 ACK를 보낸 후 즉시 소켓을 파기하지 않고 TIME_WAIT 상태로 대기합니다.

커널이 TIME_WAIT 상태를 유지하는 이유는 두 가지 핵심적인 네트워크 안전장치 때문입니다.

첫째, 네트워크 경로 상의 지연으로 인해 늦게 도착한 패킷(Delayed Segment)이 동일한 4-tuple(출발지 IP, 출발지 포트, 목적지 IP, 목적지 포트)을 사용하는 새로운 연결에 유입되어 데이터를 왜곡하는 현상을 방지합니다.

둘째, 최종 전송한 ACK 패킷이 네트워크에서 유실되었을 경우, 상대방(Passive Close)이 재전송하는 FIN 패킷을 정상적으로 수신하고 다시 ACK를 응답하여 상대방이 안전하게 LAST_ACK에서 CLOSED 상태로 전이할 수 있도록 보장합니다.

리눅스 커널은 이 대기 시간으로 2MSL(Maximum Segment Lifetime)을 적용하며, 커널 소스코드 상에서 60초(TCP_TIMEWAIT_LEN = 60*HZ)로 고정되어 있습니다.

Ephemeral Port 고갈이 일어나는 수학적 조건

TCP 커넥션은 출발지 IP, 출발지 포트, 목적지 IP, 목적지 포트라는 4가지 요소의 고유한 조합으로 식별됩니다.

클라이언트나 리버스 프록시가 단일 목적지 IP와 단일 포트(예: 10.0.2.100:8080)로 지속적인 단기 HTTP 요청(Short-lived Connection)을 전송할 경우, 변경 가능한 변수는 오직 로컬의 출발지 포트(Ephemeral Port)뿐입니다.

리눅스의 기본 로컬 포트 범위가 32,768에서 60,999라면 가용 포트는 총 28,232개에 불과합니다. 각 소켓이 60초 동안 TIME_WAIT로 묶여 있다면, 초당 요청 수가 약 470건(28,232 / 60)을 초과하는 순간 가용 포트가 완전히 바닥나며 Cannot assign requested address 에러가 발생합니다.


커널 파라미터 튜닝과 포트 대역 확장

시스템 레벨에서 포트 고갈을 방어하려면 로컬 포트 범위를 최대로 넓히고, 나가는 연결에 한해 안전하게 TIME_WAIT 소켓을 재사용하도록 커널 설정을 조정해야 합니다.

tcp_tw_recycle의 치명적 결함과 tcp_tw_reuse의 차이

과거 인터넷 문서에서는 net.ipv4.tcp_tw_recycle 옵션을 권장하는 경우가 많았으나, 이 옵션은 매우 위험하며 Linux 커널 4.12 버전부터 완전히 제거되었습니다.

tcp_tw_recycle은 소켓을 수 초 내에 강제로 회수하는 과정에서 TCP Timestamps를 검사하는데, NAT 게이트웨이나 로드밸런서 뒷단에 위치한 여러 클라이언트가 동일한 공인 IP를 공유할 경우 각 클라이언트의 시스템 클록 차이로 인해 정상적인 SYN 패킷을 공격으로 간주하고 일방적으로 드랍하는 치명적인 장애를 유발합니다.

반면 net.ipv4.tcp_tw_reuse나가는 아웃바운드 커넥션에 한해서만 동작하는 안전한 대안입니다. 클라이언트 또는 프록시 입장에서 새로운 연결을 맺을 때 기존 TIME_WAIT 상태에 있는 소켓 중 마지막 패킷 수신 시점으로부터 1초 이상 경과한 소켓의 포트를 재할당합니다. RFC 1323 기반의 타임스탬프를 엄격히 대조하므로 패킷 충돌 위험 없이 안전하게 동작합니다.

sysctl 네트워크 파라미터 설정

/etc/sysctl.d/99-network-tuning.conf 파일을 생성하여 임시 포트 대역 확장과 소켓 재사용을 설정합니다.

INI
# /etc/sysctl.d/99-network-tuning.conf

# TIME_WAIT 소켓 아웃바운드 재사용 활성화 (1: 활성화, 0: 비활성화)
net.ipv4.tcp_tw_reuse = 1

# 로컬 임시 포트(Ephemeral Port) 대역 확장 (10240 ~ 65535, 총 55,296개)
net.ipv4.ip_local_port_range = 10240 65535

# TCP 타임스탬프 활성화 (tcp_tw_reuse 동작의 필수 전제조건)
net.ipv4.tcp_timestamps = 1

# 시스템 전체 TIME_WAIT 소켓 최대 유지 개수 (메모리 고갈 방지)
net.ipv4.tcp_max_tw_buckets = 262144

# 네트워크 인터페이스 입력 큐 및 백로그 확장
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 16384

설정 파일을 저장한 후 커널에 즉시 반영합니다.

BASH
# 설정 즉시 로드 및 적용
sudo sysctl --system

# 반영된 파라미터 값 검증
sysctl net.ipv4.tcp_tw_reuse net.ipv4.ip_local_port_range

포트 대역을 10240~65535로 넓히면 단일 목적지에 대해 동시 사용할 수 있는 포트가 55,296개로 2배 가까이 늘어나며, tcp_tw_reuse가 1초가 지난 소켓을 재사용하므로 초당 수천 건의 신규 연결을 안정적으로 감당할 수 있습니다.


리버스 프록시 및 애플리케이션 커넥션 풀링

커널 튜닝이 운영체제의 한계 용량을 늘려주는 완충제라면, 애플리케이션 계층에서 연결을 맺고 끊는 빈도를 원천 차단하는 커넥션 풀링(Connection Pooling)은 본질적인 치료제입니다.

과거 다루었던 리눅스 디스크 100% 해결 사례에서도 확인할 수 있듯이, 시스템 자원의 고갈은 프로세스가 자원의 생명주기를 어떻게 관리하느냐에 따라 승패가 갈립니다. 매 요청마다 3-Way Handshake와 4-Way Handshake를 반복하는 것은 심각한 CPU 오버헤드와 포트 낭비를 초래합니다.

Nginx Upstream Keep-Alive 설정

Nginx를 리버스 프록시로 운영할 때 기본 설정은 백엔드 서버와의 통신에 HTTP/1.0을 사용하며 매 요청 후 연결을 닫습니다. upstream 블록에 keepalive 디렉티브를 명시하고 HTTP/1.1 프로토콜과 Connection "" 헤더를 지정해야 백엔드 커넥션이 재사용됩니다.

NGINX
# /etc/nginx/conf.d/upstream.conf

upstream backend_api {
    server 10.0.2.100:8080 max_fails=3 fail_timeout=10s;
    server 10.0.2.101:8080 max_fails=3 fail_timeout=10s;

    # 워커 프로세스당 백엔드와 유지할 유휴(Idle) Keep-Alive 연결 수
    keepalive 256;
    keepalive_requests 10000;
    keepalive_timeout 60s;
}

server {
    listen 80;
    server_name api.internal.local;

    location / {
        proxy_pass http://backend_api;

        # 백엔드 Keep-Alive 활성화를 위한 필수 헤더 설정
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        
        # 프록시 버퍼 및 타임아웃 최적화
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}

proxy_http_version 1.1;proxy_set_header Connection ""; 설정을 누락하면 keepalive 옵션을 선언했더라도 Nginx가 백엔드에 Connection: close 헤더를 전달하여 소켓 재사용이 동작하지 않습니다.

백엔드 런타임별 HTTP 클라이언트 커넥션 풀 적용

프록시뿐만 아니라 마이크로서비스 간 통신을 수행하는 내부 애플리케이션 런타임에서도 Keep-Alive를 명시적으로 활성화해야 합니다.

Node.js 환경에서는 기본적으로 http.AgentkeepAlive 옵션이 false로 설정되어 있으므로 이를 반드시 활성화해야 합니다.

JAVASCRIPT
// axios 인스턴스 또는 기본 http 모듈에 커넥션 풀 에이전트 바인딩
const http = require('http');
const https = require('https');
const axios = require('axios');

const httpAgent = new http.Agent({
  keepAlive: true,
  maxSockets: 100,
  maxFreeSockets: 20,
  timeout: 60000,
  freeSocketTimeout: 30000,
});

const apiClient = axios.create({
  baseURL: 'http://10.0.2.100:8080',
  httpAgent: httpAgent,
  timeout: 5000,
});

Go 언어의 경우 http.DefaultTransport에 이미 Keep-Alive가 켜져 있지만, 기본 MaxIdleConnsPerHost 값이 2로 매우 낮게 설정되어 있어 고트래픽 환경에서는 초과 연결이 즉시 닫히며 TIME_WAIT가 폭증합니다. 따라서 http.TransportMaxIdleConnsPerHost를 100 이상으로 상향해야 합니다.


트러블슈팅 적용 후 상태 검증 절차

설정을 마친 후에는 실제 커널 파라미터와 커넥션 풀이 의도한 대로 동작하는지 실시간 모니터링을 통해 단계별로 검증합니다.

소켓 재사용 및 커널 카운터 추적

터미널에서 netstat -s 명령어를 실행하여 커널이 실제로 TIME_WAIT 소켓을 재사용하고 있는지 통계를 확인합니다.

BASH
# 커널의 TIME_WAIT 재사용 카운트 확인
netstat -s | grep -i "time wait"

출력 결과에서 TCPTimeWaitOverflow가 발생하지 않고, 트래픽이 유입될 때 passive connections was reset due to early user close나 타임스탬프 기반 소켓 재사용 관련 수치가 정상적으로 유지되는지 점검합니다.

또한 ss 명령어로 업스트림 백엔드로 연결된 소켓들의 상태를 실시간으로 필터링합니다.

BASH
# 특정 업스트림 IP(10.0.2.100)로 맺어진 소켓 상태별 실시간 모니터링
watch -n 1 "ss -tan dst 10.0.2.100:8080 | awk '{print \$1}' | sort | uniq -c"

Keep-Alive가 올바르게 적용되었다면 대다수의 소켓이 ESTAB(Established) 상태로 안정적으로 유지되며, 신규 트래픽이 급증하더라도 TIME_WAIT 숫자가 수만 개로 폭증하지 않고 수백 개 이내에서 일정하게 유지됩니다.


엔지니어가 자주 겪는 사이드 이펙트 방지 FAQ

실무에서 네트워크 파라미터를 수정할 때 발생할 수 있는 부작용과 흔한 오해를 문답 형식으로 정리합니다.

tcp_tw_recycle을 켜면 왜 NAT 환경에서 대규모 장애가 발생하나요?

tcp_tw_recycle은 수신된 패킷의 TCP 타임스탬프가 직전 패킷보다 크지 않으면 패킷을 즉시 드랍합니다. 기업 사내망이나 이동통신망, AWS NAT Gateway 등 동일한 공인 IP를 공유하는 다수의 기기는 각자의 로컬 클록을 가지고 있습니다. 한 기기가 연결을 종료한 직후 클록이 미세하게 느린 다른 기기가 동일한 공인 IP로 SYN 패킷을 보내면, 서버 커널은 타임스탬프가 역전된 것으로 판단하여 연결 요청을 조용히 무시(Drop)합니다. 이로 인해 불특정 다수의 사용자가 영문도 모른 채 사이트 접속 불가 및 연결 타임아웃을 겪게 됩니다.

tcp_tw_reuse는 수신 서버(Inbound Listen) 입장에서도 효과가 있나요?

효과가 없습니다. tcp_tw_reuse는 로컬 포트를 선택하여 외부로 연결을 요청하는 connect() 시스템 콜 시점에만 개입합니다. 외부 클라이언트로부터 들어오는 인바운드 연결을 처리하는 웹 서버나 데이터베이스 서버 입장에서는 클라이언트가 사용하는 로컬 포트를 서버가 임의로 재사용할 수 없습니다. 따라서 들어오는 연결이 너무 많아 서버 쪽에 TIME_WAIT가 쌓이는 상황이라면 클라이언트 측의 커넥션 풀링을 유도하거나, 서버가 아닌 클라이언트가 Active Close를 수행하도록 아키텍처를 조정해야 합니다.

tcp_max_tw_buckets 값을 0으로 설정하여 TIME_WAIT를 완전히 끌 수 없나요?

net.ipv4.tcp_max_tw_buckets를 0이나 매우 낮은 숫자로 설정하는 것은 심각한 운영 사고를 부릅니다. 이 임계치를 초과하면 리눅스 커널은 TCP: time wait bucket table overflow 경고 메시지를 dmesg에 쏟아내며 초과된 소켓을 강제로 즉시 파기하고 상대방에게 비정상적인 RST 패킷을 보냅니다. 이 경우 진행 중이던 세션의 마지막 데이터가 유실되거나 정상적인 연결 종료가 불가능해져 애플리케이션 레벨에서 원인 불명의 Connection reset by peer 에러가 속출합니다. 버킷 크기는 시스템 메모리가 허용하는 한 넉넉하게 유지해야 합니다.

HTTP Keep-Alive를 설정했는데도 TIME_WAIT가 여전히 많이 발생하는 이유는 무엇인가요?

연결을 먼저 닫는 주체가 누구인지 확인해야 합니다. HTTP Keep-Alive를 켜더라도 keepalive_timeout 시간이 만료되거나 keepalive_requests 제한에 도달하면 누군가는 연결을 닫아야 합니다. 만약 백엔드 서버가 먼저 타임아웃을 감지하고 FIN을 전송하면 백엔드 서버에 TIME_WAIT가 남고, 프록시나 클라이언트가 먼저 닫으면 클라이언트에 TIME_WAIT가 남습니다. 따라서 클라이언트의 유휴 타임아웃을 백엔드 서버의 Keep-Alive 타임아웃보다 약간 짧게 설정하여 항상 클라이언트가 능동적으로 연결을 정리하도록 수명 주기를 일치시켜야 불필요한 포트 잠김을 방지할 수 있습니다.

Sponsored