본문 바로가기
WEB개발/백엔드

코루틴 withTimeoutOrNull과 Webclient 에 대해서

by iks15174 2025. 4. 6.

목적

기존에 webClient를 이용해서 외부에 호출하는 메서드 A가 있었다. 이 메서드 A는 webClient에 timeout 1000ms를 걸어서 사용 중이었다. 그러다가 메서드 A의 로직을 그대로 사용하면서, timeout만 300ms로 줄이고 싶은 요구가 생겼다. 기존에 메서드 A를 다른 여러 코드에서 사용 중이었기 때문에 A 내부의 webClient의 timeout을 줄이는 건 위험도가 높아 보였다

이를 해결하기 위해 새로운 코드를 작성할 때 메서드 A를 가져다 쓰고 그 상위에 withTimeoutNull이라는 함수를 사용했다  

fun getData(): String? = runBlocking {
    withTimeoutOrNull(300) {
        delayWebClient.get()
            .uri("/delay")
            .retrieve()
            .bodyToMono(String::class.java)
            .timeout(Duration.ofMillis(1000))
            .awaitSingleOrNull()
    }
}

 

이번 글에서는 webClient 상위에 withTimeoutOrNull을 정의하면 어떤 식으로 동작하는지 알아보도록 하겠다

 

실제 동작 확인

@GetMapping
fun get(): ResponseEntity<String> {
    val start = System.currentTimeMillis()
    val response = coroutineTimeoutService.getData()
    val end = System.currentTimeMillis()
    println("execMillis: ${end - start}")
    return ResponseEntity.ok(response ?: "")
}

fun getData(): String? = runBlocking {
        withTimeoutOrNull(300) {
            delayWebClient.get()
                .uri("/delay")
                .retrieve()
                .bodyToMono(String::class.java)
                .timeout(Duration.ofMillis(1000))
                .awaitSingleOrNull()
        }
    }
/*
A서버의 코드이다
result:
execMillis: 314
*/

 

A 서버에는 webClient의 timeout을 1000ms, withTimeoutOrNull을 300ms로 걸어둔 상태이다

B 서버에서는 요청을 받으면 5000ms 동안 sleep 후 응답을 준다

위와 같은 상황에서 A 서버는 약 300ms에 응답을 준다. 즉 withTimeoutOrNull이 잘 동작하고 있다는 의미이다

그러면 어떻게 withTimeoutOrNull은 이미 진행 중이던 네트워크 요청을 끊고 정해진 timeout에 응답을 주는 것일까?

 

withTimeoutOrNull 살펴보기

withTimeoutOrNull 함수의 주석을 정리하면 아래와 같다

  1. 인자로 주어진 block코드를 코루틴 내에서 실행하고, timeout이 지나면 null을 반환한다
  2. 코루틴 내부에서 실행되는 block코드는 timeout이 되면 취소된다. 취소 시점에 block이 suspend 된 상태였다면, suspend 된 함수가 다시 실행될 때 TimeoutCancellationException을 던진다

withTimeoutOrNull 함수의 내부 구현을 정리하면 아래와 같다

  1. TimeoutCoroutine을 만든다. 
    1. TimeoutCoroutine은 ScopeCoroutine과 Runnable을 상속하고 있다. Runnable을 상속하기 때문에 run메서드를 구현해야 하는데, run메서드에서는 자신의 코루틴을 TimeoutCancellationException이라는 예외와 함께 취소하는 일을 한다
  2. 1에서 만든 TimeoutCoroutine을 예약한다. 이 예약은 TimeoutCoroutine이 인자로 주어진 timeMillis뒤에 run 하도록 하는 것이다. 즉 TimeoutCoroutine이 timeMillis뒤에 취소되도록 예약하는 것이다
  3. 인자로 받은 block을 TimeoutCoroutine 내부에서 실행시킨다

그림으로 살펴보면 구조가 대략 아래와 같다

 

인자로 전달받은 block은 TimeoutCoroutine에서 실행되면서, TimeoutCoroutine은 스케줄러에 의해 timeMillis뒤에 취소되도록 설계가 되어있다. 그러면 TimeoutCoroutine이 스케줄러에 의해 취소되면 어떤 일이 발생할까?

그건 awaitSingleOrNull 내부 코드를 보면 알 수 있다

 

awaitSingleOrNull 살펴보기

awaitSingleOrNull이 하는 일을 Mono객체를 구독하는 것이다. 구독을 하기 위해서는 subscribe메서드에 Subscriber를 구현한 객체를 넘겨줘야 하는데, 이 구현체에 우리가 궁금한 점에 대한 답이 담겨 있다

public suspend fun <T> Mono<T>.awaitSingleOrNull(): T? = suspendCancellableCoroutine { cont ->
    injectCoroutineContext(cont.context).subscribe(object : Subscriber<T> {
        private var value: T? = null

        override fun onSubscribe(s: Subscription) {
            cont.invokeOnCancellation { s.cancel() }
            s.request(Long.MAX_VALUE)
        }

 

위 코드에서 onSubscribe메서드는 구독이 완료됐을 때 호출되는 함수이다. onSubscribe메서드 내부를 보면 invokeOnCancellation을 통해 현재 코루틴이 취소됐을 경우 상위 Publisher에게 취소 요청을 보내는 기능을 등록하는 걸 확인할 수 있다

 

awaitSingleOrNull에서 suspend 되어 있던 코드가, timeMillis만큼 시간이 지나면 코루틴이 취소되고 취소된 코루틴은 invokeOnCancellation에 의해 등록된 s.cancel()을 호출한다. s.cancel은 네트워크 요청을 취소하고 awaitSingleOrNull에서 suspend 된 코루틴은 resume을 시도한다. 하지만 해당 코루틴은 이미 취소된 상태이기 때문에 TimeoutCancellationException을 던진다. 해당 예외는 withTimeoutOrNull 내부의 catch 구문에 의해 잡히고, 결국 null을 return 하게 된다

} catch (e: TimeoutCancellationException) {
    // Return null if it's our exception, otherwise propagate it upstream (e.g. in case of nested withTimeouts)
    if (e.coroutine === coroutine) {
        return null
    }
    throw e
}

 

그럼 만약 cancel 과정에서 의도적으로 delay를 준다면 어떻게 될까?

fun getData(): String? = runBlocking {
    withTimeoutOrNull(300) {
        delayWebClient.get()
            .uri("/delay")
            .retrieve()
            .bodyToMono(String::class.java)
            .timeout(Duration.ofMillis(1000))
            .doOnCancel { Thread.sleep(200) } // 추가 1
            .awaitSingleOrNull()
    }
}
/*
execMillis: 504
*/

 

위 코드의 추가.1 처럼 취소과정에서 200ms만큼 delay를 주면 실행시간도 약 200ms만큼 증가된 것을 확인할 수 있다. 코루틴이 300ms뒤에 취소되고, 코루틴을 취소하면서 동시에 네트워크 취소요청을 보냈지만 취소과정에서 200ms만큼 sleep 했기 때문에 최종적으로 500ms뒤에 반환된 것이다

취소처리가 길어지면 기대한 응답속도로 반환하지 않을 수 있음을 명심해야 한다

결론

  1. webClient(reactor)와 코루틴 조합을 사용할 때 코루틴을 이용해서 timeout을 지정해 줄 수 있다