HTTP Keep-Alive 설정 안 해서 생긴 성능 문제와 해결 과정

HTTP keep-alive는 단순히 연결을 오래 유지하는 옵션처럼 보이지만, 백엔드 서비스에서는 네트워크 연결 비용과 응답 안정성에 직접 영향을 주는 설정입니다. 특히 외부 API를 자주 호출하는 서버라면 keep-alive 설정 하나로 불필요한 TCP 연결 생성이 반복될 수 있습니다.

HTTP keep-alive 문제는 어떻게 드러났나

이번 글의 주제는 http, 네트워크, keep-alive입니다. 성격상 단순 개념 설명보다는 장애와 성능 문제를 함께 다루는 운영 경험형 주제에 가깝습니다. 다만 과장된 장애 회고가 아니라, 서버 간 HTTP 호출에서 keep-alive 설정을 놓쳤을 때 어떤 일이 생기는지 차분히 정리해보겠습니다.

문제는 외부 API를 호출하는 백엔드 서버에서 시작됐습니다. 기능 자체는 정상 동작했습니다. 요청도 성공했고, 응답 코드도 대부분 200이었습니다. 그런데 특정 시간대가 되면 API 응답 시간이 들쭉날쭉해지고, 간헐적으로 read timeout이나 connection reset 계열의 에러가 보였습니다.

처음에는 외부 API 서버 문제로 보기 쉽습니다. 실제로 운영 중에는 네트워크 구간, 외부 서비스 상태, 방화벽, DNS, TLS 설정 등 확인할 것이 많습니다. 하지만 로그를 따라가다 보면 이상한 패턴이 하나 보였습니다. 매 요청마다 새로운 연결을 만들고 있었고, 기존 연결을 재사용하지 못하고 있었습니다.

 

HTTP keep-alive가 하는 일

HTTP keep-alive는 한 번 만든 TCP 연결을 요청 하나에만 쓰고 바로 닫지 않고, 일정 시간 동안 재사용할 수 있게 하는 방식입니다. HTTP 요청을 보낼 때마다 TCP 연결을 새로 만들면 연결 수립, TLS 핸드셰이크, 커널 리소스 사용이 반복됩니다.

HTTP/1.1에서는 기본적으로 지속 연결을 사용하는 방향이지만, 실제 애플리케이션에서는 사용하는 HTTP 클라이언트, 프록시, 로드밸런서, 서버 설정에 따라 연결 재사용이 제대로 되지 않을 수 있습니다. 그래서 “HTTP/1.1이면 알아서 keep-alive가 되겠지”라고 보는 것은 위험합니다.

keep-alive가 없는 경우

keep-alive가 제대로 동작하지 않으면 요청마다 연결을 새로 만듭니다. 단순히 네트워크 요청이 조금 느려지는 수준으로 끝나지 않을 수 있습니다. 요청 수가 많아질수록 연결 생성과 종료가 반복되고, 서버와 클라이언트 양쪽 모두에서 불필요한 부담이 생깁니다.


요청 1 → TCP 연결 생성 → TLS 핸드셰이크 → HTTP 요청 → 응답 → 연결 종료
요청 2 → TCP 연결 생성 → TLS 핸드셰이크 → HTTP 요청 → 응답 → 연결 종료
요청 3 → TCP 연결 생성 → TLS 핸드셰이크 → HTTP 요청 → 응답 → 연결 종료

이 흐름에서는 HTTP 요청 자체보다 연결 준비 과정이 더 비싸질 수 있습니다. 특히 HTTPS 환경에서는 TLS 핸드셰이크까지 포함되기 때문에 짧은 요청을 자주 보내는 구조일수록 손해가 커집니다.

keep-alive가 있는 경우

keep-alive가 적용되면 이미 만들어진 연결을 재사용합니다. 같은 대상 서버로 반복 호출하는 상황에서는 이 차이가 꽤 크게 나타납니다. 외부 결제 API, 인증 API, 메시징 API, 검색 API처럼 서버 간 호출이 많은 구조라면 반드시 확인해야 할 부분입니다.


TCP 연결 생성 → TLS 핸드셰이크
요청 1 → 응답
요청 2 → 응답
요청 3 → 응답
일정 시간 동안 연결 재사용

이 방식에서는 연결 생성 비용이 줄어듭니다. 요청마다 새 연결을 만드는 대신, 이미 열려 있는 연결을 활용하기 때문입니다. 결과적으로 응답 시간이 안정되고, 네트워크 레벨의 불필요한 작업도 줄어듭니다.

 

keep-alive 미설정으로 보였던 증상

HTTP keep-alive 설정이 빠졌을 때 가장 먼저 보이는 증상은 응답 시간의 흔들림입니다. 평균 응답 시간만 보면 문제가 작아 보일 수 있지만, p95나 p99 응답 시간을 보면 특정 요청들이 유난히 오래 걸리는 경우가 있습니다.

로그에서는 다음과 같은 에러가 간헐적으로 보일 수 있습니다. 이 에러들이 모두 keep-alive 때문이라고 단정할 수는 없지만, 연결 재사용이 안 되는 환경에서는 함께 확인해야 합니다.


java.net.SocketTimeoutException: Read timed out
java.net.SocketException: Connection reset
java.net.ConnectException: Connection timed out
org.apache.http.NoHttpResponseException

여기서 주의할 점은 read timeout이 보인다고 해서 항상 외부 서버가 느린 것은 아니라는 점입니다. 클라이언트 측 연결 관리가 비효율적이거나, 커넥션 풀이 없거나, 풀 설정이 너무 작거나, idle connection 처리가 맞지 않아도 비슷한 증상이 나올 수 있습니다.

 

원인 추적은 연결 재사용 여부부터 확인한다

HTTP 성능 문제를 볼 때는 애플리케이션 로그만으로 판단하기 어렵습니다. 요청 시작 시간과 종료 시간은 보이지만, 그 안에서 DNS 조회, TCP 연결, TLS 핸드셰이크, 서버 처리, 응답 수신 중 어디가 느린지는 분리해서 봐야 합니다.

실무에서는 먼저 사용하는 HTTP 클라이언트가 무엇인지 확인합니다. Java라면 RestTemplate, WebClient, Apache HttpClient, OkHttp, JDK HttpClient 등을 볼 수 있습니다. TypeScript 서버라면 axios, undici, node-fetch, got 같은 클라이언트를 확인하게 됩니다.

Java에서 흔히 놓치는 부분

Spring Boot에서 RestTemplate을 사용할 때 기본 설정만으로 사용하는 경우가 있습니다. 이때 어떤 ClientHttpRequestFactory를 쓰는지에 따라 연결 관리 방식이 달라집니다. 단순한 기본 구현을 쓰면 커넥션 풀이나 keep-alive 관점에서 기대한 방식으로 동작하지 않을 수 있습니다.


RestTemplate restTemplate = new RestTemplate();

이 코드는 보기에는 간단하지만 운영 서버에서 외부 API를 자주 호출하는 용도로는 부족할 수 있습니다. timeout 설정, connection pool, keep-alive 전략, idle connection 정리 방식이 명확하지 않기 때문입니다. 팀 코드 리뷰에서도 이런 코드는 “일단 동작은 하지만 운영 설정이 빠져 있는 코드”로 보는 편이 좋습니다.

Node.js와 TypeScript에서도 같은 문제가 생긴다

TypeScript 기반 백엔드에서도 비슷합니다. axios를 사용한다고 해서 자동으로 원하는 수준의 keep-alive가 구성되는 것은 아닙니다. Node.js에서는 http.Agent 또는 https.Agent 설정을 통해 keepAlive 옵션을 명시하는 경우가 많습니다.


import axios from 'axios';
import http from 'http';
import https from 'https';

const client = axios.create({
  timeout: 5000,
  httpAgent: new http.Agent({
    keepAlive: true,
    maxSockets: 100,
    maxFreeSockets: 20,
    timeout: 60000
  }),
  httpsAgent: new https.Agent({
    keepAlive: true,
    maxSockets: 100,
    maxFreeSockets: 20,
    timeout: 60000
  })
});

위 설정은 모든 서비스에 그대로 복사해서 쓰라는 의미는 아닙니다. 중요한 것은 HTTP 클라이언트 인스턴스를 매 요청마다 새로 만들지 않고, keep-alive가 켜진 Agent를 재사용한다는 점입니다. maxSockets와 timeout 값은 호출 대상, 동시 요청 수, 외부 API 특성에 맞춰 조정해야 합니다.

 

대안 비교: 단순 요청 방식과 커넥션 풀 방식

HTTP 네트워크 호출에서 keep-alive 문제를 해결할 때는 단순히 옵션 하나를 켜는 것보다 요청 방식을 함께 봐야 합니다. 특히 서버 코드에서 HTTP 클라이언트를 매번 생성하는 구조라면 keep-alive를 켜도 기대만큼 효과가 나오지 않을 수 있습니다.

요청마다 클라이언트를 새로 만드는 방식

가장 피해야 할 방식은 요청할 때마다 HTTP 클라이언트 인스턴스를 새로 만드는 구조입니다. 코드가 짧고 이해하기 쉬워 보이지만, 연결 재사용 관점에서는 불리합니다. 테스트 코드나 일회성 스크립트에서는 괜찮을 수 있어도 운영 서버 코드에서는 신중해야 합니다.


async function callExternalApi() {
  const client = axios.create({
    timeout: 5000
  });

  return client.get('https://api.example.com/users');
}

이 구조에서는 함수가 호출될 때마다 클라이언트 설정도 함께 만들어집니다. Agent 재사용도 어렵고, 커넥션 풀이 의미 있게 유지되기 힘듭니다. 협업 관점에서도 “왜 여기서 새 클라이언트를 만드는지”가 명확하지 않으면 나중에 성능 문제를 추적할 때 시간이 더 걸립니다.

공용 클라이언트를 재사용하는 방식

외부 API 호출이 반복되는 서비스라면 공용 HTTP 클라이언트를 구성하고 재사용하는 쪽이 낫습니다. 이렇게 하면 keep-alive, timeout, retry, logging, header 처리 같은 공통 정책을 한 곳에서 관리할 수 있습니다.


export const externalApiClient = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
  httpsAgent: new https.Agent({
    keepAlive: true,
    maxSockets: 100,
    maxFreeSockets: 20
  })
});

이 방식은 코드의 의도도 분명합니다. 외부 API 호출 정책이 별도로 존재하고, 그 정책 안에 keep-alive가 포함됩니다. 서비스가 커질수록 이런 구분이 유지보수에 더 유리합니다.

 

Spring Boot에서 keep-alive를 적용하는 예시

Spring Boot에서 HTTP keep-alive를 제대로 다루려면 사용하는 클라이언트에 맞는 설정이 필요합니다. 여기서는 RestTemplate과 Apache HttpClient 조합을 예로 들어보겠습니다. 최근 프로젝트에서는 WebClient를 쓰는 경우도 많지만, 기존 시스템에서는 RestTemplate이 여전히 많이 남아 있습니다.


@Configuration
public class HttpClientConfig {

    @Bean
    public RestTemplate restTemplate() {
        PoolingHttpClientConnectionManager connectionManager =
                new PoolingHttpClientConnectionManager();

        connectionManager.setMaxTotal(200);
        connectionManager.setDefaultMaxPerRoute(50);

        CloseableHttpClient httpClient = HttpClients.custom()
                .setConnectionManager(connectionManager)
                .evictIdleConnections(30, TimeUnit.SECONDS)
                .build();

        HttpComponentsClientHttpRequestFactory requestFactory =
                new HttpComponentsClientHttpRequestFactory(httpClient);

        requestFactory.setConnectTimeout(3000);
        requestFactory.setReadTimeout(5000);

        return new RestTemplate(requestFactory);
    }
}

여기서 중요한 설정은 connectionManager입니다. 전체 연결 수와 대상 서버별 연결 수를 분리해서 관리할 수 있습니다. 외부 API 하나만 호출하는 서비스와 여러 외부 API를 동시에 호출하는 서비스는 적절한 값이 다를 수 있습니다.

또 하나 봐야 할 부분은 idle connection 정리입니다. keep-alive는 연결을 계속 재사용하자는 의미이지, 사용하지 않는 연결을 무한정 들고 있자는 뜻은 아닙니다. 일정 시간 이상 놀고 있는 연결은 정리하는 것이 안전합니다.

 

keep-alive 적용 후 반드시 확인할 것

HTTP keep-alive 설정을 추가했다고 바로 끝내면 안 됩니다. 연결 재사용이 실제로 되고 있는지 확인해야 합니다. 설정은 되어 있지만 프록시, 로드밸런서, 서버 응답 헤더, 클라이언트 생성 방식 때문에 기대와 다르게 동작할 수 있습니다.

Connection 헤더 확인

응답 헤더에서 Connection 값이 어떻게 내려오는지 확인합니다. 서버가 명시적으로 close를 내려주면 클라이언트가 연결을 재사용하기 어렵습니다. 반대로 keep-alive를 허용하더라도 중간 프록시가 연결을 끊는 경우도 있습니다.


curl -v https://api.example.com/health

< HTTP/1.1 200 OK
< Connection: keep-alive
< Keep-Alive: timeout=60

이 값만으로 모든 것을 판단할 수는 없지만, 기본 확인 지점으로는 충분합니다. 특히 외부 API 제공사 문서에서 keep-alive 지원 여부나 권장 timeout을 안내하는 경우가 있으니 함께 확인하는 것이 좋습니다.

커넥션 풀 지표 확인

가능하다면 HTTP 클라이언트의 connection pool 상태를 지표로 확인하는 것이 좋습니다. leased, available, pending 같은 값을 볼 수 있으면 현재 연결이 제대로 재사용되는지 판단하는 데 도움이 됩니다.


leased   : 현재 사용 중인 연결
available: 재사용 가능한 유휴 연결
pending  : 연결을 기다리는 요청
max      : 최대 연결 수

pending이 계속 쌓인다면 keep-alive 자체보다 pool 크기가 작거나 특정 외부 API 응답이 느린 문제가 있을 수 있습니다. 반대로 available이 거의 없고 매번 새 연결이 만들어진다면 연결 재사용 설정을 다시 확인해야 합니다.

 

자주 하는 실수

HTTP keep-alive 설정에서 자주 놓치는 부분은 생각보다 단순합니다. 옵션을 켜는 것보다 클라이언트 생명주기와 timeout 조합을 잘 맞추는 것이 더 중요합니다.

HTTP 클라이언트를 요청마다 생성한다

keep-alive를 켰는데도 효과가 없다면 가장 먼저 이 부분을 확인합니다. 클라이언트를 요청마다 만들면 연결을 오래 유지할 수 없습니다. 커넥션 풀도 클라이언트 인스턴스에 묶여 있으므로, 인스턴스가 계속 바뀌면 풀을 재사용하기 어렵습니다.

timeout을 설정하지 않는다

keep-alive와 timeout은 함께 봐야 합니다. 연결을 재사용하더라도 connect timeout, read timeout, connection request timeout이 없으면 장애 상황에서 요청이 오래 붙잡힐 수 있습니다. 연결을 재사용하는 것과 무한정 기다리는 것은 다른 문제입니다.

idle timeout을 서버보다 길게 잡는다

클라이언트는 연결이 살아 있다고 생각하지만, 서버나 로드밸런서는 이미 연결을 닫았을 수 있습니다. 이 경우 재사용하려는 순간 connection reset 같은 문제가 발생할 수 있습니다. 그래서 클라이언트의 idle connection 유지 시간은 상대 서버나 중간 장비의 timeout보다 보수적으로 잡는 편이 안전합니다.

max connection 값을 무작정 키운다

연결 수를 크게 잡는다고 항상 좋아지는 것은 아닙니다. 외부 API 서버가 허용하는 동시 연결 수가 있을 수 있고, 클라이언트 서버의 리소스도 한정되어 있습니다. 값은 호출량과 응답 시간, 외부 API 제한 조건을 보고 조정해야 합니다.

 

해결 후 코드 구조는 이렇게 정리하는 편이 좋다

keep-alive 문제를 해결한 뒤에는 설정을 단순히 한 클래스에 추가하고 끝내기보다, 외부 연동 클라이언트 구조를 정리하는 것이 좋습니다. 같은 외부 API를 여러 곳에서 호출한다면 공통 클라이언트, 공통 timeout, 공통 logging 정책을 한 곳에 모아야 합니다.


외부 API 호출 구조 예시

ExternalApiClient
 ├─ 공통 baseUrl
 ├─ timeout 설정
 ├─ keep-alive connection pool
 ├─ 공통 header 처리
 ├─ logging
 └─ 에러 변환 정책

이렇게 정리하면 장애나 성능 문제를 볼 때도 추적 지점이 명확해집니다. 어느 서비스 클래스에서 어떤 방식으로 HTTP 요청을 보내는지 흩어져 있으면, 같은 문제가 반복될 가능성이 높습니다.

 

HTTP keep-alive 설정 기준 정리

HTTP keep-alive 설정은 “켜면 무조건 좋다”로 접근하기보다는, 서버 간 호출 패턴에 맞게 정리해야 합니다. 같은 대상 서버로 반복 호출이 많고, 요청 시간이 짧고, 외부 API 호출이 서비스 흐름에 자주 포함된다면 keep-alive는 기본적으로 검토해야 합니다.

항목 확인 기준
클라이언트 재사용 HTTP 클라이언트를 매 요청마다 만들지 않는지 확인합니다.
keep-alive 활성화 Agent 또는 connection manager에서 연결 재사용이 켜져 있는지 확인합니다.
connection pool 전체 연결 수와 대상별 연결 수를 서비스 호출량에 맞게 설정합니다.
timeout connect timeout, read timeout, pool 대기 timeout을 구분해서 설정합니다.
idle connection 사용하지 않는 연결을 적절한 주기로 정리합니다.
관측 지표 응답 시간뿐 아니라 connection pool 상태와 에러 로그를 함께 봅니다.

마무리: keep-alive는 작은 설정이 아니라 네트워크 기본기다

HTTP keep-alive는 화려한 기술은 아닙니다. 하지만 서버 간 통신이 많은 백엔드 시스템에서는 기본기라고 봐야 합니다. 특히 Java, Spring Boot, TypeScript 기반 서버에서 외부 API를 자주 호출한다면 HTTP 클라이언트 설정을 반드시 확인해야 합니다.

성능 문제를 해결할 때 캐시, 비동기 처리, 메시지 큐 같은 큰 구조부터 떠올리기 쉽습니다. 하지만 실제로는 HTTP 클라이언트가 매번 새 연결을 만들고 있었던 단순한 설정 문제가 원인일 때도 있습니다. 이런 문제는 코드 한 줄보다 설정의 의도를 이해하는 것이 더 중요합니다.

정리하면, keep-alive는 연결을 아끼는 옵션이 아니라 연결을 제대로 관리하는 방식입니다. 클라이언트를 재사용하고, 커넥션 풀을 설정하고, timeout과 idle connection 정리를 함께 구성해야 안정적인 HTTP 네트워크 호출을 만들 수 있습니다.