Search

“Connection reset by peer” Exception 해결 과정

Created time
2023/01/10 11:37
Last edited time
2023/01/16 02:24
Tags
http

배경

Spring 3.0부터 등장했던 HTTP Template Module ‘RestTemplate’를 사용하면서 큰 문제는 없게 사용했지만, Spring 5.0에서 ‘WebClient’가 나왔고 WebClient 사용을 권고하였기에 WebClient를 주로 프로젝트에 사용해왔습니다. WebClient의 사용하면서 기존에 RestTemplate의 (멀티 스레딩, Blocking 방식) 에서 WebClient (싱글 스레드, Non-Blocking)으로 변경되어 얻을 수 있는 성능과 관리에 이점이 있었습니다. 이 글에서는 WebClient를 사용하면서 발생한 “Connection reset by peer“에 대해서 어떻게 해결해 나갔는지에 대해서 이야기해보려고 합니다.
발생한 예외: (자세한 내용은 펼치기) org.springframework.web.reactive.function.client.WebClientRequestException: readAddress(..) failed: Connection reset by peer;

해결 과정

“Connection reset by peer” 발생
현상을 이야기하면, 구현하고 변경이 없는 Thirdparty 호출 코드에서 발생한 적이 없는 예외가 발생하였습니다. 현재 프로젝트에서 WebClient를 사용하면서, “Client(Application) → Server(Thirdparty) 모델” 관계를 가지고 있습니다. 코드의 변경이 없다는 것은 다음과 같은 경우에서 예외를 발생할 가능성이 있다고 판단하였습니다. 1. Client 코드가 예외를 보완할 수 없는 경우 2. “Framework 또는 Library”와의 Integration에 문제가 있는 경우 3. 호출 대상인 Thirdparty의 Server와의 Integration에 문제가 있는 경우

“Connection reset by peer” 에러는 왜 발생하였나?

위키를 통해서 왜 “Connection reset by peer”이 발생하였는지 찾아봤습니다. 내용은 다음과 같습니다.
A “connection reset by peer” error means the TCP stream was closed, for whatever reason, from the other end of the connection. In other words, the TCP RST was sent and received, but the connection is closed. This issue may happen when you send a packet on your end of the connection, but the other end doesn’t recognize the connection. It’ll send back a packet with an RST bit to close the connection. The error can happen when the peer crashes. Other times, it’s due to poorly-written applications that don’t shut their TCP connections properly.
중요한 내용은 첫 문단입니다. 요약하자면, “‘Connection reset by peer’ 에러 발생은 TCP stream이 닫혔다는 것을 의미한다.”입니다. TCP는 “데이터 전송 제어 프로토콜”로 종단간 연결을 통해서 데이터를 어떻게 전송할지 정의합니다. 그러면 어떻게 TCP 흐름이 중단 되었는지 살펴보겠습니다. 현재 우리 서비스는 HTTP/2를 활용하고 있습니다. HTTP/2는 HTTP/1.1를 호환하는데, HTTP/1.1(RFC 2068)에서 추가된 커넥션 관련 기능인 “Keep-Alive”가 적용되면 다음 이점을 얻을 수 있습니다. 1. latency 감소 : 3-way handshake에 필요한 round-trip이 줄어들기 때문에 그만큼 latency가 감소합니다. 2. 네트워크 비용 감소 : 커넥션을 재사용하기 때문에 새로운 TCP 연결을 만들기 위해서 필요한 CPU, 메모리 리소스를 줄여줍니다. 3. HTTP 파이프라인 커넥션 : 여러 개의 요청을 파이프라이닝 할 수 있다.
그러면, 우리는 여기서 발생한 “Connection reset by peer” 에러에서 왜 TCP Stream이 닫혔는가를 살펴보기 위해서 Server에서 ‘Http Keep-Alive’를 제공하고 있는지부터 확인해보겠습니다.

Thirdparty Server는 ‘Http Keep-Alive’를 제공하는가? “아니다”

Request Header ‘Connection’:‘Keep-Alive’를 사용하여 요청을 하고, Response Header ‘Connection’:‘Keep-Alive’를 응답으로 주는지 확인했지만, Thirdparty Server는 Keep-Alive를 제공하지 않고 있었습니다. 아래는 실제 요청/응답 결과입니다.
'Keep-Alive'를 제공하는 서버의 Response
HTTP/1.1 200 OK // Server는 'Keep-Alive'를 제공하고 있습니다. Connection: Keep-Alive // timeout: 연결의 유지를 위한 최소의 시간(초) // max: 연결을 종료하기 전까지 보낼 수 있는 최대 요청 수, 이후에는 connection이 close가 됩니다. Keep-Alive: timeout=10, max=20
Java
복사

그러면 왜 Thirdparty Server는 ‘Keep-Alive’를 제공하지 않았을까 짚어보고 넘어가겠습니다.

HTTP에서 ‘Keep-Alive’를 사용하는 이유는 ‘API Locality’가 발생하기 때문입니다. 여기서 ‘Locality’는 서버에 동일한 클라이언트가 연속적으로 여러 요청을 보낼 가능성이 높은 경우를 의미합니다.
HTTP/1.1부터 TCP connection을 요청할 때 마다 close하지 않고 재사용할 수 있는 Keep-Alive 옵션으로 ‘persistent connection’을 활용할 수 있습니다. ‘Keep-Alive’를 제공하지 않은 이유는 다음과 같은 경우입니다. - 요청 Client가 많아 연결이 늘어나면, Connection에 필요한 Thread가 비례하게 늘어나게 됩니다. - 자주 요청이 들어오지 않아도 Connection은 계속적으로 유지해야합니다. 이 과정에서 성능이 하락하게 됩니다. - 서버가 수용할 수 있는 Connection 수를 넘어가게 되면 새로운 사용자를 받아들이지 못하게 됩니다. - 분산 서버에서 ‘Keep-Alive’를 사용하게 되면 ‘sticky session’이나 ‘timeout’ 같은 옵션 튜닝은 필수입니다. - 결론적으로 사용자가 많고 사용자 유동이 많은 서비스에는 ‘Keep-Alive’는 성능하락의 원인이 됩니다.
그러면 다시 돌아와서 Server가 ‘Keep-Alive’를 제공하지 않고 있음을 확인했고, 이제 Client에서 잘못된 요청을 하고 있다는 것을 확인했습니다.

Server에서 제공하고 있지 않은 ‘Keep-Alive’에 대해서 Client는 잘 요청하고 있는가?

에러 “Connection reset by peer”을 확인하기 위해서 시행착오가 있었습니다. 아래 해결하기까지 과정에 대해서 설명하도록 하겠습니다. 일단 Client에서 사용하는 Framework 와 Library에 문제 없는지 확인했습니다.

‘Spring framework’ 또는 ‘Netty’ version 문제인가? “아니다”

이슈에서 살펴본 결과 ‘Spring framework’ 또는 ‘Netty’ version의 문제는 아니었음을 확인했습니다. 이유는 우리 Application에서 사용하는 dependency는 이슈에서 기준으로 이야기 하는 “Spring 5.2.6 이상, Netty 0.9.5 이상”에 적합하기 때문입니다. 현재 프로젝트의 ‘Spring framework’, ‘Netty’ version
org.springframework:spring-*:5.3.22
io.projectreactor.netty:reactor-netty-*:1.0.22

Client 구현 로직에서 발생한 에러로 판단하고 해결하는 과정

시도 이전: 기본 설정 사용 (Connection Pool O in use)

옵션 - 기본 옵션 (Connection Pool in use)
WebClient.create(HOST_URL);
Java
복사
결과

시도 1: Connection Pool 사용, Connection의 수명을 작게 가져감

옵션 - Connection Pool 최대 갯수 20개 - 최대한 생성된 Connection의 유휴시간, 수명을 10초로 줄임
WebClient.builder() .clientConnector(new ReactorClientHttpConnector(HttpClient.create( ConnectionProvider.builder("fixed") .maxConnections(20) .maxIdleTime(Duration.ofSeconds(10)) // 커넥션 풀에서 idle 상태인 커넥션 유지 시간 .maxLifeTime(Duration.ofSeconds(10)) // 커넥션 풀에서 커넥션 최대 수명 시간 .build() ))) .baseUrl(HOST_URL) .build();
Java
복사
결과 - 개선이 없었고, 오히려 짧은 커넥션으로 더 많은 “Connection reset by peer” 에러가 발생하였습니다.

시도 2: Connection Pool 사용, Connection 사용 방식 LIFO 적용

옵션 - Connection Pool 최대 갯수 20개 - Connection Pool 사용 방식 LIFO 적용으로 최근 생성된 Connection부터 사용 - 커넥션 유휴 시간과 커넥션 수명을 없애서 “요청마다 새로운 Connection 생성 요청”과 유사하게 구현 사용
WebClient.builder() .clientConnector(new ReactorClientHttpConnector(HttpClient.create( ConnectionProvider.builder("fixed") .maxConnections(20) .lifo() // 가장 최근에 사용된 커넥션을 활용하여 예외 발생시 이전에 생성된 모든 커넥션까지 모두 초기화하기 위함 .maxIdleTime(Duration.ZERO) // 커넥션 풀에서 idle 상태인 커넥션 유지 시간 .maxLifeTime(Duration.ZERO) // 커넥션 풀에서 커넥션 최대 수명 시간 .build() ))) .baseUrl(HOST_URL) .build();
Java
복사
결과 - 커넥션 사용 방식을 LIFO로 변경하였기에 새로운 Connection이 에러가 발생시 오래된 Connection들도 모두 제거하기 때문에 비교적 적은 수의 에러가 발생하였습니다. - 커넥션의 유휴 시간과 수명은 변경 로직에 효과를 주지 못했습니다.

시도 3: 요청마다 새로운 Connection 생성 후 요청 방식

옵션 - Connection Pool을 제거하고, 요청마다 새로운 Connection 생성
WebClient.builder() .clientConnector(new ReactorClientHttpConnector(HttpClient.newConnection())) .baseUrl(HOST_URL) .build();
Java
복사
결과 - 애초에 ‘Keep-Alive’를 제공하고 있지 않았던 서버이기 때문에 요청마다 새로운 Connection을 맺는게 해법이었고, 결과적으로 이후 에러는 발생하지 않았습니다.

결론

Thirdparty와 같은 다른 Server를 사용하는 경우에는 Connection을 어떻게 연결하고 유지하고 사용할지에 대한 사전 테스트를 진행하고 Integration 해야합니다. 이 과정을 생략하는 경우에 어떤 버그가 발생할 지 예측하지 못할 수 있습니다. 그래서 기본적으로 TCP/IP 표준도 잘 이해해야 하고, 요청할 Server에서 제공하는 HTTP의 특성을 최대한 파악하고 개발을 진행해야 안정적인 Integration이 가능합니다.