JVM 진영에는 수많은 HttpClient 라이브러리가 있다. 나도 한 대여섯가지 써 본 것 같은데, Unirest, HttpURLConnection, RestTemplate, Webclient, Retrofit.. 등등 써봤다. 가장많이 써 봤던 RestTemplate 설정에 관해서 적어보자.
특별히 많이 썼던 이유는 별 거 없고, 스프링부트로 작업할 때, 기본 내장된 client 라이브러리라서 그냥 갖다쓴거다. 따로 더 의존성을 추가해서 어플리케이션을 무겁게 만들고 싶지 않아서.. 이제는 스프링 측에서도 deprecated 됐다고 webclient 쓰라고 자꾸 부추키긴 하지만.. 아직도 잘 쓰고 있고, 왠만하면 바꿀 생각은 없다.. 바꿀 필요성을 못 느낀다는 쪽에 더 가까움. 요런 게 몇가지 있는데 Docker를 통한 ecr 배포 시스템이라든가.. Jenkins 잘 쓰고 있는데, 굳이 바꿔야 될 필요가 있나..
전통적인 스레드 풀 방식의 Blocking Api call 방식이다. 직관적이고 , 디버깅하기도 쉽다고 난 생각한다. 성능이 그리 중요하지 않다면, 충분히 괜찮다고 생각한다.
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)
}
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까지 찍으면, 파일 같은 거 업로드 할 때, 로그가 너무 많아져서, 제외하도록 하자
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 발생할때 역시 간단히 로깅찍는 용도
@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
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://findmypiece.tistory.com/276
https://tech.lezhin.com/2020/07/15/kotlin-webflux
https://www.slideshare.net/ifkakao/5-113145589
https://findmypiece.tistory.com/312
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 환경에서 내가 종종 쓰는 유용한 라이브러리나 플러그인 소개하는 글 적어봐야겠다.