반응형

RestClient를 활용한 예제

build.gradle.kts

dependencies {
    // for HttpInterface + RestClient
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.apache.httpcomponents.client5:httpclient5:5.4.1")
}

TodoClient

interface TodoClient {
    @GetExchange("/todos/{id}")
    fun getTodo(@PathVariable id: Long): Todo

    @PostExchange("/posts")
    fun savePosts(@RequestBody request: Post): Post

    @PutExchange("/{filename}")
    fun uploadFile(@PathVariable filename: String, @RequestBody byteArray: ByteArray)

    data class Todo(
        val userId: Long,
        val id: Long,
        val title: String,
        val completed: Boolean,
    )

    data class Post(
        val title: String,
        val body: String,
        val userId: Long,
    )
}

TodoClientTest

@SpringBootTest
class TodoClientTest {
    @Autowired
    private lateinit var todoClient: TodoClient

    @Test
    fun test() {
        val result1 = todoClient.getTodo(1)
        val result2 = todoClient.savePosts(TodoClient.Post("title", "body", 1))

        assertEquals(1, result1.id)
        assertEquals("title", result2.title)
    }
}

TodoClientConfig

@Configuration
class TodoClientConfig {
    private val log = LoggerFactory.getLogger(this::class.java)

    @Bean
    fun todoClient(objectMapper: ObjectMapper): TodoClient {
        val client = RestClient.builder()
            .baseUrl("https://jsonplaceholder.typicode.com")
            .messageConverters {
                it[0] = MappingJackson2HttpMessageConverter(objectMapper)
            }
            .requestFactory(
                HttpComponentsClientHttpRequestFactory(
                    HttpClientBuilder.create()
                        .setDefaultRequestConfig(
                            RequestConfig.custom()
                                .setConnectionRequestTimeout(Timeout.ofSeconds(5))
                                .setResponseTimeout(Timeout.ofSeconds(30))
                                .build()
                        )
                        .setConnectionManager(
                            PoolingHttpClientConnectionManagerBuilder.create()
                                .setMaxConnPerRoute(30) // 동일한 호스트와의 연결 커넥션 수
                                .setMaxConnTotal(30)    // 전체 연결 커넥션 수
                                .build()
                        )
                        .build()
                )
            )
            .requestInterceptor { request, body, execution ->
                val response = execution.execute(request, body)
                val bodyBytes = response.body.use { it.readBytes() }

                log.info("requestBody : {}", String(body))
                log.info("responseBody : {}", String(bodyBytes))

                object : ClientHttpResponse by response {
                    override fun getBody() = bodyBytes.inputStream()
                }
            }
            .defaultStatusHandler(
                { it.is4xxClientError },
                { request, response -> log.warn("url: {}, status: {}", request.uri, response.statusCode) }
            )
            .build()

        return HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(client))
            .build()
            .createClient(TodoClient::class.java)
    }
}

WebClient를 활용한 예제

build.gradle.kts

dependencies {
    // for HttpInterface
    implementation("org.springframework.boot:spring-boot-starter-web")

    // for WebClient
    implementation("org.springframework:spring-webflux:6.2.0")
    implementation("io.projectreactor.netty:reactor-netty:1.2.0")
}

TodoClient

  • 위 예제와 동일

TodoClientTest

  • 위 예제와 동일

TodoClientConfig

@Configuration
class TodoClientConfig {
    private val log = LoggerFactory.getLogger(this::class.java)

    @Bean
    fun todoClient(objectMapper: ObjectMapper): TodoClient {
        val client = WebClient.builder()
            .baseUrl("https://jsonplaceholder.typicode.com")
            .clientConnector(
                ReactorClientHttpConnector(
                    HttpClient
                        .create(
                            ConnectionProvider.builder("custom-pool")
                                .maxConnections(200) // 최대 연결 수(동시 접속이 100건 초과될 경우 오류 발생. 충분히 큰 숫자로 설정 필요)
                                .pendingAcquireTimeout(Duration.ofSeconds(5)) // 연결 풀에서 대기 시간
                                .maxIdleTime(Duration.ofSeconds(30)) // 최대 유휴 시간
                                .build()
                        )
                        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 연결 타임아웃
                        .responseTimeout(Duration.ofSeconds(15)) // 응답 타임아웃
                        .keepAlive(true)
                )
            )
            .exchangeStrategies(
                ExchangeStrategies.builder()
                    .codecs {
                        it.defaultCodecs().jackson2JsonEncoder(Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON))
                        it.defaultCodecs().jackson2JsonDecoder(Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON))
                    }
                    .build()
            )
            .defaultStatusHandler(
                { status -> status.is4xxClientError },
                { response ->
                    log.warn("url: {}, status: {}", response.request().uri, response.statusCode())
                    Mono.empty()
                }
            )
            .build()

        return HttpServiceProxyFactory
            .builderFor(WebClientAdapter.create(client))
            .build()
            .createClient(TodoClient::class.java)
    }
}

WebClient 요청/응답 로깅하기 - Filter

WebClientLoggingFilter

class WebClientLoggingFilter : ExchangeFilterFunction {
    private val log = LoggerFactory.getLogger(this::class.java)

    override fun filter(request: ClientRequest, next: ExchangeFunction): Mono<ClientResponse> {
        var requestBody = ""
        var responseBody = ""

        return next
            .exchange(
                ClientRequest.from(request)
                    .body { outputMessage: ClientHttpRequest, context: BodyInserter.Context ->
                        request.body()
                            .insert(object : ClientHttpRequestDecorator(outputMessage) {
                                override fun writeWith(bodyPublisher: Publisher<out DataBuffer>): Mono<Void> {
                                    return super.writeWith(Mono.from(bodyPublisher).doOnNext { buffer ->
                                        requestBody += buffer.toString(Charsets.UTF_8)
                                    })
                                }
                            }, context)
                            .doOnEach {
                                log.info("[REQ] url: {}, method: {}, body: {}", request.url(), request.method(), requestBody)
                            }
                    }
                    .build()
            )
            .map { response ->
                response
                    .mutate().body { body ->
                        body
                            .doOnNext { buffer ->
                                responseBody += buffer.toString(Charsets.UTF_8)
                            }
                            .doOnComplete {
                                log.info("[RES] url: {}, method: {}, status: {}, body: {}", request.url(), request.method(), response.statusCode(), responseBody)
                            }
                    }
                    .build()
            }
    }
}

TodoClientConfig

val client = WebClient.builder()
    // ...
    .filter(WebClientLoggingFilter()) // 추가
    .build()

WebClient 요청/응답 로깅하기 - Adapter + Context

DemoContext

data class DemoContext(
    val guid: String,
)

DemoContextHolder

object DemoContextHolder {
    private val threadLocal = ThreadLocal<DemoContext>()

    fun get() = threadLocal.get()

    fun set(context: DemoContext) = threadLocal.set(context)

    fun clear() = threadLocal.remove()

    fun asContextElement(context: DemoContext) = threadLocal.asContextElement(context)
}

LoggingWebClientAdapter

class LoggingWebClientAdapter(
    private val client: WebClient,
) : ReactorHttpExchangeAdapter by WebClientAdapter.create(client) {
    private val log = LoggerFactory.getLogger(this::class.java)

    override fun <T : Any?> exchangeForBodyMono(requestValues: HttpRequestValues, bodyType: ParameterizedTypeReference<T>): Mono<T> {
        loggingRequest(requestValues)
        return exchangeForEntityMono(requestValues, bodyType)
            .map { responseEntity ->
                loggingResponse(requestValues, responseEntity)
                responseEntity.body!!
            }
            .doOnError { throwable ->
                loggingResponse(requestValues, ResponseEntity.internalServerError().body(throwable.localizedMessage))
            }
    }

    private fun loggingRequest(requestValues: HttpRequestValues) {
        val url = requestValues.uri()
        val method = requestValues.httpMethod!!.name()

        val requestLog = HttpLog(
            guid = DemoContextHolder.get().guid,
            url = url.path,
            method = method,
            status = null,
            headers = requestValues.headers.toList().associateBy({ it.first }, { it.second }),
            params = UriComponentsBuilder.fromUri(url).build().queryParams,
            body = requestValues.bodyValue?.let { if (it is String) it else mapper.writeValueAsString(it) },
        )

        log.info("### request: {}", requestLog)
    }

    private fun loggingResponse(requestValues: HttpRequestValues, responseEntity: ResponseEntity<*>) {
        val url = requestValues.uri()
        val method = requestValues.httpMethod!!.name()

        val responseLog = HttpLog(
            guid = DemoContextHolder.get().guid,
            url = url.path,
            method = method,
            status = responseEntity.statusCode.value(),
            headers = responseEntity.headers.toList().associateBy({ it.first }, { it.second }),
            params = null,
            body = responseEntity.body?.let { if (it is String) it else mapper.writeValueAsString(it) },
        )

        log.info("### response: {}", responseLog)
    }

    private fun HttpRequestValues.uri(): URI {
        return if (uri != null) uri!! else DefaultUriBuilderFactory().expand(uriTemplate ?: "", uriVariables)
    }

    data class HttpLog(
        val guid: String,
        val url: String,
        val method: String,
        val status: Int?,
        val headers: Map<String, Collection<String>>?,
        val params: Map<String, Collection<String>>?,
        val body: String?,
    )

    companion object {
        private val mapper = jacksonObjectMapper()
    }
}

TodoClientConfig

val client = WebClient.builder()
    // ...
    .filter { request, next ->
        val context = DemoContextHolder.get()
        next.exchange(request)
            .doOnNext {
                DemoContextHolder.set(context)
            }
    }
    .build()
    
val todoClient = HttpServiceProxyFactory
    .builderFor(LoggingWebClientAdapter(client))
    .build()
    .createClient(TodoClient::class.java)

Test

val context = DemoContext(guid = UUID.randomUUID().toString())

runBlocking(DemoContextHolder.asContextElement(context)) {
    val result = todoClient.savePosts(TodoClient.Post("title", "body", 1))
    log.info("### result: {}", result)
}

WebClient + Coroutine 조합으로 병렬 요청하기

build.gradle.kts

dependencies {
    // for HttpInterface
    implementation("org.springframework.boot:spring-boot-starter-web")

    // for WebClient
    implementation("org.springframework:spring-webflux:6.2.0")
    implementation("io.projectreactor.netty:reactor-netty:1.2.0")
    
    // for Coroutine
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.9.0")
}

TodoClient

interface TodoClient {
    @GetExchange("/todos/{id}")
    suspend fun getTodo(@PathVariable id: Long): Todo // suspend 추가

    data class Todo(
        val userId: Long,
        val id: Long,
        val title: String,
        val completed: Boolean,
    )
}

TodoService

@Service
class TodoService(
    private val todoClient: TodoClient,
) {
    fun getTodos(): List<TodoClient.Todo> {
        return runBlocking {
                async {
                    val todo1 = async { todoClient.getTodo(1) }
                    val todo2 = async { todoClient.getTodo(2) }
                    val todo3 = async { todoClient.getTodo(3) }
                    listOf(
                        todo1.await(),
                        todo2.await(),
                        todo3.await(),
                    )
                }
                .await()
        }
    }

    fun getTodosV2(): List<TodoClient.Todo> {
        return runBlocking {
                async {
                    (1..3)
                        .map {
                            async { todoClient.getTodo(it.toLong()) }
                        }
                        .awaitAll()
                }
                .await()
        }
    }
}

TodoServiceTest

@SpringBootTest
class TodoServiceTest {
    @Autowired
    private lateinit var todoService: TodoService

    @Test
    fun test() {
        val todos1 = todoService.getTodos()
        val todos2 = todoService.getTodosV2()
        assertEquals(todos1, todos2)
    }
}

참고

반응형

'Development > Spring' 카테고리의 다른 글

[Spring] WebSocket  (1) 2024.12.09
[Spring] Partitioning  (2) 2024.09.13
[Spring] Circuit Breaker(with resilience4j)  (0) 2024.07.15
[Spring] Sharding  (0) 2024.07.12
[Spring] multi-module 프로젝트 구성하기(kotlin, gradle)  (0) 2024.07.07

+ Recent posts