KERBE.io

TECH / troubleshooting

WebClient Connection prematurely closed 에러 원인과 해결

#WebClient#Netty#TCP#Keep-Alive#트러블슈팅

Connection prematurely closed BEFORE response

회사에서 WebClient로 다른 API를 호출할 때 간헐적으로 Connection prematurely closed BEFORE response라는 에러 메시지를 만났습니다. 왜 발생하는지, 어떻게 해결해야 하는지 원인을 추적한 과정을 정리합니다.

이 문제를 이해하려면 TCP 연결 과정과 Keep-Alive 동작 방식을 먼저 짚어야 합니다.

TCP 연결과 해제: 3-way handshake, 4-way handshake

TCP는 전송 계층에 위치한 연결형 프로토콜로, IP와 함께 TCP/IP라는 이름으로 널리 불립니다. 웹 브라우저 같은 클라이언트가 서버에 연결할 때는 물론 이메일 전송, 파일 전송에도 쓰입니다. 연결형이기 때문에 3-way handshake로 연결을 맺고 4-way handshake로 연결을 해제하며, 데이터의 순서를 보장합니다.

연결을 맺는 3-way handshake는 다음 세 단계로 진행됩니다.

3-way handshake 과정

  • Step 1 (SYN) — 클라이언트가 서버와 커넥션을 맺기 위해 SYN을 보냅니다.
  • Step 2 (SYN + ACK) — 서버가 SYN을 받고, 받았다는 응답으로 ACK와 SYN 패킷을 함께 보냅니다.
  • Step 3 (ACK) — 클라이언트가 서버의 응답 패킷을 받고 ACK를 보내면서 연결이 성립됩니다.

연결을 끊는 4-way handshake는 한 단계가 더 있습니다.

4-way handshake 과정

  • Step 1 (FIN + ACK) — 클라이언트가 close()를 호출해 접속을 끊으려 합니다.
  • Step 2 (ACK) — 서버가 클라이언트로부터 FIN을 받았다는 확인 신호를 보내고 대기합니다. 이 시점에 서버는 TIME_WAIT 상태로 들어갑니다.
  • Step 3 (FIN) — 남은 데이터를 모두 보낸 서버가 연결을 끊겠다는 FIN 패킷을 클라이언트로 보냅니다.
  • Step 4 (ACK) — 클라이언트가 FIN을 받고, 확인했다는 신호로 ACK를 서버에 보냅니다.

Keep-Alive는 왜 필요한가

HTTP는 기본적으로 Connection Less 프로토콜입니다. 즉 매 요청마다 연결을 새로 맺고 끊습니다. 이 방식은 매번 3-way/4-way handshake를 반복해야 하므로 데이터를 주고받을 때마다 상당한 리소스를 소비합니다. Keep-Alive는 이 문제를 보완하기 위해 서버와 클라이언트 사이의 커넥션을 일정 시간 유지하는 방식입니다.

HTTP 1.0에서는 헤더에 keep-alive 옵션을 명시해야 동작했지만, HTTP 1.1부터는 기본값입니다. 다만 웹 서버 쪽에서 Keep-Alive 설정이 켜져 있어야 실제로 동작합니다. Keep-Alive에는 주로 두 가지 옵션이 있습니다.

  • timeout — 연결이 닫힐 때까지의 idle time을 설정
  • max — Keep-Alive로 재사용할 수 있는 요청 횟수 제한

아래는 실제 웹 서버의 Keep-Alive timeout 설정 화면입니다.

Keep-Alive timeout 옵션 설정 화면

와이어샤크로 Keep-Alive가 설정된 서버와의 통신 패킷을 확인해보면, 3-way handshake로 연결이 맺어진 뒤 추가 송수신이 없어도 연결이 유지되다가, timeout으로 설정한 시간이 지나면 4-way handshake로 연결이 종료되는 흐름을 볼 수 있습니다.

와이어샤크로 확인한 Keep-Alive 연결 유지 및 종료 패킷

빨간색 사각형이 3-way handshake, 파란색 사각형이 Keep-Alive로 유지되는 연결 구간, 주황색 사각형이 4-way handshake에 해당합니다.

그래서 무엇이 문제인가

문제는 Keep-Alive 타임아웃으로 서버가 연결을 끊는 4-way handshake가 진행되는 바로 그 시점에, 클라이언트가 같은 커넥션으로 새 요청을 보내면서 생깁니다. 정확히는 클라이언트가 요청을 보내자마자 서버로부터 연결 종료를 알리는 FIN 패킷을 받게 될 때 예외가 발생합니다.

클라이언트 입장에서는 커넥션 풀에 여전히 살아있는 연결로 보이지만, 서버는 이미 타임아웃으로 그 연결을 끊는 절차를 시작한 뒤입니다. 확률적으로 발생하는 문제라 재현이 쉽지 않고, 로그에도 간헐적으로만 남습니다.

사용하는 HTTP 클라이언트에 따라 예외 종류도 다릅니다.

  • Apache HTTP Client — NoHttpResponseException
  • Netty HTTP Client (WebClient) — PrematureCloseException

해결 방법

HTTP 클라이언트의 maxIdleTime을 서버의 Keep-Alive 타임아웃 값보다 작게 설정하는 것이 핵심입니다. 클라이언트가 서버보다 먼저 커넥션 풀에서 유휴 연결을 회수하도록 만들면, 서버가 먼저 연결을 끊어버리는 시점과 클라이언트의 요청 전송이 겹치는 경우를 원천적으로 피할 수 있습니다.

서버의 정확한 Keep-Alive 타임아웃 값을 모른다면 와이어샤크로 실제 패킷을 캡처해 timeout 구간을 확인하고, 그보다 여유 있게 짧은 값을 클라이언트 쪽에 설정하는 방식으로 접근하면 됩니다.