본문 바로가기
java/thread(동시성)

API 호출 관점에서 WebClient 와 Coroutine 조합해보기

by 권성호 2024. 1. 30.

사실이 아니라 공부한 내용과 생각을 정리한 글입니다. 언제든 가르침을 주신다면 감사하겠습니다.

기존 WebClient 다시 보기

이전 글에서 webclient를 활용해 api 통신을 했던 코드를 다시 한번 살펴보자.

    @GetMapping("/test/webclient/non-blocking/{sleep}")
    fun `webclient 기반 코드(non-blocking)`(
        @PathVariable("sleep") sleep: Long,
    ): ResponseEntity<List<String?>> {
        println("호출전: ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}")

        val resultList = Flux.merge((1..10).map {
            webClientCall(sleep, it)
        }).toIterable().toList()

        println("호출후: ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}")
        return ResponseEntity.ok(resultList)
    }

    private fun webClientCall(
        sleep: Long,
        idx: Int,
    ): Mono<String?> {
        return webClient.get()
            .uri("/test/$sleep/$idx")
            .retrieve()
            .bodyToMono(String::class.java)
    }

 

위 코드에서 api 통신 결과가 오면 즉각적으로 아래와 같은 작업을 수행해야 한다면 어떻게 구현할 수 있을까?

  1. 특정 후처리 로직 실행(샘플코드에선 postProcess 이름의 메서드로 추상화)
  2. 특정 변환 로직 실행(샘플코드에선 postTransformation 이름의 메서드로 추상화)
  3. 변환된 데이터 로깅(샘플코드에선 postLogging 이름의 메서드로 추상화)

아마 Mono 가 제공해 주는 doOnNext, flatMap 등의 메서드를 활용해 아래와 같이 구현할 수 있을 것이다.

    private fun webClientCall(
        sleep: Long,
        idx: Int,
    ): Mono<String?> {
        return webClient.get()
            .uri("/test/$sleep/$idx")
            .retrieve()
            .bodyToMono(String::class.java)
            .doOnNext { postProcess() }
            .flatMap { postTransformation() }
            .doOnNext { postLogging() }
    }

    private fun postProcess() {
        println("some postProcess")
    }

    private fun postTransformation(): Mono<String?> {
        println("some postTransformation")
        return Mono.just("postTransformation")
    }

    private fun postLogging() {
        println("some postLogging")
    }

 

위 코드는 webclient 를 사용했을 때 가져갈 수 있는 io에 대한 non-blocking의 이점을 가져가면서도 추가 요구사항을 만족한다.

하지만 코드의 가독성 관점에서는 어떤가?

요구사항은 단순히 api 결과를 받아 후처리 및 변환하라는 것인데, 이를 위해 doOnNext, flatMap 이 무엇이고 어떻게 동작해야 하는지 알아야 한다. 

또한 코드의 형태도 절차지향적이지 않고 메서드체이닝 형태를 유지해야 한다.

더 심각한 것은 webclient 가 제공하는 이벤트 루프 내에서 리액티브 하게 동작하는 것을 유지하기 위해선, 앞으로 요구사항이 추가될 때마다 Mono, Flux 가 제공하는 다양한 메서드를 사용해 메서드 체이닝 형태의 코드를 계속해서 늘려나갈 가능성이 있다.

Mono, Flux를 활용한 메서드 체이닝 방식의 코딩 스타일이 익숙한 사람이라면 문제가 없겠지만, 일반적으로 절차지향적인 코드가 더 이해하기 쉽다.

결국, non-blocking에 대한 성능적 이점을 얻기 위해 가독성 낮은 코드를 작성해야 할 여지가 있는 것으로 생각된다.

성능적 이점과 가독성, 두 마리 토끼를 잡을 수 있는 방법은 없을까?

Webclient & Coroutine 함께 사용하기

거두절미하고 코드부터 보자

    @GetMapping("/test/webclient/non-blocking/coroutine/{sleep}")
    fun `webclient_coroutine 기반 코드(non-blocking)`(
        @PathVariable("sleep") sleep: Long,
    ): ResponseEntity<List<String?>> {
        println("호출전: ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}")

        val resultList = runBlocking(Dispatchers.IO) {
            (1..10).map {
                async { webClientCallWithLogic(sleep, it) }
            }.awaitAll()
        }

        println("호출후: ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}")
        return ResponseEntity.ok(resultList)
    }

    private suspend fun webClientCallWithLogic(
        sleep: Long,
        idx: Int,
    ): String? {
        val result = webClient.get()
            .uri("/test/$sleep/$idx")
            .retrieve()
            .bodyToMono(String::class.java)
            .awaitSingle()

        postProcess(result)
        val postTransformation = postTransformation(result)
        postLogging(postTransformation)

        return result
    }

    private fun postProcess(result: String?) {
        println("some postProcess")
    }

    private fun postTransformation(result: String?): String? {
        println("some postTransformation")
        return "postTransformation"
    }

    private fun postLogging(result: String?) {
        println("some postLogging")
    }

 

webClientCallWithLogic 메서드를 보면 기존 메서드 체이닝을 통해 구현했던 코드가 절차지향적으로 변한 것을 확인할 수 있다!

이렇게 사용할 수 있었던 이유는 해당 메서드가 코루틴에서 실행되는 suspend 메서드이고 awaitSingle() 메서드를 통해 웹클라이언트가 사용하는 이벤트루프와 코루틴을 연결했기 때문이다.

전체 흐름을 표현한 그림을 보면서 구체적인 동작방식에 대해 조금 상세하게 살펴보자.

위 그림에 표기된 1번부터 6번까지의 순서에 주목하길 바란다.

아래 그림은 위 그림에서 표기한 1번부터 6번까지의 과정이 실제 코드의 어느 부분에서 발생하는지 표현한 그림이다.

1번 로직을 수행하기 전까지는 메인스레드에서 요청을 처리하고 있다가 1번 로직을 만나면 코루틴을 만들어서 코루틴 영역에 던진다.

코루틴 영역에서 코루틴이 수행되다가 3번 영역을 만나면 webclient의 event loop에게 외부 api 호출 작업을 던진다.

이때 awaitSingle() 을 주목해야 하는데, 바로 event loop와 코루틴을 연결해 주는 역할을 하기 때문이다.

구체적으로 살펴보면, 아래와 같은 작업을 수행한다.

  1. event loop 에 io 작업을 던졌으니 해당 코루틴은 일시중단된다.(코루틴에서 사용되는 스레드를 점유하지 않음)
  2. io 작업이 완료되면 event loop로부터 시그널을 받아 코루틴을 다시 시작할 수 있는 대기 상태로 변경한다.
  3. 대기 상태로 전환된 코루틴은 디스패처를 통해 다시 실행된다.

결국 위와 같은 동작을 통해 webclient를 사용하는 영역을 최소화하고 나머지 코드를 코루틴 영역으로 대려옴으로써, non-blocking io의 이점을 누림과 동시에 함수형 코드가 무한정 증식하는것을 막을 수 있을 것으로 보인다.

다음 글에서는 java 21 의 가상스레드에대해 다뤄본다.

 

 

참고

https://techblog.woowahan.com/7349/

댓글