이 글은 실무에서 겪은 이슈의 원인과 해결, 문제 분석을 회고 형식으로 작성한 글입니다. 결론만 보아도 무방합니다!
1. 개요 (문제 상황)
사내에서 스프링 부트 버전을 3.2.3 으로 업그레이드를 하면서 HTTP 클라이언트로 RestClient 를 도입하기로 했습니다.
RestClient 는 스프링 부트 3.2.x 버전부터 사용할 수 있으며 RestTemplate 을 더이상 유지보수 하지 않게 되면서 스프링 진영에서 권장하고 있는 HTTP 클라이언트 입니다. WebClient 도 있긴 하지만 따로 spring-cloud 의존성을 추가해야 하기 때문에 후보에서 제외되었습니다.
잠깐 RestClient 의 장점에 대해서 설명하자면 fluent 한 API 를 제공하고 체이닝 메서드를 제공하기 때문에 가독성이 좋습니다.
그리고 팀에서 선택하기에 따라서 OpenFeign 처럼 인터페이스에서 선언형으로 사용할 수도 있고 서비스 로직에서 체이닝 메서드를 통해 HTTP 통신을 할 수도 있습니다! (저희는 fluent 하게 체이닝 메서드를 사용하는 방식으로 개발했습니다.)
기존에는 Retrofit 을 사용하고 있었으나 안드로이드 진영에서 많이 사용되는 라이브러리고 별도의 의존성 추가 없이도 사용할 수 있는 RestClient 를 사용하기로 했습니다.
레거시를 청산할 수 있는 새로운 라이브러리의 적용이라니 너무 신이났습니다. ^^!!
하지만 신남도 잠시 어떤 순서로 문제의 코드를 마주하게 되었는지 차근차근 설명해 보도록 하겠습니다.
1) RestClient 설정
공식문서에서도 매우 잘 나와있다시피 RestClient 빈을 선언하고 체이닝 메서드로 HTTP 요청 요소들을 세팅한 뒤 호출하면 끝입니다.
실무에서는 defaultHeader, defaultStatusHandler 등 조금의 세팅을 더 했지만 여기선 생략하도록 하겠습니다.
- https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
RestClient customClient = RestClient.builder()
.baseUrl("https://example.com")
.requestInterceptor(myCustomInterceptor)
.build();
String result = restClient.get()
.uri("https://example.com")
.retrieve()
.body(String.class);
System.out.println(result);
2) 커스텀 로깅 인터셉터
간략하게 스켈레톤 코드로 잘 호출되는 것을 확인한 뒤 request, response 를 로깅할 수 있는 커스텀 인터셉터를 추가하기로 합니다!
- 참고: https://www.baeldung.com/spring-resttemplate-logging
public class LoggingInterceptor implements ClientHttpRequestInterceptor {
static Logger LOGGER = LoggerFactory.getLogger(LoggingInterceptor.class);
@Override
public ClientHttpResponse intercept(
HttpRequest req, byte[] reqBody, ClientHttpRequestExecution ex) throws IOException {
LOGGER.debug("Request body: {}", new String(reqBody, StandardCharsets.UTF_8));
ClientHttpResponse response = ex.execute(req, reqBody);
InputStreamReader isr = new InputStreamReader(
response.getBody(), StandardCharsets.UTF_8);
String body = new BufferedReader(isr).lines()
.collect(Collectors.joining("\n"));
LOGGER.debug("Response body: {}", body);
return response;
}
}
이대로 실행하게 되면 인터셉터가 응답 스트림을 이미 소비하여 API 를 호출했을 때 빈 응답값이 오게 됩니다. 따라서 BufferingClientHttpRequestFactory 객체를 사용하여 요청과 응답을 메모리에 버퍼링하여 문제를 해결 할 수 있습니다.
(하지만 이 방법은 최악의 경우 OOM 으로 이어질 수 있다고 하는데 해당 글에서는 따로 다루지 않도록 하겠습니다. )
ClientHttpRequestFactory factory =
new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory());
RestTemplate restTemplate = new RestTemplate(factory);
자, 그러면 다시 한번 코드를 실행해 볼까요?
3) 문제 발생
String result = restClient.get()
.uri("https://example.com")
.retrieve()
.body(String.class);
System.out.println(result);
위 코드를 실행하게 되면 에러를 만나게 됩니다... 로깅 인터셉터를 추가하기전까지만 해도 잘되던 코드였는데요?
java.lang.IllegalArgumentException: method GET must not have a request body.
이 문제가 왜 발생했을까요?
좀 더 저수준으로 내려가서 HTTP 클라이언트 라이브러리를 무엇을 사용하고 있는지를 알아봐야했습니다.
2. 분석 (문제의 원인을 찾아서...)
1) 내가 사용하고 있는 저수준 HTTP 클라이언트 라이브러리는 무엇일까요?
고수준의 HTTP 클라이언트는 RestClient 를 사용하고 있는데 그렇다면 저수준의 HTTP 클라이언트 라이브러리는 무엇을 사용하고 있었을까요? 위에 명시한 간단한 코드들을 보면 어떤 라이브러리를 사용하고 있는지 설정해 준적이 없습니다.
어떤 저수준 라이브러리를 사용할것인지 적용하는 여러가지 방법들이 있으나
저는 타임아웃등의 사용자 정의구성을 설정하기 위해서 ClientHttpRequestFactorySettings.DEFAULTS 를 사용했습니다.
@Bean
public RestClient restClient() {
var settings = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(5L))
.withReadTimeout(Duration.ofSeconds(5L));
return RestClient.builder()
.baseUrl("")
.requestFactory(new BufferingClientHttpRequestFactory(ClientHttpRequestFactories.get(settings)))
.requestInterceptor(new RestClientLoggingInterceptor())
.build();
}
참고: https://docs.spring.io/spring-boot/reference/io/rest-client.html#io.rest-client.restclient
이 때, requestFactory 를 세팅해주는 부분을 보면 ClientHttpRequestFactories.get(settings) 를 해주고 있습니다.
해당하는 메서드 로직을 보게되면 순서대로 APACHE_HTTP_CLIENT, JETTY_CLIENT, OKHTTP_CLIENT, SIMPLE 을 지원하고 있습니다.
그리고 위 상수들은 ClassUtils 이라는 클래스에서 isPresent 메서드로 클래스 path 를 통해 의존성 존재여부를 가지고 있습니다.
그렇다면, Retrofit 을 사용해본적이 있다면 알 수 있을텐데 아래와 같은 조건에서 사용하게 되는 저수준 HTTP 클라이언트 라이브러리는 무엇일까요?
1. 별도의 저수준 라이브러리 의존성을 추가한 적 없음
2. retrofit 사용중
바로… Okhttp3 입니다. Retrofit 과 Okhttp 는 squareup 이라는 같은 회사에서 만들었고 Retrofit 은 Okhttp 라이브러리에 의존하고 있습니다. HTTP 클라이언트를 교체한다고 해서 기존 레거시 코드를 빅뱅으로 지울 수는 없었기 때문에 해당하는 의존성이 존재했고 ClientHttpRequestFactories.get(settings) 를 호출했을 때 "okhttp3.OkHttpClient" 클래스가 존재했기 때문에 ClientHttpRequestFactory 는 OkHttp3ClientHttpRequestFactory 로 리턴되어 해당 저수준 라이브러리를 사용하게 되었습니다.
2) Okhttp3 는 왜 GET 요청일 때 본문(body) 가 있다면 에러를 발생시키는걸까요?
관련된 내용을 squareup/okhttp 레포 이슈 탭에서 찾을 수 있었습니다.
2017년에 누군가 해당 에러를 받고 요청본문을 보낼 수 있도록 허용해달라는 이슈였는데 코멘트 중에서 이유를 찾을 수 있었습니다.
https://github.com/square/okhttp/issues/3154
위 코멘트를 해석하면 아래와 같습니다.
GET 요청이 의미 없는 요청 본문을 가질 수 있도록 허용하는 것은 상당한 비용을 초래합니다.
- 캐싱이 예측할 수 없게 작동함: 요청 본문이 포함된 GET 요청은 캐시 동작에 부정적인 영향을 미칠 수 있습니다. HTTP 캐시는 GET 요청의 응답을 저장하고 다시 사용할 수 있도록 설계되었지만, 본문이 있으면 캐시의 일관성을 해칠 수 있습니다.
- 인터셉터가 충돌하거나 리소스를 누수할 수 있음: HTTP 요청의 본문을 다루는 과정에서 인터셉터가 예상치 못한 동작을 하게 되어, 시스템의 안정성이 저하될 수 있습니다.요청 본문이
- HTTP/1 연결 풀에 영향을 미쳐 요청 조작 공격을 허용할 수 있음: HTTP/1에서는 요청과 응답이 명확히 구분되어야 합니다. 요청 본문이 포함되면, 이 구분이 흐려져 보안 취약점이 발생할 수 있습니다.
코멘트를 조금 더 읽어보면 여러 개발자들이 RFC 에서도 제약하고 있지 않은데 너무하다고들 하고 있습니다 ㅠㅠ
개인적으로 아래 코멘트가 너무 웃펐습니다. "해줘."
그리고 현재까지도 계속해서 GET 요청시 요청 본문을 허용하지 않고 있는데요.
이는 RestClient 뿐만아니라 OpenFeign 이나 다른 고수준 HTTP 클라이언트를 사용해도 동일할 것입니다.
다시 문제 상황으로 돌아가서 저는 API GET 요청에 요청 본문을 포함시킨적이 없습니다. 그러면 어디서 포함된걸까요??
3) 도대체 어디서 요청 body 가 포함된걸까요?
다시 한번 제가 개발했던 순서를 따라가면서 소거하다보면 마지막으로 의심가는 부분이 한가지 있습니다.
바로 로깅 인터셉터를 추가하면서 추가한 BufferingClientHttpRequestFactory 클래스 입니다!
위에서 간단하게 언급했다시피 BufferingClientHttpRequestFactory 는 RestClient에서 HTTP 요청을 수행할 때 사용하는 클래스로, 요청 본문을 메모리에 버퍼링하여 전체 요청 본문을 읽고 전송할 수 있도록 지원합니다.
주요 기능
- 버퍼링: 요청 본문을 한 번에 메모리에 읽어서 전송하므로, 다른 인터셉터나 요청 후킹 로직에서 본문을 필요할 때 다시 사용할 수 있습니다.
- 성능: 버퍼링을 통해 요청 본문을 여러 번 읽을 수 있지만, 메모리 사용량이 증가할 수 있으므로 대용량 데이터 전송 시 주의가 필요합니다.
- 인터셉터와의 호환성: 요청 본문을 버퍼링하면, 여러 인터셉터가 순차적으로 실행될 때 동일한 본문에 접근할 수 있게 되어, 더 유연한 요청 처리 로직을 구현할 수 있습니다.
앗. 그러면 이 때 의심해볼 수 있습니다. BufferingClientHttpRequestFactory 너가 요청보낼 때 body 를 보내고 있는거구나?
도대체 어디에서 body 를 만들어서 보내고 있는건지 디버깅을 통해 코드를 추적했습니다.
BufferingClientHttpRequestWrapper 클래스 (스프링 6.1.4 버전입니다.) 에 들어가보면 executeInternal 이라는 메서드가 있는데 이곳에서 어떤 스트리밍 Http 메세지든 setBody 를 하고 있는 현장을 목격할 수 있습니다.
spring 컨트리뷰터의 꿈을 안고 해당 이슈가 고쳐졌는지 확인해 보았습니다만 당연히 이미 아주 전에 고쳐져 있었습니다. ^.ㅠ
4월에 누군가 이슈레이징을 했고 바로 다음 패치 버전으로 수정(수정된 commit)되었습니다.
수정된 코드는 다음과 같습니다.
bufferedOutput 의 길이가 0 이상일 때만 setBody 를 호출하도록 조건이 추가되었습니다.
4) 해결
문제의 코드가 수정되었으니 해결방법은 간단했습니다.
스프링 부트 버전을 올리면 됩니다… 3.2.3 에서 GA 인 3.2.10 으로 버전업을 하니 해결되었습니다.
(운영중인 서버의 버전을 올리는게 쉬운일은 아니지만 이미 3.2.3 으로 올리면서 많은 시행착오를 겪었기 때문에 패치 버전을 올리는 건 문제가 되지 않았습니다…)
3. 추가적인 문제
- Spring Boot 3.2.0 버전에서 Okhttp3 이 deprecated 됨
ClientHttpRequestFactories 클래스에서 OkHttp 클래스를 확인하면 @Deprecated 어노테이션 마크가 붙어있는 것을 볼 수 있습니다. 따라서 RestClient 뿐만아니라 RestTemplate, WebClient 도 동일할 것으로 보입니다.
spring-boot 3.2.0 버전 부터 deprecated 된 것을 볼 수 있는데 왜 deprecated 시켰을까요?
이 또한 이슈탭에서 찾아볼 수 있었습니다.
정리해보면,
1. Okhttp4 가 출시되었고 이는 Okhttp3 과 호환은 가능하지만 Kotlin 런타임이 필요함
- 런타임에 Kotlin 을 요구하기 때문에 Java 사용에는 덜 적합하다는 판단
- (Okhttp4 는 Kotlin 으로 개발됨 (안드로이드 진영에 완전히 맞춰가고 있는 것으로 보임))
2. Okhttp5 는 현재 진행중이지만 이전 버전과 호환되지 않을 예정
3. Jetty, Netty 등 좋은 대안이 있으니 사용하기 바람
따라서 spring 에서 Okhttp5 를 오피셜하게 지원하지 않는 이상 다른 저수준 http client 라이브러리를 사용하는 것을 권장합니다.
SimpleClientHttpRequestFactory 의 경우 운영에서 사용하기에 적합하지 않다는 의견들이 많아 다른 라이브러리를 사용하는 것이 좋을 것 같습니다.
(저의 경우, 다른 대안을 사용하더라도 Retrofit 를 아직 제거할 수 없기 때문에 방향성에 대해서 조금 더 생각해보기로 했습니다.)
4. 마무리
위에서 주저리 주저리 작성하면서 알게된 것을 요약하면 아래와 같습니다.
1. ClientHttpRequestFactories.get() 을 사용하게되면 지원하는 클라이언트에 한해서 프로젝트에 주입되어있는 저수준 http client 라이브러리를 자동으로 사용한다.
2. Okhttp 에서는 GET 요청에서 body 를 포함하는 것을 허용하지 않는다.
3. 요청/응답을 로깅하기위한 BufferingClientHttpRequestFactory 에서 body 가 없어도 포함시키는 로직은 스프링 6.1.6 버전에서 포함시키지 않도록 수정되었다.
4. Spring 에서는 Okhttp3 클라이언트를 코틀린 호환 문제로(대표하면) deprecated 시켰다.
사실 제목에는 RestClient 를 포함했지만 BufferingClientHttpRequestFactory 를 사용하는 RestTemplate 에서도 특정 버전 이하에서 동일한 현상이 발생할 것이기 때문에 주의해서 사용할 필요가 있습니다. 만약 버전업이 가능하지 않은 환경의 경우 다른 방법을 찾아본다거나 사용하는 라이브러리를 교체한다든지의 작업이 필요할 것입니다.
느낀점
저의 경우 버전을 올릴 수 있는 환경이었기 때문에 쉽게 이슈를 해결할 수 있었지만 만일 그렇지 못한 상황이었다면 조금 머리가 아팠을 것 같습니다….. (RestClient 는 도입했고 도입한 기능의 데드라인이 쪼여오고….)
또한 깃헙에서 이슈가 레이징되고 그 이슈로부터 사람들이 남긴 코멘트, 해결된 커밋, 그리고 또 다른 이슈에서 멘션된 히스토리 등이 남아있어서 그 기록들을 따라가면서 히스토리를 알게되는 과정들이 매우 흥미로웠던 것 같습니다.