안정적으로 운영되던 서비스에서 갑작스럽게 특정 시간대에 API 응답 속도가 5초 이상 지연되는 현상을 겪어보신 적 있으십니까? 특히 TPS(초당 트랜잭션 수)가 평소와 다름없는 수준임에도 불구하고, 간헐적으로 발생하는 타임아웃과 지연은 개발자와 운영자를 곤혹스럽게 만듭니다. 네트워크 장비나 인프라의 문제가 아니라면, 그 원인은 의외로 우리가 무심코 사용했던 코드 한 줄에 숨어 있을 수 있습니다.
일부 서버에서 발생한 API 지연 장애에 대한 분석을 진행하여, 조치한 내용을 바탕으로, 지연의 핵심 원인이었던 SimpleClientHttpRequestFactory의 구조적 한계와 이를 해결하기 위한 Connection Pool 도입 전략에 대해 심층적으로 알아보겠습니다.
왜 커넥션 풀이 없는 HTTP 요청이 OS의 자원을 고갈시키는지, 그리고 리눅스 커널 레벨에서 어떤 일이 벌어지는지 궁금하셨던 분들에게 명쾌한 해답이 될 것입니다.
장애 현상과 초기 분석 – 왜 네트워크 문제가 아닌가?
특정 시간대에 일부 애플리케이션 서버에서 평소보다 2000ms 이상 지연되는 현상이 발생했습니다. 평소 하루 20건 미만으로 지연이었던 것과 비교하면 명백한 이상 징후였습니다.
초기에는 네트워크나 타겟 서버의 장애를 의심했습니다. 하지만 분석 결과는 달랐습니다.
- 타겟 서버 정상: 타겟 서버 자체에는 장애나 특이사항이 없었습니다.
- TPS 평이: 전체적인 트래픽 양은 평소와 비슷한 수준이었습니다.
- 특정 API 국한: 인증 정보를 다루는 특정 API 호출 시점에만 지연이 발생했고, 다른 API들은 정상적으로 처리
- 네트워크 장비 무관: 지연이 발생한 서버들이 서로 다른 스위치 장비에 물려 있었으므로, 특정 스위치나 랙(Rack) 단위의 하드웨어 장애일 가능성은 희박
결국 외부 요인이 아닌 어플리케이션 내부의 병목으로 분석 범위를 좁혔고,
그 중심에는 HTTP 요청을 처리하는 SimpleClientHttpRequestFactory가 있었습니다.
원인 분석 – SimpleClientHttpRequestFactory와 OS 자원 고갈
이번 장애의 핵심 원인(Root Cause)은 Connection Pool이 없는 SimpleClientHttpRequestFactory를 사용했기 때문입니다. 이 방식이 왜 5초 이상의 치명적인 지연을 유발했는지 기술적으로 상세히 분석해 보겠습니다.
1. 매 요청마다 반복되는 핸드쉐이크 (Handshake Overhead)
SimpleClientHttpRequestFactory는 요청을 보낼 때마다 새로운 연결을 맺고 끊습니다. 이는 친구에게 질문할 때마다 전화를 걸고 끊는 것과 같습니다.
- DNS 조회: 도메인 주소를 찾습니다.
- TCP 3-way Handshake: 서버와 연결을 수립합니다. (SYN -> SYN-ACK -> ACK)
- SSL/TLS Handshake: 가장 무거운 작업입니다. 인증서를 교환하고 암호화 키를 생성하는 과정에서 많은 CPU 연산과 네트워크 왕복(Round Trip)이 발생합니다.TPS 300 상황에서 이 무거운 과정을 초당 300번씩 반복하니, 실제 데이터를 보내기도 전에 준비 운동하다가 지쳐버린 셈입니다.
2. 포트 고갈 (Port Exhaustion)과 TIME_WAIT의 늪
더 큰 문제는 연결을 끊은 직후(Close)에 발생합니다. 리눅스 OS는 연결을 끊은 포트를 즉시 재사용하지 않고, 혹시 늦게 도착할 패킷을 위해 60초 동안 TIME_WAIT 상태로 잠가둡니다(2MSL Rule).
- 가용 포트의 한계: 리눅스가 외부 통신용으로 내어줄 수 있는 임시 포트(Ephemeral Port)는 약 28,000개(32,768 ~ 60,999)로 한정되어 있습니다.
- 고갈 메커니즘: TPS 300일 때, 1분이면 18,000개(300 * 60초)의 포트가
TIME_WAIT상태에 빠집니다. 트래픽이 조금만 튀어도 가용 포트 28,000개가 순식간에 동납니다. - 결과: 사용할 포트가 없어진 OS는 애플리케이션 스레드를 대기(Block) 상태로 만듭니다. 이것이 바로 타임아웃 설정과 무관하게 발생한 5초 지연의 실체입니다.
조치 방법 – Connection Pool 도입과 Tomcat 튜닝
이 문제를 근본적으로 해결하기 위해 Apache HttpClient(HttpComponentsClientHttpRequestFactory)를 도입하여 Connection Pool을 적용했습니다.
1. Connection Pool 적용 전략
- Factory 교체:
SimpleClient...대신HttpComponentsClient...를 사용하여 연결을 끊지 않고 재사용하도록 변경했습니다. 이로써 핸드쉐이크 비용과TIME_WAIT문제를 동시에 해결했습니다. - 타임아웃 설정 (Fail Fast): 연결 요청(ConnectionRequest)에 200ms, 읽기(Read)에 1,000ms 제한을 두어, 지연 발생 시 스레드를 붙잡고 있지 않고 즉시 에러를 뱉어 시스템 전체가 마비되는 것을 방지했습니다.
- 풀 크기(Capacity Planning): TPS 300을 방어하기 위해
MaxConnTotal을 500으로,MaxConnPerRoute를 300으로 넉넉하게 설정하여 특정 타겟 서버로 가는 요청이 대기열에서 밀리지 않도록 했습니다.
2. Tomcat Max Threads 증설
- 기존 300개였던 톰캣 스레드를 500개로 증설했습니다. Blocking I/O 방식에서는 HTTP 클라이언트 연결 수만큼 스레드가 확보되어야 병목이 생기지 않기 때문입니다. 이는 리틀의 법칙(Little’s Law)에 따라 계산된 필요 용량에 안전 마진을 더한 값입니다.
직접 측정해 본 결과
위 분석을 코드로 확인해 봤습니다. 서버가 접속해 온 클라이언트 소스 포트를 기록하게 만들면 커넥션이 재사용되는지 셀 수 있습니다. spring-web 6.2.11 / httpclient5 5.4.2 / JDK 21 환경입니다.
static final Set<Integer> ports = ConcurrentHashMap.newKeySet();
server.createContext("/ping", ex -> {
ports.add(ex.getRemoteAddress().getPort()); // 클라이언트 소스 포트 기록
byte[] b = "pong".getBytes();
ex.sendResponseHeaders(200, b.length);
ex.getResponseBody().write(b);
ex.close();
});
순차 요청에서는 차이가 없었다
먼저 한 스레드로 50번 호출해 봤습니다. 예상과 다른 결과가 나왔습니다.
[Simple] 요청 50건 / 사용된 클라이언트 포트 1개
[Pooled] 요청 50건 / 사용된 클라이언트 포트 1개
둘 다 포트 1개였습니다. SimpleClientHttpRequestFactory는 내부적으로 HttpURLConnection을 쓰는데, 여기에는 keep-alive 캐시가 이미 들어 있습니다. 그래서 “요청마다 새 연결을 맺고 끊는다”는 설명은 순차 호출에서는 성립하지 않습니다.
문제는 동시성에서 드러난다
실제 장애 상황은 TPS 300, 즉 동시 요청이 많은 상태였습니다. 30개 스레드가 각각 20번씩, 총 600건을 동시에 던져 봤습니다.
[Simple] 스레드 30 x 20 = 600 요청 / 사용된 소스 포트 117개 / 819 ms
[Pooled] 스레드 30 x 20 = 600 요청 / 사용된 소스 포트 10개 / 2716 ms
| 방식 | 생성된 커넥션(소스 포트) | 소요 시간 |
|---|---|---|
| SimpleClientHttpRequestFactory | 117개 | 819 ms |
| Pooling (maxPerRoute=10) | 10개 | 2,716 ms |
HttpURLConnection의 keep-alive 캐시는 호스트당 기본 5개로 제한됩니다(http.maxConnections). 동시 요청이 그 이상이면 초과분은 캐시에 들어가지 못하고 닫힙니다. 닫힌 커넥션은 TIME_WAIT 로 갑니다. 600건을 처리하며 117개의 커넥션이 만들어진 이유가 이것입니다.
정확히 말하면 “매 요청마다 새 연결”이 아니라 “동시 요청이 keep-alive 캐시 크기를 넘는 만큼 새 연결”입니다. TPS 300 에 동시성이 높은 상황에서는 결과적으로 같은 일이 벌어집니다. 반대로 트래픽이 낮을 때 이 문제가 안 보이는 이유도 설명됩니다.
풀을 도입하면 느려질 수도 있다
위 표에서 눈에 걸리는 것이 있습니다. 풀을 쓴 쪽이 3배 이상 느렸습니다.
당연한 결과입니다. maxPerRoute=10은 동시에 10개까지만 보내겠다는 선언입니다. Simple 은 117개를 동시에 열어 밀어붙였으니 로컬 테스트에서는 빠릅니다.
그런데 운영에서는 이 제한이 목적입니다. 타겟 서버를 보호하고, OS 포트를 아끼고, 폭주할 때 스스로를 지킵니다. 다만 풀 크기가 그대로 처리량 상한이 된다는 점을 알고 정해야 합니다. 너무 작으면 커넥션 대기가 새 병목이 됩니다.
적용 설정
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(100); // 전체 상한
cm.setDefaultMaxPerRoute(20); // 호스트(라우트)당 상한 — 실질적인 동시성 한계
RequestConfig rc = RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofSeconds(1)) // 풀에서 못 받으면 1초 뒤 실패
.setResponseTimeout(Timeout.ofSeconds(3))
.build();
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(cm)
.setDefaultRequestConfig(rc)
.evictIdleConnections(TimeValue.ofSeconds(30)) // 유휴 커넥션 정리
.build();
RestTemplate restTemplate =
new RestTemplate(new HttpComponentsClientHttpRequestFactory(client));
setConnectionRequestTimeout을 반드시 주세요. 이걸 빼면 풀이 고갈됐을 때 스레드가 무한정 대기합니다. 장애 상황에서 빠르게 실패하는 것이 매달려 있는 것보다 낫습니다.
측정 방법
본인 환경에서 세어 보려면 서버 쪽 코드가 없어도 됩니다.
# 현재 이 프로세스가 만든 대상 서버로의 연결 수
ss -tan | grep '<타겟IP>:443' | wc -l
# 상태별로 몇 개인지
ss -tan | grep '<타겟IP>:443' | awk '{print $1}' | sort | uniq -c
# TIME_WAIT 만
ss -tan state time-wait | grep -c '<타겟IP>:443'
풀을 적용한 뒤 이 숫자가 풀 크기 근처에서 안정되는지 확인하면 설정이 실제로 먹었는지 알 수 있습니다.
정리
이번 장애 분석을 통해 우리는 “연결을 맺고 끊는 비용“이 대규모 트래픽 환경에서 얼마나 치명적인지 확인할 수 있었습니다. SimpleClientHttpRequestFactory는 구현이 간단하지만, 커넥션 풀 부재로 인해 OS의 포트 자원을 낭비하고 결국 시스템을 멈추게 만들었습니다.
해결책은 명확합니다. 외부 API 호출이 빈번한 서비스에서는 반드시 Connection Pool을 사용하여 TCP 연결을 재사용해야 합니다. 또한, OS 레벨의 TIME_WAIT 특성과 포트 한계를 이해하고, 이에 맞는 적절한 스레드 풀과 커넥션 풀 사이즈를 산정하는 것이 안정적인 서비스 운영의 핵심입니다. 이번 분석 자료가 여러분의 시스템을 더욱 견고하게 만드는 데 도움이 되기를 바랍니다.
다음 포스팅 : API 5초 지연의 진짜 원인: 포트 고갈과 TCP 패킷 유실 메커니즘 분석
“API 지연 원인 분석: SimpleClientHttpRequestFactory의 문제점과 Connection Pool 도입 효과”에 대한 1개의 생각