RestTemplate vs WebClient

공부는 혼자하는 거·2023년 1월 11일
0

Spring Tip

목록 보기
32/52

JVM 진영에는 수많은 HttpClient 라이브러리가 있다. 나도 한 대여섯가지 써 본 것 같은데, Unirest, HttpURLConnection, RestTemplate, Webclient, Retrofit.. 등등 써봤다. 가장많이 써 봤던 RestTemplate 설정에 관해서 적어보자.
특별히 많이 썼던 이유는 별 거 없고, 스프링부트로 작업할 때, 기본 내장된 client 라이브러리라서 그냥 갖다쓴거다. 따로 더 의존성을 추가해서 어플리케이션을 무겁게 만들고 싶지 않아서.. 이제는 스프링 측에서도 deprecated 됐다고 webclient 쓰라고 자꾸 부추키긴 하지만.. 아직도 잘 쓰고 있고, 왠만하면 바꿀 생각은 없다.. 바꿀 필요성을 못 느낀다는 쪽에 더 가까움. 요런 게 몇가지 있는데 Docker를 통한 ecr 배포 시스템이라든가.. Jenkins 잘 쓰고 있는데, 굳이 바꿔야 될 필요가 있나..

RestTemplate

전통적인 스레드 풀 방식의 Blocking Api call 방식이다. 직관적이고 , 디버깅하기도 쉽다고 난 생각한다. 성능이 그리 중요하지 않다면, 충분히 괜찮다고 생각한다.

HttpClient

org.apache.http.client 를 사용하다. 공식적으로 deprecation 되었지만,

retry는 별도로 의존성 추가해줘야 된다

    // ??? 왜 부트에 기본적으로 등록된 httpclient 를 import 할 수 없지?
    implementation("org.apache.httpcomponents:httpclient:4.5.14")
    implementation("org.springframework.retry:spring-retry:2.0.0")

   fun clientHttpRequestInterceptor(): ClientHttpRequestInterceptor {

        return object : ClientHttpRequestInterceptor {
            override fun intercept(
                request: HttpRequest,
                body: ByteArray,
                execution: ClientHttpRequestExecution
            ): ClientHttpResponse {
                val retryTemplate = RetryTemplate()
                retryTemplate.setRetryPolicy(SimpleRetryPolicy(3))

                try{
                    return retryTemplate.execute<ClientHttpResponse, IOException> { context ->  execution.execute(request, body)}
                }catch (e: Throwable) {
                    throw RuntimeException(e)
                }
            }
        }
    }

    fun createRequestFactory(): BufferingClientHttpRequestFactory {

        val factory = HttpComponentsClientHttpRequestFactory()

        val client: HttpClient = HttpClientBuilder
            .create()
            .setMaxConnTotal(50) //연결을 유지할 최대 숫자
            .setMaxConnPerRoute(20) //특정 경로당 최대 숫자
            .setConnectionTimeToLive(30, TimeUnit.SECONDS) // keep - alive
            //서버에서 keepalive시간동안 미 사용한 커넥션을 죽이는 등의 케이스 방어로 idle커넥션을 주기적으로 지움
            .build()

        factory.httpClient = client
        factory.setConnectTimeout(5000) //5초
        factory.setReadTimeout(5000) //5초
        return BufferingClientHttpRequestFactory(factory)
    }

ClientHttpRequestInterceptor

class RestTemplateLoggingInterceptor : ClientHttpRequestInterceptor {


    val log = KotlinLogging.logger{}

    override fun intercept(request: HttpRequest, body: ByteArray, execution: ClientHttpRequestExecution): ClientHttpResponse {
        val response = execution.execute(request, body)
        loggingResponse(response)
        loggingRequest(request, body)
        return response
    }

    private fun loggingRequest(request: HttpRequest, body: ByteArray) {


        log.info {
            """
            =====Request======
            Headers: ${request.headers}    
            Request Method:${request.method}
            Request URI: ${request.uri}            
            =====Request======
            """.trimIndent()
        }

        //Request body: ${if (body.isEmpty()) null else String(body, StandardCharsets.UTF_8)}
    }

    private fun loggingResponse(response: ClientHttpResponse) {
        val body = getBody(response)

        log.info {
            """
            =====Response======
            Headers: ${response.headers}    
            Response Status: ${response.rawStatusCode}                      
            =====Response======
            """.trimIndent()
        }

        //Response body: ${body}

    }

    private fun getBody(response: ClientHttpResponse): String {
        BufferedReader(InputStreamReader(response.body)).use { br -> return br.readLine() }
    }


}

인터셉터를 이용해 간단히 로깅을 찍도록 했다. body까지 찍으면, 파일 같은 거 업로드 할 때, 로그가 너무 많아져서, 제외하도록 하자

ResponseErrorHandler

class RestTemplateErrorHandler : ResponseErrorHandler {

    private val log = KotlinLogging.logger{}

    override fun hasError(response: ClientHttpResponse): Boolean {
        val statusCode = response.statusCode
        //    response.getBody() 넘겨 받은 body 값으로 적절한 예외 상태 확인 이후 boolean return
        return !statusCode.is2xxSuccessful
    }

    @Throws(IOException::class)
    override fun handleError(response: ClientHttpResponse) {
        val error = getErrorAsString(response)

        log.error { """
                ================
                Headers: ${response.headers}    
                Response Status: ${response.rawStatusCode}           
                error: ${error}
                ================
            """.trimIndent() }

        //error: ${error}
    }

    @Throws(IOException::class)
    private fun getErrorAsString(response: ClientHttpResponse): String {
        BufferedReader(InputStreamReader(response.body)).use { br -> return br.readLine() }
    }
}

error 발생할때 역시 간단히 로깅찍는 용도

config


@Configuration
class AccountRestClientConfig {

    //RestTemplate은 Thread Safe 한가?
    //https://renuevo.github.io/spring/resttemplate-thread-safe/
    //https://amagrammer91.tistory.com/65
    //https://renuevo.github.io/spring/resttemplate-thread-safe/
    //http://guide.ustraframework.kro.kr/ref-doc/02/2ZweudfTSv7Ohe4zrmh4  ssl 비활성화
    //https://blog.eomsh.com/84?category=564774 http2 지원할려면

    /**
     * restTemplate 벤더마다 다르게 등록, 
     *
     */


    @Bean
    fun appleTemplate(): RestTemplate {
        //restTemplate.e
        return RestTemplateBuilder()
            .requestFactory { ClientRequestFactory.createRequestFactory() }
            .setConnectTimeout(Duration.ofMillis(5000)) //읽기시간초과, ms
            .setReadTimeout(Duration.ofMillis(5000)) //연결시간초과, ms
            .additionalInterceptors(listOf<ClientHttpRequestInterceptor>(RestTemplateLoggingInterceptor()))
            .errorHandler(RestTemplateErrorHandler()) //.messageConverters()
            .rootUri(Url.APPLE_GET_OPEN_KEY_URL)
            .build()
    }


    @Bean
    fun facebookTemplate(): RestTemplate {
        //restTemplate.e
        return RestTemplateBuilder()
            .requestFactory { ClientRequestFactory.createRequestFactory() }
            .setConnectTimeout(Duration.ofMillis(5000)) //읽기시간초과, ms
            .setReadTimeout(Duration.ofMillis(5000)) //연결시간초과, ms
            .additionalInterceptors(listOf<ClientHttpRequestInterceptor>(RestTemplateLoggingInterceptor()))
            .errorHandler(RestTemplateErrorHandler()) //.messageConverters()
            .rootUri(Url.FACEBOOK_URL)
            .build()
    }

    @Bean
    fun googleTemplate(): RestTemplate {

        return RestTemplateBuilder()
            .requestFactory { ClientRequestFactory.createRequestFactory() }
            .setConnectTimeout(Duration.ofMillis(5000)) //읽기시간초과, ms
            .setReadTimeout(Duration.ofMillis(5000)) //연결시간초과, ms
            .additionalInterceptors(listOf<ClientHttpRequestInterceptor>(RestTemplateLoggingInterceptor()))
            .errorHandler(RestTemplateErrorHandler()) //.messageConverters()
            .rootUri(Url.GOOGLE_URL)
            .build()
    }

    object Url {
        // TODO: 환경변수로 빼기
        const val FACEBOOK_URL = "https://graph.facebook.com"
        const val GOOGLE_URL = "https://oauth2.googleapis.com"
        const val APPLE_GET_OPEN_KEY_URL = "https://appleid.apple.com/auth/keys"

    }


}

내가 즐겨 쓰는 패턴이다..

사용법은 다 잘 나와있기에, multipart form data 전송할 때, 특이한 점 하나만 써보겠다.

    private fun sendToServer(
        file: MultipartFile,
    ): ByteArray {

        var headers = HttpHeaders()

        headers.contentType = MediaType.MULTIPART_FORM_DATA
        headers.accept = mutableListOf(MediaType.ALL)

        val reqBody: MultiValueMap<String, Any> = LinkedMultiValueMap()
        //todo bytes를 ByteArrayResource로 랩핑한 게 아니라 resource 를 줘야한다. 무슨 차이인지 모르지만, 이렇게 하면 성공이 뜨네..
        reqBody.add("buffer", file.resource)
        reqBody.add("filename", file.originalFilename)

        val requestEntity = HttpEntity(reqBody, headers)
        val response = masteringTemplate.postForEntity("/", requestEntity, ByteArray::class.java)

        log.info { " ${file.originalFilename}" }
        log.info { "response===> ${response}" }

        val responseBody = response.body ?: throw RuntimeException("")

        return responseBody
    }

file resource를 넘겨주는 게 구글에 검색한 관련예시랑은 좀 다르드라..

Interceptor를 활용하여 요청과 응답 데이터를 log로 남기는 방법 중 ResponseEntity의 Body는 한번 사용되면 소멸되는 Stream 이기 때문에 로깅 인터셉터에서 Body Stream을 읽어 소비가 되면 실제 비즈니스 로직에서는 Body를 받아올 수 없는 문제점이 생긴다. 따라서 위와 같이 RestTemplate Bean 설정에서 requestFactory에 BufferingClientHttpRequestFactory를 세팅해줘서 Stream 콘텐츠를 메모리에 버퍼링 함으로써 Body 값을 두 번 읽을 수 있게 해야한다.

하지만 이렇게 BuffringClientHttpRequestFactory를 사용하면 성능상의 단점이 발생하는데, 전체 데이터의 Body를 메모리에 올리기 때문에, 용량이 큰 데이터를 송수신하는 과정에서 OOM이 발생할 수 도 있는 것. 따라서 용량이 큰 데이터를 전송할 때는, 사용하지 않는 편이 좋다.. 인터셉터 다 빼고, error handler 다 빼놓자.

    fun createLargeFileRequestFactory(): SimpleClientHttpRequestFactory {
        val simpleClientHttpRequestFactory = SimpleClientHttpRequestFactory()
        simpleClientHttpRequestFactory.setBufferRequestBody(false) // false
        return simpleClientHttpRequestFactory
    }
    
    
        @Bean
	    fun fileTemplate(): RestTemplate {
      
        return RestTemplateBuilder()
            .requestFactory { ClientRequestFactory.createLargeFileRequestFactory() }
            .setConnectTimeout(Duration.ofSeconds(30)) //읽기시간초과, ms
            .setReadTimeout(Duration.ofSeconds(30)) //연결시간초과, ms
            .rootUri(url)
            .build()
    }

참고

https://www.baeldung.com/spring-rest-template-multipart-upload
https://www.baeldung.com/spring-resttemplate-download-large-file
https://stackoverflow.com/questions/15781885/how-to-forward-large-files-with-resttemplate
https://venable.io/2020/12/spring-rest-template-for-large-files/
https://expitly.tistory.com/61

WebClient

dependencies {

    // mac silicon only
    // https://github.com/apache/commons-lang/blob/master/src/main/java/org/apache/commons/lang3/SystemUtils.java#L1173
    val isMacOS: Boolean = System.getProperty("os.name").startsWith("Mac OS X")
    val architecture = System.getProperty("os.arch").toLowerCase()
    if (isMacOS && architecture == "aarch64") {
        developmentOnly("io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64")
    }
    
    ....
    

예외처리

비동기 메서드 호출하는 상위메서드에서는 try catch 적용한다 하더라도 비동기 메서드에서 발생하는 에러를 잡을 수 없다.

    @Test
    fun testType(){
        try {
            tryCatchTest()
        }catch (e:Exception) {
            println("못 잡아")
        }
    }

    fun tryCatchTest() {
        CompletableFuture.runAsync {
            tryCatchTest2()
        }
    }

    fun tryCatchTest2(){
        throw RuntimeException("Exception")
    }

webClient는 이럴 경우 onErrorMap을 통해서 2XX 코드가 아닌 error를 잡을 수 있다.

    fun testCall(): Mono<String> {

        return analyzeWebClient.get()
            .uri("/")
            .retrieve()
            .bodyToMono<String>()
            .onErrorMap { e ->
                log.error { ">??????!!!!    ${e.message}" }
                e
            }
    }




    fun monoTest() {
        try {
            testCall().subscribe { result ->
                try {
                    println(result)
                    throw RuntimeException("why!!!")
                } catch (e: Exception) {
                    log.error { "여기 " }
                }

            }
        } catch (e: Exception) {
                    log.error { "여기 안탐" }       
        }
    }

이거는 할 말이 많으므로.. 이거땜에 개고생한 입장으로서, 나중에 시간되서 정리되면 글 추가하도록 하겠다.

Spring WebFlux에서 request body 타입은 Flux<DataBuffer> 라, 로깅을 위해 한번 읽어버리면 요청을 처리하는 handler에서 body를 읽을 수 없다.

참고

https://icthuman.tistory.com/entry/Spring-WebClient-%EC%82%AC%EC%9A%A9-2-MVC-WebClient-%EA%B5%AC%EC%A1%B0

https://findmypiece.tistory.com/276

https://medium.com/riiid-teamblog-kr/spring-webflux-%EC%97%90%EC%84%9C-corouter-filter%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-request-response-%EB%A1%9C%EA%B9%85%ED%95%98%EA%B8%B0-df56f9d9680

https://tech.lezhin.com/2020/07/15/kotlin-webflux

https://www.slideshare.net/ifkakao/5-113145589

https://findmypiece.tistory.com/312

https://umbum.dev/1047

https://xlffm3.github.io/spring%20&%20spring%20boot/webflux-async-nonblocking/

https://oliveyoung.tech/blog/2022-11-10/oliveyoung-discovery-premium-webclient/

https://medium.com/nerd-for-tech/webclient-error-handling-made-easy-4062dcf58c49
https://yangbongsoo.tistory.com/9
https://medium.com/a-developers-odyssey/spring-web-client-exception-handling-cd93cf05b76

참고

그 외 시간되면 JVM 환경에서 내가 종종 쓰는 유용한 라이브러리나 플러그인 소개하는 글 적어봐야겠다.

profile
시간대비효율

0개의 댓글