Tomcat Async Request 이후 keep-alive timeout이 60초로 닫히던 이유

#Spring#Tomcat
운영 환경에서 다른 서버가 특정 애플리케이션을 호출할 때 간헐적으로 502 Bad Gateway 가 발생했다.
처음에는 upstream 오류나 네트워크 이슈를 의심했지만, 흐름을 역추적해보니 응답 직후 idle 상태로 들어간 HTTP 연결이 정확히 60초 후 서버 FIN 으로 종료되고 있었다.
keepAliveTimeout 을 100초로 설정했는데 실제 종료 시점은 60초였고, 결국 HTTP/1.1 + Servlet async request 이후 keep-alive 재진입 이라는 특정 조건에서 발생하는 Tomcat Bug 69748 과 맞닿아 있었다.
이 글은 설정값 소개보다, 실제로 어떤 단서로 원인을 좁혀갔는지를 보여주는 트러블슈팅 기록에 가깝다.

겪은 현상

출발점은 keep-alive 가 아니라, 다른 서버가 특정 애플리케이션을 호출할 때 간헐적으로 보이던 502 였다. 평소에는 정상 응답이 왔지만, 호출 흐름을 역추적하면서 아래 패턴을 확인했다.

  • 응답 자체는 정상적으로 내려간다.
  • 그 뒤 연결이 idle 상태로 유지된다.
  • 정확히 60초 후 서버가 FIN 을 전송하면서 연결이 닫힌다.

운영에서 확인한 설정값은 이랬다.

server.tomcat.keep-alive-timeout=100s
# connectionTimeout 은 기본값인 60초로 동작하고 있었다.
keepAliveTimeout 100초 설정과 실제 60초 종료 현상을 비교한 다이어그램

겉으로 보기에는 keepAliveTimeout 이 아니라 connectionTimeout 기준으로 연결이 끊기는 것처럼 보였다.


원인 추적

처음에는 여러 방면으로 설정이나 인프라 구성을 확인했지만 원인을 특정하기 어려웠고, 결국 패킷을 뜨게 되었다. 패킷을 뜨고 나니 원인을 좁히는 건 훨씬 수월해졌다.

1. 요청 경로 확인

문제를 보던 당시 요청이 어디를 거치는지부터 그려봤다.

Client 에서 ALB, EC2, Spring Boot Embedded Tomcat 으로 이어지는 시스템 구성도

이 구조에서 먼저 의심한 지점은 아래 셋이었다.

  • ALB 가 먼저 연결을 닫는가
  • 중간 proxy 가 있는가
  • 클라이언트가 먼저 연결을 닫는가

2. ALB 확인

ALB idle timeout 은 90초였는데 연결 종료는 60초에 발생했으니, 숫자만 봐도 ALB 가 원인일 가능성은 낮았다. 다만 이것만으로 확신하기는 어려웠고, 이후 패킷을 뜨고 나서 확실하게 제외할 수 있었다.

이런 종류의 문제는 "LB 뒤에 있으니까 LB 문제겠지" 라고 생각하면 오래 헤매기 쉬운데, 이번에는 종료 시점이 너무 정확해서 오히려 애플리케이션 서버 설정값을 먼저 의심하는 편이 맞았다.


3. 패킷 확인

여러 설정을 확인해도 원인이 잡히지 않아서 결국 패킷을 뜨게 되었는데, tcpdump 와 Wireshark 로 확인해봤더니 연결을 먼저 닫는 쪽은 클라이언트가 아니라 서버였다.

관측된 사실은 아래와 같았다.

  • 응답은 정상 전송된다.
  • idle 상태로 들어간다.
  • 60초 후 Server → Client FIN 이 발생한다.

패킷을 뜨고 나니 ALB 가 먼저 끊는 상황도 아니고 클라이언트가 먼저 종료한 것도 아니라는 걸 확인할 수 있었고, 결국 Tomcat 이 idle timeout 으로 소켓을 닫고 있다는 결론에 도달했다. 처음에 보이던 502 는 애플리케이션 로직 오류가 아니라, 서버 쪽 keep-alive 연결이 예상보다 빨리 닫히면서 발생한 문제였다.


4. telnet 으로 재현

재현을 해보려고 로컬에서 telnet 으로 직접 HTTP 요청을 보내봤다.

telnet localhost 8080
GET /api/async-demo HTTP/1.1
Host: localhost

응답은 정상적으로 왔다.

HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 12 Mar 2026 00:31:09 GMT

1a
{"status":"ok","items":[]}
0

그대로 두면 약 60초 뒤 아래처럼 연결이 닫혔다.

Connection closed by foreign host.

여기까지 오면 간헐적 운영 이슈가 아니라 로컬에서 반복 확인 가능한 동작이 되므로, 원인 추적이 훨씬 수월해진다.


Q. connectionTimeout 처럼 보인 이유?

Tomcat NIO connector 는 idle socket timeout 판단에 내부적으로 socketWrapper.getReadTimeout() 값을 사용하는데, 이 값이 연결 lifecycle 중 언제 바뀌는지를 봐야 문제를 이해할 수 있다. 정상적인 흐름이라면 첫 연결에서 connectionTimeout 이 들어가더라도, 요청을 하나 처리하고 keep-alive 상태로 돌아갈 때 keepAliveTimeout 으로 갱신되어야 한다.


Q. async request 에서 차이가 생긴 이유?

여기까지 보고 나니 "왜 keepAliveTimeout 100초가 아니라 60초가 남아 있었을까?" 라는 질문 하나로 좁혀졌다.

요청은 HTTP/1.1 이었고 내부적으로 Servlet async lifecycle 을 타고 있었기 때문에, 같은 Tomcat 이더라도 일반적인 동기 요청 경로와는 다르게 봐야 하는 상황이었다.

Spring 에서 흔히 보이는 형태로 바꾸면 대략 이런 경우다.

  • DeferredResult
  • Callable
  • WebAsyncTask
  • SseEmitter
  • request.startAsync()

대표적으로 아래 같은 형태가 있다.

@GetMapping("/api/async-demo")
public Callable<Response> api() {
    return () -> service.call();
}

DeferredResult 를 쓰는 경우도 마찬가지다.

DeferredResult<Response>

중요한 건 "서버가 외부 API 를 비동기로 호출했다" 가 아니라, Tomcat 이 inbound request 를 Servlet async 로 처리했다 는 점이다.

이 문제는 모든 Tomcat keep-alive 요청에서 발생하는 것이 아니라,
HTTP/1.1 요청이 Servlet async lifecycle 을 탄 뒤 keep-alive 상태로 복귀하는 특정 경로 에서 의심해야 하는 문제다.

Tomcat changelog 에서 찾은 단서

Tomcat changelog 를 보다가 이번 현상과 거의 정확히 맞아떨어지는 문구를 발견했다.

69748: Add missing call to set keep-alive timeout when using HTTP/1.1 following an async request

짧지만 겪은 현상을 거의 그대로 설명하는 문장이었고, 풀어쓰면 이렇다.

  • HTTP/1.1 요청이다.
  • async request 이후다.
  • 그 다음 keep-alive 대기로 들어간다.
  • 그런데 그 시점에 keep-alive timeout 설정 호출이 누락될 수 있었다.

이 내용을 겪은 현상에 대응시키면 async request 이후 keep-alive 로 복귀하는 시점에 기대하던 keepAliveTimeout 100초가 아니라 connectionTimeout 60초가 계속 남아 있는 셈이었고, 운영에서 보이던 "항상 정확히 60초 후 종료" 라는 현상도 이 흐름으로 자연스럽게 설명이 되었다.


코드 흐름 추적

실제로 디버깅할 때 의미 있었던 건 소켓 timeout 값이 어떤 시점에 어떻게 바뀌는지를 코드로 따라가는 과정이었다.

socketWrapper.readTimeout 이 언제 100초로 바뀌어야 하는가?

이 질문 하나를 붙잡고 따라가니 추적 경로가 의외로 단순해졌다.

1. 연결 직후 기본 timeout 확인

첫 진입점은 NioEndpoint.setSocketOptions() 이다.

socketWrapper.setReadTimeout(getConnectionTimeout());

이 시점의 read timeout 은 connectionTimeout 60초로, 초기값 자체는 정상이다. 60초가 이상한 값은 아니고, 문제는 이후 keep-alive 단계에서 이 값이 100초로 갱신되지 않는다는 점에 있었다.

2. 첫 요청 파싱 구간

다음으로 본 지점은 Http11InputBuffer.parseRequestLine() 인데, 첫 요청에서는 keptAlive = false 이므로 connection timeout 기준으로 동작한다.

if (!keptAlive) {
    wrapper.setReadTimeout(connectionTimeout);
}

여기까지도 이상은 없고, 첫 요청이므로 readTimeout 은 여전히 60초다.

3. 요청 처리 중 timeout 변화 여부

그 다음은 CoyoteAdapter.service() 를 따라갔는데, 여기서 Spring 핸들러로 제어가 넘어가면서도 socket 의 read timeout 은 여전히 60초였다. 요청 처리 중에 timeout 이 바뀌는 게 아니라, 응답이 끝나고 keep-alive 상태로 돌아갈 때 비로소 값이 바뀌어야 했다.

4. 동기 요청의 keep-alive 재진입

먼저 동기 요청부터 봤는데, Http11Processor.service() 흐름을 지나면서 keep-alive 재진입이 준비된다.

확인한 포인트는 두 가지였다.

  • 응답 완료 이후 keptAlive = true 로 넘어가는가
  • 다음 request line 대기 전에 keepAliveTimeout 이 적용되는가
동기 요청에서 Tomcat NIO keep-alive timeout 이 100초로 갱신되는 흐름

동기 요청에서는 poller 가 보게 되는 timeout 이 100초로 정상 갱신된다.

5. async 요청에서 갈리는 지점

같은 지점을 async 요청과 비교하면 어디서 갈리는지 보이기 시작한다.

async 요청 이후 Tomcat keep-alive timeout 설정 호출이 누락되는 흐름

여기까지 비교하면 결론은 꽤 선명해진다.

  • 첫 요청까지는 동기/비동기 모두 동일하다.
  • 차이는 응답 완료 후 keep-alive 로 복귀하는 시점에 생긴다.
  • async path 에서는 keepAliveTimeout 설정 호출이 빠지는 경로가 존재하는 것으로 보였다.

6. changelog 와 코드 추적 결과 연결

여기서 Tomcat changelog 의 이 한 줄이 결정적으로 맞물렸다.

69748: Add missing call to set keep-alive timeout when using HTTP/1.1 following an async request

코드 추적 결과에 대응시키면 해석은 거의 같다.

  • following an async request → 재현한 문제가 정확히 async 요청 이후였다.
  • missing call to set keep-alive timeout → 추적한 핵심도 바로 그 누락 여부였다.

버그 번호를 먼저 알고 현상을 끼워 맞춘 것이 아니라, 현상을 코드로 좁힌 뒤 changelog 문장으로 최종 확인한 셈 이다.

7. 추적 순서 정리

처음부터 Bug 번호를 알고 접근한 것이 아니라, 아래 순서로 좁혀졌다.

  1. keepAliveTimeout 이 설정과 다르게 동작한다.
  2. 종료 시각이 ALB 와 맞지 않는다.
  3. FIN 은 서버가 보낸다.
  4. async request 에서만 재현된다.
  5. Tomcat 코드 흐름상 timeout 갱신 누락 지점이 보인다.
  6. Tomcat changelog 의 Bug 69748 문구와 일치한다.

의심할 조건

이 문제는 아래 조건이 겹칠 때 의심해볼 만하다.

  • Spring Boot Embedded Tomcat
  • HTTP/1.1 keep-alive
  • Servlet async request 사용
  • connectionTimeout 과 keepAliveTimeout 이 다름

핵심은 "Tomcat 에 keep-alive timeout 버그가 있다" 가 아니라, async request 이후 keep-alive 로 다시 들어가는 특정 경로에서만 문제가 드러날 수 있다 는 점이다.

관측상으로는 보통 이렇게 보인다.

  • 응답은 정상
  • 다음 요청 없이 idle 상태
  • keepAliveTimeout 보다 짧은 시점에 연결 종료
  • 종료 시점이 connectionTimeout 과 비슷하거나 정확히 일치

대응 방향

정리하면 대응 방향은 세 가지였다.

1. Tomcat 업그레이드

Bug 69748 fix 가 포함된 버전으로 올리는 것이 가장 우선순위가 높고 정석적인 방법이다.

Tomcat 버전수정 포함 버전
9.0.x9.0.108 이상
10.1.x10.1.44 이상
11.0.x11.0.10 이상

2. connectionTimeout 과 keepAliveTimeout 을 맞추기

근본 해결은 아니지만, 운영 리스크를 빠르게 낮추는 우회책은 될 수 있다.

server.tomcat.connection-timeout=100s
server.tomcat.keep-alive-timeout=100s

명시적으로 이렇게 맞추면 timeout 갱신이 누락되더라도 관측되는 종료 시점 차이는 줄어든다.

3. async request 사용 지점 점검

특히 아래 패턴을 사용 중이라면 재현 여부를 확인해볼 가치가 있다.

  • DeferredResult
  • Callable
  • WebAsyncTask
  • SseEmitter
  • request.startAsync()

마무리

이번 이슈는 간헐적 502 에서 시작해서 Tomcat async request 이후 keep-alive timeout 적용 누락 이라는 결론에 도달했는데, 핵심은 단순히 "Tomcat 버그가 있었다" 가 아니라 아래 포인트에 있었다.

  • 설정값만 보면 100초라고 믿기 쉽지만, 실제 소켓에 어떤 timeout 이 남아 있는지는 별개다.
  • async path 는 동기 path 와 다른 흐름을 탄다.
  • LB, proxy, client 를 의심하기 전에 누가 실제로 FIN 을 보냈는지부터 확인해야 한다.

keepAliveTimeout 설정값과 실제 idle close 시점은 항상 같다고 가정하면 안 된다.

운영에서 timeout 문제를 볼 때는 설정 파일만 읽지 말고 반드시 실제 패킷과 연결 종료 주체를 먼저 확인하는 편이 낫고, 그 한 단계만 넘어가도 문제는 의외로 빨리 좁혀진다.


정리
✔ keepAliveTimeout 설정값과 실제 idle close 시점은 항상 같지 않다.
✔ async request 이후 keep-alive 복귀 경로에서 timeout 갱신 호출이 누락될 수 있다. (Tomcat Bug 69748)
✔ 간헐적 502 가 보일 때, LB / proxy / client 보다 먼저 누가 FIN 을 보냈는지 확인하는 것이 빠르다.
✔ 설정값만 믿지 말고, 패킷 캡처와 코드 흐름을 같이 봐야 한다.

📚 Reference

🏷️ 같은 태그의 글 보기