지난 포스팅에서 우리는 SimpleClientHttpRequestFactory 사용 시 Connection Pool 부재로 인해 발생하는 포트 고갈 현상과 그로 인한 장애에 대해 알아보았습니다. 하지만 여기서 한 가지 더 깊은 의문이 생깁니다. “포트가 부족하다면 즉시 에러가 나야지, 왜 하필 ‘5초’라는 애매한 시간 동안 멈춰 있다가 성공하거나 실패하는 걸까?”
많은 개발자가 이 대기 시간을 단순히 “빈 포트가 날 때까지 줄 서서 기다린 시간”으로 오해하곤 합니다. 하지만 운영체제(OS)의 네트워크 스택은 그렇게 친절하지 않습니다. 오늘 다룰 내용은 단순한 자원 부족을 넘어, 네트워크 패킷이 소멸하고 다시 살아나는 TCP 프로토콜의 심연에 관한 이야기입니다. 서버의 CPU와 메모리가 텅텅 비어있음에도 불구하고 API가 느려지는 미스터리한 현상, 그 뒤에 숨겨진 TCP 재전송(Retransmission)과 패킷 유실(Packet Drop) 메커니즘을 낱낱이 파헤쳐 보겠습니다.
포트 부족 시 OS의 동작 – 대기인가, 즉시 실패인가?
먼저 가장 많이 하는 오해부터 바로잡아야 합니다. OS의 가용 포트가 0개가 되면 애플리케이션은 대기(Queueing)하지 않습니다. 즉시 실패(Fail Fast)합니다.
1. 포트 100% 고갈 시의 현상
만약 리눅스 서버에서 가용할 수 있는 임시 포트(Ephemeral Port) 28,000개가 모두 사용 중이라면, 자바 애플리케이션은 연결을 시도하는 즉시 java.net.BindException: Address already in use 또는 No buffer space available 에러를 뱉어냅니다. 이는 은행 창구에서 “번호표 마감되었습니다”라고 안내받고 바로 쫓겨나는 것과 같습니다. 기다릴 기회조차 주지 않습니다.
2. 5초 지연의 진짜 의미 (아수라장 효과)
그렇다면 우리가 겪은 5초 지연은 무엇일까요? 포트가 완전히 0개가 된 상태가 아니라, “거의 고갈되어 OS가 극한의 과부하에 걸린 아수라장 상태“였기 때문입니다. 이때 발생한 5초는 포트를 받기 위해 대기한 시간이 아니라, 연결 요청(Handshake)을 시도했으나 무시당해서 다시 시도하느라 걸린 시간입니다. 즉, 정상적인 처리가 아니라 비정상적인 재전송(Retransmission)이 발생하고 있었던 것입니다.
서버를 마비시키는 TCP 재전송(Retransmission) 메커니즘
이 5초라는 시간은 우연히 발생한 것이 아닙니다. TCP 프로토콜의 표준 규약에 따라 수학적으로 계산된 시간입니다.
1. 패킷 유실 (Packet Drop) 발생
TPS 300 상황에서 SimpleClient를 사용하면 초당 300번의 연결과 해제가 반복됩니다. 이때 OS 커널은 수만 개의 TIME_WAIT 소켓을 관리하느라 과부하에 걸리고, SYN Backlog(연결 대기열)가 꽉 차게 됩니다.
- 정상 상황: Client가
SYN을 보내면 Server는SYN+ACK로 응답합니다. - 장애 상황: 대기열이 꽉 찬 OS는 들어오는
SYN패킷을 처리하지 못하고 조용히 버립니다(Drop). “잠깐 기다려”라는 응답조차 주지 않고 무시해 버리는 것입니다.
2. 지수적 후퇴 (Exponential Backoff)와 RTO
요청을 보낸 내 서버(Client)는 상대방이 패킷을 버렸는지 알 길이 없습니다. 그래서 응답이 올 때까지 기다리다가, 일정 시간이 지나면 다시 보냅니다. 이때 대기 시간(RTO)은 재시도할 때마다 2배씩 늘어납니다.
- 0초 (최초 시도): 연결 요청(
SYN) 전송 → OS가 바빠서 Drop - +1초 (1차 재전송): “1초 동안 답이 없네? 다시 보내자.” → Drop
- +3초 (2차 재전송): “또 없네? 이번엔 2초 더 기다려보자.” (누적 1+2=3초)
- +7초 (3차 재전송): “이번엔 4초 더…” (누적 1+2+4=7초)
사용자가 겪은 약 5초의 지연은 2차 재전송(3초) 이후 연결에 성공했거나, 처리 시간까지 포함되어 발생한 필연적인 결과였습니다. 패킷 하나를 놓칠 때마다 지연 시간은 기하급수적으로 늘어납니다.
하드웨어 스펙과 논리적 자원의 한계 (CPU/RAM vs Port)
“서버 사양을 높이면 해결되지 않을까?”라는 질문을 많이 받습니다. 결론은 “해결되지 않는다“입니다. 이는 물리적 체력의 문제가 아니라 논리적 티켓의 문제이기 때문입니다.
1. 물리적 자원 (CPU/RAM)
- 서버의 메모리가 32GB라도,
TIME_WAIT소켓 3만 개가 차지하는 메모리는 고작 90MB 수준입니다. - CPU 역시 바쁘긴 하겠지만, 단순히 소켓 상태를 관리하는 것만으로 최신 서버를 다운(Shutdown)시킬 만큼의 부하를 주기는 어렵습니다.
- 즉, 서버 하드웨어는 멀쩡하게 살아있습니다.
2. 논리적 자원 (Port)
- 문제는 포트(Port)입니다. 리눅스 OS가 외부 통신용으로 내어줄 수 있는 포트는 약 28,000개로 고정되어 있습니다.
- 아무리 CPU가 빠른 슈퍼컴퓨터라도, 나가는 문(Port)이 28,000개로 한정되어 있고 그 문이 모두 잠겨있다면(TIME_WAIT) 통신은 불가능합니다.
- 이를 “식물인간 상태“라고 비유할 수 있습니다. 생명 유지 장치(CPU/RAM)는 돌아가지만, 외부와 소통(Network)은 끊긴 상태입니다.
따라서 하드웨어 스케일업(Scale-up)보다는, Connection Pool을 도입하여 한 번 맺은 연결(포트)을 끊지 않고 재사용하는 소프트웨어적 튜닝만이 유일한 해결책입니다. Connection Pool을 쓰면 재전송 도박을 할 필요 없이 열려있는 문으로 드나들기만 하면 되므로, 5초 지연 문제는 원천적으로 차단됩니다.
커널 값으로 확인해 본 것
위에서 설명한 숫자들이 실제 값과 맞는지 Linux 컨테이너에서 직접 확인했습니다.
임시 포트 범위
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768 60999
가용 포트 수: 28,232개
본문에서 말한 “약 28,000개”가 맞습니다. 정확히는 28,232개입니다. 이 값은 sysctl -w net.ipv4.ip_local_port_range="10240 65535" 로 넓힐 수 있고, 그러면 약 55,000개가 됩니다. 다만 근본 해결이 아니라 시간을 버는 것입니다.
5초의 정체 — SYN 재전송 시각
이 글의 핵심 주장이 맞는지 계산해 봤습니다.
$ cat /proc/sys/net/ipv4/tcp_syn_retries
6
재시도 6회의 누적 대기 시각: 1, 3, 7, 15, 31, 63 초
본문의 1초 → 3초 → 7초가 정확합니다. 초기 RTO 1초에서 시작해 매번 2배로 늘어나므로 누적 시각이 1, 3, 7, 15, 31, 63 초가 됩니다. 6회를 모두 소진하면 연결 하나에 63초를 쓰고 실패합니다.
겪은 지연이 5초 부근이었다는 것은 2차 재전송(3초) 이후 붙었거나 3차(7초)에 붙기 직전이었다는 뜻입니다. 애플리케이션 타임아웃을 3초로 잡아 두었다면 이 구간에서 전부 타임아웃이 납니다.
이것이 타임아웃 설정만으로 해결되지 않는 이유입니다. 타임아웃을 늘리면 지연을 감수하는 것이고, 줄이면 실패율이 올라갑니다. 커넥션을 재사용해 핸드셰이크 자체를 없애는 것이 유일한 근본 대책입니다.
TIME_WAIT 축적 재현
짧은 연결을 300개 만들고 세어 봤습니다.
# 리스너 하나 띄우고, 클라이언트가 먼저 close 하는 연결 300개 생성
$ ss -tan state time-wait | wc -l
298
$ ss -tan state time-wait | head -4
0 0 127.0.0.1:38852 127.0.0.1:9000
0 0 127.0.0.1:39600 127.0.0.1:9000
0 0 127.0.0.1:40584 127.0.0.1:9000
0 0 127.0.0.1:39526 127.0.0.1:9000
연결 300개에 TIME_WAIT 298개가 남았고, 소스 포트가 전부 다릅니다. 연결을 맺고 끊을 때마다 포트 하나가 60초간 잠긴다는 것이 눈으로 확인됩니다.
자주 잘못 알려진 것 — tcp_fin_timeout 은 TIME_WAIT 와 무관하다
포트 고갈을 검색하면 net.ipv4.tcp_fin_timeout을 줄이라는 조언이 자주 나옵니다. 이 값은 TIME_WAIT 지속 시간이 아닙니다.
$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60 # FIN_WAIT_2 상태의 타임아웃. TIME_WAIT 와 다른 상태다.
Linux 에서 TIME_WAIT 지속 시간은 커널 소스의 TCP_TIMEWAIT_LEN 상수 60초로 고정돼 있습니다. sysctl 로 바꿀 수 없습니다. tcp_fin_timeout을 아무리 줄여도 TIME_WAIT 는 그대로 60초입니다. 이 조언을 따라 값을 바꾸고 “효과가 없다”고 결론 내리는 경우가 많은데, 애초에 다른 값입니다.
그럼 커널 튜닝으로 할 수 있는 것
$ cat /proc/sys/net/ipv4/tcp_tw_reuse
2 # 최신 커널 기본값. 0=사용안함, 1=허용, 2=루프백에만 허용
| 설정 | 효과 | 주의 |
|---|---|---|
ip_local_port_range 확대 | 포트 수를 늘려 고갈 시점을 늦춘다 | 근본 해결 아님. 시간을 버는 조치 |
tcp_tw_reuse=1 | TIME_WAIT 소켓을 조건부로 재사용 | 최신 커널 기본값은 2(루프백만). 타임스탬프 옵션이 켜져 있어야 하고 NAT 환경에서 문제가 될 수 있다 |
tcp_max_tw_buckets 축소 | TIME_WAIT 상한을 강제 | 초과분을 즉시 폐기하므로 TCP 규약을 어긴다. 권장하지 않음 |
세 가지 모두 임시 조치입니다. 커넥션을 재사용하면 TIME_WAIT 가 애초에 생기지 않습니다. 커널 값을 만지는 것보다 애플리케이션을 고치는 편이 언제나 낫습니다.
진단 명령 모음
# 상태별 소켓 수 (한눈에 보기)
ss -s
# TIME_WAIT 총 개수
ss -tan state time-wait | wc -l
# 어느 대상으로 몰리는지
ss -tan state time-wait | awk '{print $4}' | sort | uniq -c | sort -rn | head
# 임시 포트를 얼마나 쓰고 있는지
ss -tan | awk '{print $3}' | grep -oE '[0-9]+$' \
| awk '$1>=32768 && $1<=60999' | wc -l
# SYN 재전송이 실제로 일어나는지 (재전송 카운터)
netstat -s | grep -i retrans
netstat -s | grep retrans 값이 계속 증가하고 있다면 이 글이 말한 상황이 실제로 벌어지고 있다는 뜻입니다.
정리
이번 심층 분석을 통해 API 5초 지연의 진짜 범인은 단순한 자원 부족이 아닌, TCP 패킷 유실로 인한 재전송 대기 시간임이 밝혀졌습니다.
OS 레벨에서의 포트 고갈은 대기열을 만들어주는 자비로운 동작이 아니라, 들어오는 요청을 가차 없이 버리고(Drop) 즉시 에러를 뱉는 냉혹한 환경입니다. 우리가 겪은 지연은 그 냉혹한 환경 속에서 살아남기 위해 TCP 프로토콜이 처절하게 재시도(Retransmission)를 반복했던 흔적입니다.
이제 우리는 왜 Connection Pool이 필수적인지 명확히 알게 되었습니다. 그것은 단순히 성능을 높이는 옵션이 아니라, 한정된 자원(Port)과 가혹한 네트워크 환경(Packet Drop) 속에서 안정적인 서비스를 유지하기 위한 유일한 생존 수단입니다. 이 글이 여러분의 시스템을 더욱 견고하게 만드는 통찰이 되기를 바랍니다.