SpringBoot 환경에서 HTTP 요청을 보낼 때 주로 사용하는 client 는 RestTemplateWebClient 가 있다. RestTemplate 은 Spring 3.0 부터 포함된 client 로, mvc 환경에서 blocking I/O 방식으로 요청을 보낼 때 주로 사용되어왔고, WebClient 는 (비교적 ?) 최신인 5.0 부터 도입되어, webflux 환경에서 non-blocking I/O 로 요청을 보낼 때 애용되어왔다.

WebClient 의 connection manager 는 기본 구현체로 reactor-netty 를, 그리고 reactor-netty 는 netty 의 wrapper 로, 실상 HTTP 요청을 보내는 connection 을 관리하는 주체는 netty framework 를 사용한다. 따라서 WebClient 의 설정을 customize 하기 위해서는 netty, reactor-netty 의 옵션들을 알아야 한다.

아직까지도 RestTemplate, 그리고 스프링 6 버전부터 도입된 RestClient 을 사용하는 경우도 많이 찾아볼수 있고, 해당 클라이언트에서 주로 사용되는 Apache HttpClient 5 버전에도 여러 옵션들이 존재하지만, 해당 내용까지 담기에는 너무 길어질 것 같아 다음에 알아보는 것으로 하고, 오늘은 WebClient, 즉 netty 쪽을 알아보자.

WebClient (reactor-netty) 옵션

webClient 에서 주로 사용하는 옵션들은 다음과 같다. (물론 netty 자체에서도 제공하는 옵션들은 수없이 많다)

val connectionProvider = ConnectionProvider.builder("my-pool")
	.maxIdleTime(Duration.ofMillis(42))
	.maxLifeTime(Duration.ofMillis(42))
	.build();
	
val httpClient = HttpClient.create(connectionProvider)
	.doOnConnected {
		it.addHandlerLast(ReadTimeoutHandler(42, TimeUnit.MILLISECONDS))
			.addHandlerFirst(WriteTimeoutHandler(42, TimeUnit.MILLISECONDS))
	}
   .responseTimeout(Duration.ofMillis(42))

val webClient = WebClient.builder()
	.clientConnector(ReactorClientHttpConnector(httpClient))
	.build()

하나씩 살펴보자.

val connectionProvider = ConnectionProvider.builder("my-pool")
	.maxIdleTime(Duration.ofMillis(42))
	.maxLifeTime(Duration.ofMillis(42))
	.build();

가장 먼저 살펴볼 부분은 maxIdleTimemaxLifeTime 이다. ConnectionProvider 클래스는 개별의 connection 뿐만 아니라, connection pool 전략과 관련된 설정을 지정할 수 있다.

maxLifeTime 은 connection 의 전체 생명주기를 의미한다. 바꿔 말하면, connection 이 얼마나 사용되던, 사용되지 않았던 관계 없이, 한번 맺은 connection 을 얼마나 오랫동안 사용할 것인지를 의미한다. 예를 들어, maxLifeTime 이 10 초로 설정되어 있다면, server 에 한번 connection 을 맺고나서, 10초가 지나면 해당 connection 은 pool 에서 제거되고(기존에 해당 connection을 몇 번이고 사용했을지라도), 다시 요청을 보내려고 하면 새로운 connection 을 맺어서 요청을 보낸다.

maxIdleTime 은 idle 상태, 즉 사용되지 않은 connection 을 얼마나 이후에 pool 에서 제거할 것인지를 의미한다. 가령, maxIdleTime 이 5초라면, 0초에 요청을 보낸 뒤 5초가 될때까지 새로운 요청을 보내지 않으면 해당 connection 은 pool 에서 제거된다. 만약 3초에 요청을 보냈었다면, 8초가 될때까지는 살아있었을 것이다.

val httpClient = HttpClient.create(connectionProvider)
	.doOnConnected {
		it.addHandlerLast(ReadTimeoutHandler(42, TimeUnit.MILLISECONDS))
			.addHandlerFirst(WriteTimeoutHandler(42, TimeUnit.MILLISECONDS))
	}
   .responseTimeout(Duration.ofMillis(42))

두 번째로는 HttpClient 클래스에서 설정할 수 있는 옵션들로, ReadTimeoutHandler , WriteTimeoutHandler, 그리고 responseTimeout 이다.

responseTimeout 은 server 로 요청을 보내고 나서, 응답이 올 때까지 걸리는 대기 시간을 의미한다. 역시 예시를 들면, responseTimeout 이 3초라고 한다면, 0초에 요청을 보냈는데 server 에서 해당 요청을 처리하는데 4초가 걸렸다면 timeout 으로 exception 이 발생한다. 만약 3초 이내로 응답이 왔다면 exception 이 발생하지 않는다.

ReadTimeoutHandlerWriteTimeoutHandler 는 조금 특수한데, reactor 혹은 WebClient 와 관계없이, 순수하게 netty 의 TCP connection 자체(정확히는 netty 의 channel pipeline)에 거는 handler 다. WriteTimeoutHandler 는, 이름처럼 “쓰기”를 하는데 걸리는 시간으로, netty channel 에 write 를 요청했으나 제 시간에 완료가 되지 않는다면 exception 을 발생시킨다. ReadTimeoutHandler 또한 마찬가지로, netty channel 에 read 로 들어오는 시간이 설정된 값보다 넘었을 경우 exception 이 발생된다.

여기까지 살펴보면, 각 옵션들과 그 의미가 모호하게 느껴지는 부분들이 있다. 가령, responseTimeoutReadTimeoutHandler 는 비슷한 역할을 하는 것으로 보이기에 둘 중 아무거나 써도 될 것 같아 보이지만, 사실은 그렇지 않다.

responseTimeout vs ReadTimeoutHandler

responseTimeoutReadTimeoutHandler 는 둘 다 ‘응답’ 을 대기하는 속성이다. 결론부터 먼저 말하자면, ReadTimeoutHandler 는 특수한 상황이 아니라면 사용하는 것을 추천하지 않는다. 그 이유는, 두 개 timeout 의 tick 이 시작하는 시점이 다르기 때문이다.

앞서 responseTimeout 은, “요청을 보내고 나서”, 응답이 올 때까지 걸리는 대기 시간이라고 언급했다. 따라서, 이 과정은 connection 을 생성 혹은 pool 에서 가져오거나,TLS handshake 를 하는 시간을 제외한 시간이다. 즉, 순수하게 HTTP level 에서 응답-요청까지의 interval 을 계산한 값이다.

한편, ReadTimeoutHandler 의 timeout 은 connection 이 사용되려고 하는 시점부터 tick 이 진행된다. 바꿔 말하면, ReadTimeoutHandler 는 TCP 관점에서 timeout 을 측정한다. 따라서, TLS handshake 와 같은 부가적인 시간도 포함된다.

즉, 그림으로 표현하면 아래와 같이 동작한다.

image.png

이는 간단한 테스트 코드로도 실험해볼 수 있다. 먼저, 다음과 같이 아주 간단한 controller 코드를 작성하자.

@PostMapping("/test")
fun test() {
	return "Hello World!"
}

그리고 테스트코드는 다음과 같다.

val httpClient = HttpClient.create()
	.doOnConnected {
		it.addHandlerLast(ReadTimeoutHandler(1000, TimeUnit.MILLISECONDS)) // (1)
	}
	.wiretap("my-wiretap", LogLevel.INFO, AdvancedByteBufFormat.TEXTUAL) // netty log 활성화
	.responseTimeout(Duration.ofMillis(1000)) // (2)

val webClient = WebClient.builder()
	.clientConnector(ReactorClientHttpConnector(httpClient))
	.build()

val body = Mono.delay(Duration.ofMillis(2000))
	.doOnNext { logger.info { "body supplied" } }
	.map { "some-body" }

webClient.post()
	.uri("http://localhost:8080/test")
	.body(body)
	.retrieve()
	.bodyToMono<String>()
	.test()
	.expectNextCount(1)
	.verifyComplete()

위 코드를 보면, ReadTimeoutHandlerresponseTimeout 을 둘 다 설정했는데, 실제로 테스트 코드를 실행할 때는 둘 중 하나를 주석처리 하면서 코드를 실행해보면 된다. body 를 일반적인 value 가 아닌, Mono 로 한번 감쌌다는 점에 주목하자. 즉, webClient 를 요청하려고 connection 을 맺은 뒤, 요청을 보낼 때 body 를 2초 뒤에 제공한다.

responseTimeout 만을 사용했을 때의 로그는 다음과 같다.

2025-02-28 00:07:26.825 [reactor-http-kqueue-2] [INFO] my-wiretap - [5c518a70] REGISTERED 
2025-02-28 00:07:26.864 [reactor-http-kqueue-2] [INFO] my-wiretap - [5c518a70] CONNECT: localhost/127.0.0.1:8080 
2025-02-28 00:07:26.872 [reactor-http-kqueue-2] [INFO] my-wiretap - [5c518a70, L:/127.0.0.1:64410 - R:localhost/127.0.0.1:8080] ACTIVE 
2025-02-28 00:07:28.891 [parallel-1] [INFO] WebClientTest - body supplied 
2025-02-28 00:07:28.901 [reactor-http-kqueue-2] [INFO] my-wiretap - [5c518a70-1, L:/127.0.0.1:64410 - R:localhost/127.0.0.1:8080] WRITE: 167B POST /hello?sleep=0 HTTP/1.1
user-agent: ReactorNetty/1.2.1
host: localhost:8080
accept: */*
Content-Type: text/plain;charset=UTF-8
content-length: 9

some-body 
2025-02-28 00:07:28.902 [reactor-http-kqueue-2] [INFO] my-wiretap - [5c518a70-1, L:/127.0.0.1:64410 - R:localhost/127.0.0.1:8080] FLUSH 
2025-02-28 00:07:28.951 [reactor-http-kqueue-2] [INFO] my-wiretap - [5c518a70-1, L:/127.0.0.1:64410 - R:localhost/127.0.0.1:8080] READ: 127B HTTP/1.1 200 
Content-Type: text/plain;charset=UTF-8
Content-Length: 13
Date: Thu, 27 Feb 2025 15:07:28 GMT

Hello, world! 
2025-02-28 00:07:28.965 [reactor-http-kqueue-2] [INFO] my-wiretap - [5c518a70, L:/127.0.0.1:64410 - R:localhost/127.0.0.1:8080] READ COMPLETE 

반대로, ReadTimeoutHandler 만 사용했을 때는 다음과 같이 ReadTimeoutException 이 발생한다.

2025-02-27 23:55:03.117 [reactor-http-kqueue-2] [INFO] my-wiretap - [cd99e5c0] REGISTERED 
2025-02-27 23:55:03.136 [reactor-http-kqueue-2] [INFO] my-wiretap - [cd99e5c0] CONNECT: localhost/127.0.0.1:8080 
2025-02-27 23:55:03.140 [reactor-http-kqueue-2] [INFO] my-wiretap - [cd99e5c0, L:/127.0.0.1:63968 - R:localhost/127.0.0.1:8080] ACTIVE 
2025-02-27 23:55:04.149 [reactor-http-kqueue-2] [WARN] r.n.h.c.HttpClientConnect - [cd99e5c0-1, L:/127.0.0.1:63968 - R:localhost/127.0.0.1:8080] The connection observed an error 
io.netty.handler.timeout.ReadTimeoutException: null
2025-02-27 23:55:04.153 [reactor-http-kqueue-2] [INFO] my-wiretap - [cd99e5c0-1, L:/127.0.0.1:63968 - R:localhost/127.0.0.1:8080] CLOSE 
2025-02-27 23:55:04.154 [reactor-http-kqueue-2] [INFO] my-wiretap - [cd99e5c0-1, L:/127.0.0.1:63968 ! R:localhost/127.0.0.1:8080] INACTIVE 
2025-02-27 23:55:04.155 [reactor-http-kqueue-2] [INFO] my-wiretap - [cd99e5c0-1, L:/127.0.0.1:63968 ! R:localhost/127.0.0.1:8080] UNREGISTERED 

responseTimeout 은 body 까지 모두 전송하고 나서부터 측정을 했기 때문에 timeout 에 걸리지 않았다. 반면, ReadTimeoutHandler 는 body 를 전송(Mono.delay 로 body 가 생성되는 시간을 포함하여)하는 시간까지 포함했기 때문에 exception 이 발생한 것을 확인할 수 있다.

responseTimeout 과 관련한 이슈는 다음 링크 에서 확인해 볼 수 있다.

ReadTimeoutHandler vs maxIdleTime

ReadTimeoutHandler 가 connection 생성 이후부터 timeout 을 측정한다면, 이번에는 maxIdleTime 이랑도 비교해볼 수 있다. ReadTimeoutHandler 의 timeout 이 1000ms , maxIdleTime 의 timeout 이 1000ms 라고 가정해보자. 응답을 받아서, 처리 후 connection pool 에 돌아가기까지 100ms 가 걸렸다면, connection pool 에서 900ms 뒤에 ReadTimouetHandler 로 인해 connection 이 제거될까 ? 그렇다면 maxIdleTime 은 의미가 없는거 아닐까 ? 그렇지는 않다.

앞서 ReadTimeoutHandler 를 이야기할 때, “connection 이 생성된 시점” 이라고 말하지 않고, “connection 이 사용되려고 하는 시점” 이라고 표현하면서 동시에 bold 체를 사용한 이유가 있다.

ReadTimeoutHandler 자체만으로 보았을 때, timeout 은 connection 이 생성된 시점, 즉 channel 에 연결이 되는 시점부터 시작하는게 맞다. 따라서 connection pool 에 다시 되돌아가서 idle 상태로 대기중일 때에도 tick 이 계속 진행될 것이라고 생각되지만, 사실은 doOnConnected 에서 연결된 handler 는 한 번의 요청이 끝나면 제거된다.

// reactor.netty.ReactorNetty.class
// 아래 메소드를 호출한다
static void registerForClose(boolean shouldCleanupOnClose,
		String name, // ReadTimeoutHandler
		Connection context) {
	if (!shouldCleanupOnClose) {
		return;
	}

	context.onTerminate().subscribe(null, null, () -> context.removeHandler(name));
}

doOnConnected 는, “connection 이 생성되었을 때” 뿐만 아니라, 새로운 요청을 보내기 위해 connection pool 에서 idle connection 을 꺼내올때도 해당된다.

즉, 정리하자면 요청을 보내기 위해 connection 을 꺼내거나 새로 생성한다. 이 때 ReadTimeoutHandler 가 등록된다. 요청이 끝난 이후 ReadTimeoutHandler 는 제거되고, connection 은 pool 로 되돌아가며, 이 때부터 maxIdleTime 의 tick 이 진행되는 흐름이다.

자주 접하는 에러들

WebClient 를 사용하면서 서비스를 운영하다보면 위 에러를 자주 마주하게 된다.

  • reactor.netty.http.client.PrematureCloseException: Connection prematurely closed BEFORE response
  • Connection Reset By peer

해당 에러가 발생하는 원인은 다양하지만, 만약 간헐적으로 발생한다면 주로 HTTP keep-alive 를 살펴볼 필요가 있다. (apache httpclient5 에서는 idleTime 을 HTTP keep alive 헤더를 바탕으로 동적으로 생성할 수 있는 API를 제공하지만, 아직 reactor netty 에서는 확인하지 못했다)

에러가 발생하는 상황의 흔한 예시를 들면 다음과 같다.

  • connection idle time 이 해당 시간보다 길게 설정되어 있었고, 동시에 서버 측에서는 connection 에 대한 close 메세지(TCP FIN)를 보내주지 않고 조용히 connection 을 닫았다. 이 때, client 입장에서는 stale connection 을 이용해 요청을 보내지만, 서버측에서는 이미 닫힌 connection 이므로 TCP RST flag 를 응답한다
  • idle time 설정이 keep-alive 헤더보다 아주 조금 작게 설정되어 있어서, connection pool 에서 connection 을 꺼내고, request 를 보내기 아주 직전에 connection 이 종료되었다.

1번 케이스의 경우, 기본적으로 서버측에서 close 를 보내주는게 맞지만, client 입장에서는 idle time 을 낮게 잡는게 최선으로 보인다. reactor netty 레포지토리에 올라온 이슈 중 하나 가 이 예시에 해당한다.

2번 케이스 또한, idle time 을 좀 더 작게 잡을 수도 있고, connection pool 전략 중 lifo 를 선택하는 것도 방법이다. (ConnectionProvider.builder(”pool-name”).lifo().build() )

lifo 전략은 connection pool 에서 connection 을 꺼낼 때, “가장 최근에 사용된”(MRU, Most Recently Used) connection 을 사용하도록 한다. 가장 최근에 사용되었다는 것은, 가장 idleTime 이 짧다는(=더 오래 살 수 있다는) 의미이므로, 상대적으로 에러가 발생할 가능성이 적다.

PrematureCloseException 을 던지는 위치를 찾아보면, netty 의 channelInactive 가 호출되었을 때, 즉 tcp 상에서는 RST flag 를 받았을 때, 혹은 예상치 못한 타이밍에 FIN flag 를 받았을 때 해당 에러를 던진다.

// reactor.netty.http.client.HttpClientOperations.class 
// 해당 함수는 `channelInactive` 에서 호출된다.
@Override
protected void onInboundClose() {
	if (isInboundCancelled() || isInboundDisposed()) {
		listener().onStateChange(this, ConnectionObserver.State.DISCONNECTING);
		return;
	}
	listener().onStateChange(this, HttpClientState.RESPONSE_INCOMPLETE);
	if (responseState == null) {
		Throwable exception;
		if (markSentHeaderAndBody()) {
			exception = AbortedException.beforeSend();
		}
		else if (markSentBody()) {
			exception = new PrematureCloseException("Connection has been closed BEFORE response, while sending request body");
		}
		else {
			exception = new PrematureCloseException("Connection prematurely closed BEFORE response");
		}
		listener().onUncaughtException(this, addOutboundErrorCause(exception, unprocessedOutboundError));
		return;
	}
	super.onInboundError(addOutboundErrorCause(new PrematureCloseException("Connection prematurely closed DURING response"),
			unprocessedOutboundError));
}

이 역시 다음의 간단한 코드로 netty 서버를 띄워서 테스트해볼 수 있다.

fun main() {
	val bossGroup = NioEventLoopGroup()
	val workerGroup = NioEventLoopGroup()

	try {
		ServerBootstrap()
			.group(bossGroup, workerGroup)
			.channel(NioServerSocketChannel::class.java)
			.childHandler(object : ChannelInitializer<SocketChannel>() {
				override fun initChannel(ch: SocketChannel) {
					ch.pipeline().addLast(RudeServerHandler())
				}
			})
			.bind(8080).sync().channel().closeFuture().sync()
	} finally {
		bossGroup.shutdownGracefully()
		workerGroup.shutdownGracefully()
	}
}

private class RudeServerHandler : ChannelInboundHandlerAdapter() {
	override fun channelActive(ctx: ChannelHandlerContext) {
		// (1)
		ctx.close().await()
	}

	override fun channelRead(ctx: ChannelHandlerContext, msg: Any) {
		// (2)
		ctx.close().await()
	}
	
	override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
		cause.printStackTrace()
		ctx.close()
	}
}

main 부분은 서버를 띄우는 부분이라 크게 볼 건 없고, RudeServerHandlerchannelActive , 그리고 channelRead 부분을 살펴보면 된다. channelActive 함수는 connection 이 연결되었을 때, 그리고 channelRead 는 데이터를 읽을 때(모든 데이터는 아님) 호출된다. 그리고 이 타이밍에, channel(connection) 을 강제로 닫아버리고 있다.(ctx.close().await()). 둘 다 있을 필요는 없으므로, 하나씩 주석처리를 해가며 테스트를 해보면 된다. 먼저, channelRead 쪽 close 만 사용했을 때(=channelActive 쪽 코드를 주석처리 했을 때) 를 살펴보자.

예상한대로, reactor.netty.http.client.PrematureCloseException: Connection prematurely closed BEFORE response 에러가 발생한다. 이 때의 wireshark packet 을 보면 다음과 같다.

image.png

1~3 번째 packet(2380, 2381, 2383) 은 tcp 3-way handshake 로, 정상적인 connection 을 맺었다. 4번째 packet(2385) 은 tcp 흐름제어(sliding window)와 관련된 packet 이므로 무시하자. 5번째 packet(2392) 에서, HTTP 요청을 정상적으로 보냈고, 6번째 packet(2393) 은 해당 데이터를 정상적으로 받았음을 의미한다.

7번째(2404)부터, 갑작스럽게 server 에서 client 측으로 FIN 을 보내면서, connection 을 닫는다. client 입장에서는 request 를 제대로 보냈으나 갑작스럽게 FIN 으로 connection 을 닫으라는 메세지를 받았으므로, 예상치 못한 상황이라 예외를 던진다. 에러 메세지 Connection prematurely closed BEFORE response 가 어떤 의미인지 이해가 될 것이다.

이번에는 반대로 channelActive 쪽 close 만 사용해보자. 이번에는 기존과 달리 Connection reset by peer 에러가 발생한다. 이 또한 packet 을 살펴보자.

image.png

1~4 번째 packet(2239, 2240, 2242, 2244) 은 기존과 동일하게, 3-way handshake 이후 sliding window 를 위한 packet 을 주고받았다. 5번째 packet(2251) 에서,TCP 연결을 맺자마자, server 측에서 FIN 을 보내 connection 종료를 보내는 것을 볼 수 있다. 그러나 7번째 packet(2257) 에서 client 는 이미 종료된 connection 에 대해 요청을 보내버렸고, server 측에서는 “난 이미 종료했는데?” 라며 RST 플래그를 응답한것이다.

6번째 packet 에서, server 의 FIN 에 client 가 ACK 으로 응답했음에도 요청을 보낸 이유는 비동기적인 측면이 원인으로 추측하고 있다. reactor netty 에서는 요청을 한창 보내려고 준비중인 와중에, netty framework 가 우선 ACK 을 먼저 응답하고, 요청을 중단하려고 보니까 이미 데이터는 요청된 것으로 보인다.

만약 본인이 webClient 를 사용하다가, 해당 에러가 발생하는데 정확히 무슨 원인인지 파악하기 어렵다면 위 내용을 참고하여 다음의 공식문서를 참고하는 것도 도움이 될 것 같다.

https://projectreactor.io/docs/netty/release/reference/faq.html#faq.connection-closed