[TIL] HTTP 커넥션 풀 고갈 트러블슈팅 - EntityUtils.consume()

2026. 5. 23. 17:33·[TIL]

HTTP 커넥션 풀 고갈 트러블슈팅 — EntityUtils.consume() 한 줄의 비밀

1. 들어가기 전

이슈 현상

외부 API 장애 발생 시 Apache HttpClient 커넥션 풀이 빠르게 고갈되어, 정상 API 호출까지 연쇄적으로 실패하는 현상

문제 정의

외부 API 호출 코드에서 응답이 200일 때만 EntityUtils.toString() 으로 본문을 읽고, 비-200 응답에서는 본문을 읽지 않은 채 응답을 닫는 구조.

이때, 외부 API가 일시적으로 5xx/4xx 응답을 반복적으로 반환하기 시작하자 짧은 시간 안에 커넥션 풀이 고갈되었고, 결국 장애와 무관한 다른 외부 API 호출까지 ConnectionPoolTimeoutException 으로 실패하는 연쇄 장애가 발생.


2. 문제 재현 및 로그 (Reproduction & Logs)

수정 전 코드 (문제가 된 코드)

public static String sendGetRequest(String url, Map<String, String> headers) {
    CloseableHttpResponse response = null;
    try {
        HttpGet request = new HttpGet(url);
        headers.forEach(request::addHeader);
        response = httpClient.execute(request);
        int statusCode = response.getStatusLine().getStatusCode();
        if (statusCode == 200) {
            return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
        }
        // 비-200 응답인 경우: body를 읽지 않고 그대로 빠져나갑니다.
    } catch (SSLException e) {
        return "";
    } catch (Exception e) {
        log.error("sendGetRequest fail : {}", url, e);
    } finally {
        closeResponse(response);
    }
    return "";
}

에러 로그 (일반 예시)

org.apache.http.conn.ConnectionPoolTimeoutException: Timeout waiting for connection from pool
    at org.apache.http.impl.conn.PoolingHttpClientConnectionManager.leaseConnection(...)
    at org.apache.http.impl.conn.PoolingHttpClientConnectionManager$1.get(...)
    at org.apache.http.impl.execchain.MainClientExec.execute(...)
    at org.apache.http.impl.execchain.ProtocolExec.execute(...)
    at org.apache.http.impl.execchain.RetryExec.execute(...)
    at org.apache.http.impl.execchain.RedirectExec.execute(...)
    at org.apache.http.impl.client.InternalHttpClient.doExecute(...)
    at ApiClient.sendGetRequest(ApiClient.java)
    ...

기대 결과

외부 API가 어떤 상태 코드를 반환하든, 사용한 커넥션은 풀에 정상적으로 반환되어 다음 요청에서 재사용되어야 합니다. 외부 API의 장애가 다른 API 호출에 영향을 주어서는 안 됩니다.


3. 원인 분석 (Root Cause Analysis)

이번 문제의 본질은 단순히 "커넥션 풀이 작아서"가 아니라, 응답 본문을 읽지 않으면 Apache HttpClient가 커넥션을 풀에 되돌려놓지 못한다는 점에 있습니다. 왜 그런지 TCP 레벨부터 차근차근 따라가 보겠습니다.

3-1. 배경 — TCP 연결과 Keep-Alive

HTTP 통신은 TCP 위에서 동작합니다. 새로운 TCP 연결을 맺으려면 3-way handshake 과정을 거쳐야 합니다.

이 3단계는 네트워크 왕복 시간(RTT)만큼의 비용이 듭니다. 매 HTTP 요청마다 이 과정을 반복하면 큰 낭비입니다.

이를 막기 위해 HTTP/1.1은 기본적으로 Keep-Alive 동작을 사용합니다. 한 번 맺어둔 TCP 연결을 끊지 않고 여러 요청에 재사용하는 방식입니다.

 

1) Keep-Alive 없을 때

 

 

2) Keep-Alive 있을 때

3-2. 커넥션 풀 동작 방식

단일 TCP 연결은 한 번에 요청 하나만 처리할 수 있습니다. 고트래픽 서버에서는 여러 커넥션을 미리 만들어 두고 풀(Pool)로 관리합니다. Apache HttpClient에서는 PoolingHttpClientConnectionManager 가 이 역할을 합니다.

요청 처리 흐름은 다음과 같습니다.

  1. 풀에서 AVAILABLE 상태인 커넥션 하나를 가져와 LEASED 로 바꿉니다.
  2. 요청을 보내고 응답을 받습니다.
  3. 커넥션을 다시 AVAILABLE 로 바꿔 풀에 반환합니다.

문제는 3번 단계가 언제 일어나는지 입니다. 응답을 받았다고 자동으로 반환되는 것이 아니라, 특정 조건을 만족해야 풀에 돌아갑니다.

3-3. TCP 스트림의 구조와 "다음 응답 시작점" (핵심)

여기서부터가 핵심입니다. HTTP 응답이 TCP 스트림에서 실제로 어떻게 생겼는지 봐야 합니다.

TCP 스트림 (바이트의 연속)
─────────────────────────────────────────────────────→

[HTTP 응답 헤더]          [HTTP 응답 Body]
HTTP/1.1 500 Error\r\n   {"error": "internal"}\r\n
Content-Length: 24\r\n
\r\n
↑                        ↑                          ↑
헤더 끝                  body 시작                  body 끝

Keep-Alive 환경에서는 같은 TCP 연결로 여러 요청을 보내기 때문에 응답이 연속해서 흘러옵니다.

 

TCP 스트림
────────────────────────────────────────────────────────────────→

[응답1 헤더] [응답1 Body] [응답2 헤더] [응답2 Body] [응답3 ...]
             ↑           ↑
          body 끝    다음 응답 시작

여기서 중요한 점은 TCP는 그냥 바이트의 흐름 이라는 것입니다. "여기서부터 응답 2가 시작합니다" 라고 표시해 주는 구분자는 없습니다. 이전 응답의 body를 끝까지 다 읽어야만 다음 응답이 어디서 시작하는지 알 수 있습니다.

 

즉, 현재 응답의 body를 읽지 않으면 클라이언트는 스트림 포인터가 어디에 있는지 정확히 알 수 없게 됩니다. 그 상태로 같은 커넥션을 재사용하면 다음 요청의 응답이 이전 응답의 body와 뒤섞여 버립니다. 이는 데이터 정합성에 치명적입니다.

 

3-4. Apache HttpClient의 lazy body 읽기

httpClient.execute(request) 를 호출하면 내부적으로 다음과 같은 일이 일어납니다.

httpClient.execute(request)
    │
    ├─ 1. 커넥션 풀에서 커넥션을 획득합니다.
    │       풀이 비어있으면 connectionRequestTimeout 동안 대기합니다.
    │
    ├─ 2. TCP 스트림으로 HTTP 요청을 전송합니다.
    │
    ├─ 3. 응답 헤더만 읽습니다 (statusCode, Content-Length 등).
    │       이때 반환되는 response 객체의 entity 는 아직 읽지 않은 스트림을 가리킵니다.
    │
    └─ 4. (응답 body는 여전히 TCP 스트림에 남아 있는 상태로 리턴됩니다.)

 

즉 execute() 가 리턴된 시점의 스트림 상태는 다음과 같습니다.

TCP 스트림
────────────────────────────────────────────────→

[응답 헤더 ✅읽음] [응답 Body ❌아직 안 읽음]
                   ↑
           현재 스트림 포인터 위치

 

### execute() 리턴 직후, TCP 스트림 상태.

- TCP 포인터는 body(어직 미읽음) 앞에 위치된다.

이를 lazy body 읽기 라고 부릅니다. 응답 본문은 호출자가 명시적으로 읽기 전까지는 TCP 스트림에 그대로 머물러 있습니다.

 

3-5. 200과 비-200의 동작 차이 (핵심)

이제 문제가 된 코드를 다시 봅니다.

response = httpClient.execute(request);
// → 이 시점: 헤더만 읽힘, body는 TCP 스트림에 대기 중

int statusCode = response.getStatusLine().getStatusCode();

if (statusCode == 200) {
    return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
    // EntityUtils.toString() 내부에서 스트림을 끝까지 읽습니다.
    // 결과: TCP 스트림 포인터가 body 끝으로 이동합니다.
    // → 커넥션이 "재사용 가능한 상태" 가 됩니다.
}

// 비-200이면 여기로 옵니다.
// body는 TCP 스트림에 그대로 남아 있고, 포인터는 body 시작 지점에 멈춰 있습니다.

} finally {
    closeResponse(response);  // 내부적으로 response.close() 가 호출됩니다.
}

 

그렇다면 response.close() 는 어떻게 동작할까요?

Apache HttpClient의 동작 원리를 의사 코드로 단순화하면 다음과 같습니다 (실제 구현은 여러 클래스에 걸쳐 있으나 핵심 분기는 동일합니다).

// 단순화한 의사 코드입니다.
public void close() {
    HttpEntity entity = this.response.getEntity();

    if (EntityUtils.isFullyConsumed(entity)) {
        // body를 끝까지 읽었습니다 → TCP 스트림 위치가 명확합니다.
        // → 커넥션을 AVAILABLE 로 바꿔 풀에 반환합니다. ✅
        conn.setState(AVAILABLE);
        connManager.releaseConnection(conn);
    } else {
        // body를 아직 읽지 않았습니다 → TCP 스트림 위치가 불명확합니다.
        // 재사용하면 다음 요청 응답과 데이터가 뒤섞일 위험이 있습니다.
        // → 커넥션을 그냥 종료합니다 (TCP close). ❌
        conn.close();
        // 풀에서 이 커넥션 슬롯 하나가 영구히 사라집니다.
    }
}

즉, 비-200 응답이 올 때마다 커넥션이 1개씩 풀에서 사라지는 구조였던 것입니다.

 

3-6. 풀 고갈 시뮬레이션

이를 실제 장애 상황에 대입해 보겠습니다. 도메인당 풀 한도가 100인 상황에서 외부 API가 500을 연속 반환하는 경우입니다.

외부 API 장애 상황 (500 에러 연속 반환)

시간 0s:  풀 상태 [conn1~100 AVAILABLE]   100개
          ↓
요청  1:  conn1 LEASED → 500 응답 → body 미소비 → conn1 폐기  풀: 99개
요청  2:  conn2 LEASED → 500 응답 → body 미소비 → conn2 폐기  풀: 98개
요청  3:  conn3 LEASED → 500 응답 → body 미소비 → conn3 폐기  풀: 97개
...
요청 100: conn100 LEASED → 500 응답 → 폐기                    풀: 0개
          ↓
요청 101: 풀에 AVAILABLE 커넥션이 하나도 없습니다.
          → MaxTotal 미만이면 새 커넥션을 생성합니다 (3-way handshake 비용 발생).
          → MaxTotal 에 도달하면
            → connectionRequestTimeout 동안 대기합니다.
            → 타임아웃이 지나면 ConnectionPoolTimeoutException 이 발생합니다.
            → 로그에 에러가 찍히고, 호출자는 빈 문자열을 받게 됩니다.

여기서 더 심각한 점은, 같은 풀을 공유하는 다른 도메인의 API 호출까지 영향을 받는다는 것입니다. MaxTotal=300 전체가 잠식되면 정상 동작 중인 외부 API 호출도 모두 풀 대기에 들어가 타임아웃됩니다. 단일 외부 시스템의 장애가 전체 외부 호출 장애로 번지는 셈입니다.


4. 해결 과정 (Troubleshooting Steps)

4-1. 시도해 볼 수 있는 접근들과 한계

이런 상황에서 흔히 떠올리는 접근법들과 그 한계를 정리하면 다음과 같습니다.

시도 결과 한계
커넥션 풀 크기 증가 (MaxTotal 늘리기) 일시적으로 버티는 시간만 늘어남 장애가 지속되면 결국 고갈됩니다. 근본 원인을 해결하지 못합니다.
connectionRequestTimeout 증가 에러 발생 시점만 늦춤 응답 지연만 길어지고, 결국 동일한 예외가 발생합니다.
재시도 로직 추가 풀 소진 속도만 가속 재시도가 다시 비-200 응답을 받으면 커넥션이 더 빠르게 폐기됩니다. 오히려 역효과입니다.
외부 API 장애 시 회로 차단(Circuit Breaker) 보조 수단으로는 유효 근본 원인이 "응답 본문 미소비" 인 한 차단기 복구 시점에 다시 같은 문제가 재발할 수 있습니다.

이 모든 시도는 현상에 대한 대응 일 뿐, 본질적인 원인인 "본문을 읽지 않으면 커넥션이 반환되지 않는다" 라는 점을 해결하지 못합니다.

 

4-2. 최종 해결책 — EntityUtils.consume() 한 줄

근본 해결책은 의외로 간단합니다. 응답의 status code 와 무관하게, finally 블록에서 응답 본문을 항상 끝까지 읽어 주는 것 입니다. 이를 위해 Apache HttpClient는 EntityUtils.consume() 이라는 유틸을 제공합니다.

수정 전/후 비교

// [수정 전]
} finally {
    closeResponse(response);
}

// [수정 후]
} finally {
    if (response != null) {
        try {
            EntityUtils.consume(response.getEntity());  // 비-200 응답에서도 body 를 끝까지 소비합니다.
        } catch (Exception ignored) { }
    }
    closeResponse(response);
}

전체 코드 (수정 후)

public static String sendGetRequest(String url, Map<String, String> headers) {
    CloseableHttpResponse response = null;
    try {
        HttpGet request = new HttpGet(url);
        headers.forEach(request::addHeader);
        response = httpClient.execute(request);
        int statusCode = response.getStatusLine().getStatusCode();
        if (statusCode == 200) {
            return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
        }
    } catch (SSLException e) {
        return "";
    } catch (Exception e) {
        log.error("sendGetRequest fail : {}", url, e);
    } finally {
        if (response != null) {
            try {
                EntityUtils.consume(response.getEntity());  // 핵심: body 소비를 강제합니다.
            } catch (Exception ignored) { }
        }
        closeResponse(response);
    }
    return "";
}

EntityUtils.consume() 이 하는 일

EntityUtils.consume() 의 동작을 의사 코드로 표현하면 다음과 같습니다 (실제 구현은 1바이트씩 읽는 것이 아니라 내부 버퍼를 사용해 chunk 단위로 읽지만, 개념은 동일합니다).

// 단순화한 의사 코드입니다.
public static void consume(HttpEntity entity) throws IOException {
    if (entity == null) return;
    if (entity.isStreaming()) {
        InputStream instream = entity.getContent();
        // body 를 끝까지 읽어서 버립니다.
        // 읽은 내용은 사용하지 않고, 오직 스트림 포인터를 끝으로 이동시키는 것이 목적입니다.
        while (instream.read() != -1) { }
        instream.close();
    }
}

이 호출 이후 TCP 스트림의 상태는 다음과 같이 변합니다.

TCP 스트림
────────────────────────────────────────────────→

[응답 헤더 ✅읽음] [응답 Body ✅읽음(버림)]
                                              ↑
                                    스트림 포인터 (body 끝)
                                    → 커넥션 재사용 가능 상태

이제 response.close() 시점에 EntityUtils.isFullyConsumed() 검사가 true 가 되어, 커넥션은 폐기되지 않고 AVAILABLE 상태로 풀에 반환됩니다.

 

4-3. 수정 후 기대되는 효과

같은 장애 상황을 다시 돌려 보면 결과는 완전히 달라집니다.

외부 API 장애 상황 (500 에러 연속 반환) — 수정 후

시간 0s:  풀 상태 [conn1~100 AVAILABLE]   100개
          ↓
요청  1:  conn1 LEASED → 500 응답 → body 소비(버림) → conn1 반환  풀: 100개
요청  2:  conn1 LEASED → 500 응답 → body 소비(버림) → conn1 반환  풀: 100개
요청  3:  conn1 LEASED → 500 응답 → body 소비(버림) → conn1 반환  풀: 100개
...
→ 풀 크기가 그대로 유지되며 ConnectionPoolTimeoutException 도 발생하지 않습니다.

일반적으로 이 수정을 적용한 뒤 관찰되는 지표는 다음과 같습니다.

  • 풀 사용량 그래프 안정화: 외부 API 장애가 지속되어도 LEASED/AVAILABLE 카운트가 정상 범위를 벗어나지 않습니다.
  • 커넥션 생성/폐기 카운트 감소: 신규 TCP handshake 비용이 줄어 응답 지연이 개선됩니다.
  • 연쇄 장애 사라짐: 특정 외부 API 의 장애가 동일 인스턴스에서 호출하는 다른 외부 API 에 영향을 주지 않습니다.

5. 학습 및 회고 (Lesson Learned & Next Steps)

원인 한 줄 요약

HTTP 응답 본문을 끝까지 읽지 않으면 Apache HttpClient 는 TCP 스트림 포인터가 어디 있는지 알 수 없어 해당 커넥션을 폐기합니다. 이로 인해 비-200 응답이 반복되면 풀이 빠르게 고갈됩니다.

 

조금 더 직관적으로 정리하면 이렇습니다.

TCP 스트림은 연속된 바이트 흐름이라, Body 를 끝까지 읽어야 "다음 통신 시작점" 을 알 수 있습니다. Apache HttpClient 는 이 위치를 모르면 커넥션을 안전하게 재사용할 수 없으므로 그냥 버려 버립니다.

 

개선 사항

  1. EntityUtils.consume() 호출을 모든 HTTP 클라이언트 유틸의 finally 블록에 강제합니다.
    • 응답을 사용하든 사용하지 않든, status code 가 무엇이든 일관되게 본문을 소비하도록 합니다.
    • 사내 공통 HTTP 유틸이 있다면 이 패턴이 누락된 곳이 있는지 전수 점검합니다.
  2. 커넥션 풀 메트릭을 모니터링 시스템에 추가합니다.
    • PoolingHttpClientConnectionManager.getTotalStats() 로 노출되는 LEASED/AVAILABLE/PENDING 카운트를 지표로 수집합니다.
    • 도메인별 (Route 별) 풀 사용량도 함께 추적하면 어느 외부 API 가 풀을 잠식하는지 즉시 파악할 수 있습니다.
  3. 자원 회수 패턴을 코드 리뷰 체크리스트에 추가합니다.
    • try-with-resources 사용 가능한 곳은 적극 활용합니다 (CloseableHttpResponse 는 AutoCloseable 입니다).
    • 다만 try-with-resources 만으로는 본문 소비가 강제되지 않으므로, 본문 처리 책임을 별도로 명시합니다.
  4. Apache HttpClient 5.x 마이그레이션 검토.
    • 5.x 에서는 응답 처리 API 가 람다 기반(HttpClientResponseHandler)으로 개선되어 본문 소비를 잊기 어려운 구조로 바뀌었습니다. 장기적인 안정성을 위해 마이그레이션을 검토해 볼 만합니다.
  5. 외부 API 호출에 Circuit Breaker 도입.
    • 본 이슈의 근본 원인은 해결되었지만, 외부 API 장애 자체로부터 시스템을 보호하기 위한 보조 장치로 Circuit Breaker (Resilience4j 등) 를 함께 도입하면 안정성이 한층 더 올라갑니다.

마치며

이번 사례는 "코드 한 줄 누락" 이 어떻게 시스템 전체에 연쇄적인 영향을 미칠 수 있는지를 잘 보여줍니다. 라이브러리가 추상화해 둔 "커넥션 풀" 이라는 개념도, 그 아래의 TCP 스트림 구조와 HTTP 라이브러리의 lazy 읽기 동작을 이해해야 비로소 진짜 원인이 보입니다.

 

라이브러리를 단지 사용만 하는 것이 아니라, 그 라이브러리가 자원을 어떻게 관리하는지 를 한 번씩 들여다보는 습관이 비슷한 장애를 예방하는 가장 확실한 방법이라고 생각합니다.

'[TIL]' 카테고리의 다른 글

[TIL] EHcache 캐시 오염 트러블슈팅 - 캐시 객체  (0) 2026.06.27
[TIL] Class 핫리로드와 톰캣의 오해 - 톰캣이 올라오고 class 파일을 교체해도 적용될까?  (0) 2026.06.16
트러블슈팅 - OpenFeign과 @Configuration: 빈 등록의 함정과 FeignContext의 이해  (0) 2025.10.17
TIL - CDC로 향하는 가는 첫 번째 과정(1) : OracleDB 트랜잭션 로그(Redo log)를 읽어서 Kafka에 적재하기  (0) 2025.01.10
[ TIL ] RECOVER_YOUR_DATA : RDS 해킹 일지  (1) 2024.11.13
'[TIL]' 카테고리의 다른 글
  • [TIL] EHcache 캐시 오염 트러블슈팅 - 캐시 객체
  • [TIL] Class 핫리로드와 톰캣의 오해 - 톰캣이 올라오고 class 파일을 교체해도 적용될까?
  • 트러블슈팅 - OpenFeign과 @Configuration: 빈 등록의 함정과 FeignContext의 이해
  • TIL - CDC로 향하는 가는 첫 번째 과정(1) : OracleDB 트랜잭션 로그(Redo log)를 읽어서 Kafka에 적재하기
7.06com
7.06com
우당탕탕 코딩하기
  • 7.06com
    우당탕탕 개발자의 이야기
    7.06com
  • 전체
    오늘
    어제
    • 분류 전체보기 (67)
      • [Spring] (7)
      • [JAVA] (3)
      • [디자인패턴] (1)
      • [TIL] (11)
      • [CI,CD] (5)
      • [협업] (1)
      • [Database] (5)
      • [CS] (3)
      • [코딩테스트] (15)
      • [알고리즘] (0)
      • [후기-회고] (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
7.06com
[TIL] HTTP 커넥션 풀 고갈 트러블슈팅 - EntityUtils.consume()
상단으로

티스토리툴바