Spring WebFlux와 Kotlin 코루틴을 함께 사용하는 프로젝트에서, CancellationException이 어떤 의미를 가지는지, 왜 다시 던져줘야 하는지, 그리고 클라이언트가 연결을 끊었을 때 어떤 일이 벌어졌고, 팀원들과 어떤 의견들을 나누었는지 정리한 경험을 공유한다.
프로젝트에서는 각종 코루틴 로직을 runCatching 혹은 try-catch 로 감싸는 패턴을 많이 사용하고 있었다.
그러던 중 일부 API 에서 아래와 같은 에러 로그가 굉장히 많이 사내 로그 플랫폼에 남아 있는 것을 확인했다.
kotlinx.coroutines.JobCancellationException: MonoCoroutine was cancelled
다행히 이전 조직에서는 코틀린 경험으로 코루틴 취소에 대한 이해가 어느정도 있기에 CancellationException에 대한 내용과 이것을 왜 다시 던져야하는지에 대해 설파를 시작했다.
Kotlin에서 CancellationException은 코루틴이 정상적으로 중단(cancel) 되었음을 나타내는 예외이다. 이는 보통 아래와 같은 상황에서 발생한다.
withTimeout이 만료되었을 때cancel() 호출했을 때중요한 점은 CancellationException은 예외지만 오류가 아니라는 점이다. 이는 정상적인 종료 시그널로 간주되며, catch하거나 Result.failure로 감싸서 삼켜버리면 오히려 정상 흐름을 방해하게 된다.
in Code Review
Q.
CancellationException도 예외인데, 왜 잡아서 처리하지 않고 다시 던져야 하나요?A. 코루틴에서
CancellationException은 정상적인 종료 신호이기 때문이다.
이 예외를 잡아서 처리하게 되면, 하위 코루틴은 취소되지 않고 계속 동작할 수 있어, 구조적 동시성이 깨지고, 불필요한 리소스를 소모할 수 있다.Kotlin 공식 문서에서도 이 예외는 다시 throw 해야 한다고 명시하고 있다.
당시 문제가 될 수 있었던 Redis 조회 함수는 대략적으로 아래와 구조다.
suspend fun <T> getRedisValue(
key: String,
type: Type,
): T? {
return runCatching {
reactiveRedisTemplate.opsForValue().getAndAwait(key)?.let {
serializedGson.fromJson(it, type) as T
}
}.onFailure { e > Throwable
log.error(e) { "getRedisValue | $key | ${e.message}" }
}.getOrNull()
}
위 코드를 살펴보면 일반적으로는 예외가 발생하더라도 서비스 안정성을 위해 "로그만 남기고 null 을 반환하구나" 라고 생각하게된다.
하지만 이 코드는 CancellationException이 발생하더라도 내부에서 Result.failure로 감싸고, 로그를 찍고, 여전히 null을 반환한다.
상위 로직에서는 이 실패를 단순한 "캐시 미스" 혹은 예외로 인한 null 반환으로 간주하게 된다. 그러나 실제로는 사용자가 요청을 중단했거나, 코루틴이 취소되었음에도 불구하고 Redis와 후속 로직이 계속 실행되는 문제가 있었다.
Kotlin 공식 문서에서도 명시하고 있듯, CancellationException은 반드시 다시 던져야 한다.
Cancellation is always cooperative. Suspending functions should check for cancellation and throw
CancellationExceptionif needed.
— Kotlin Docs
이에 따라 프로젝트에는 다음과 같은 유틸 함수를 도입했다.
// 프로젝트에서 직접 정의한 유틸 함수
inline fun <R> runCatchingCancellable(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
}
이 함수는 일반적인 runCatching과 동일하게 동작하지만, CancellationException만큼은 그대로 다시 던져 흐름을 유지하도록 설계했다. 그리고 프로젝트 컨벤션을 통일하고, CancellationException을 안전하게 처리하는 습관을 들이기 위한 래핑 유틸로 사용 중이다.
in Code Review
Q. 왜 내부에서
runCatching을 다시 사용하지 않고,try-catch를 사용했나요?A. “
runCatching { ... }.onFailure { throw e }형태는 예외 재던지기로는 비직관적이고, IDE에서 추적도 불편하니 try-catch를 쓰는 게 낫다.”그래서 결국
runCatchingCancellable도 내부적으로try-catch로 구현했다.
여기까지는 좋았으나, 팀 코드 리뷰 중에 아래와 같은 질문을 받았다.
RestControllerAdvice 같은 곳에서 로그를 남기고 있는데, 여기에라도 남아야하지 않나요?"CancellationException을 무지성으로 잡아서 위로 던져줘야한다고만 생각했지, 이것이 왜 어떤 때는 사용자에게 전달되는 예외로 전파되고, 어떤 때는 내부에서 더 이상 진행되지 않는지까지는 생각해보지 않았다.
여기서부터 좀 깊게 파고 들어갔다.
실제로 실험을 해봤다. 아래와 같은 컨트롤러를 작성하고 테스트해본다.
@GetMapping("/test/timeout")
suspend fun timeout(): String {
return runCatchingCancellable {
withTimeout(5000) {
delay(10000) // suspend 중 5초 후 타임아웃
}
"OK"
}.getOrDefault("FAIL")
}
CancellationException이 RestControllerAdvice에 도달한다. (예외 로그도 남음)즉, 실제로 suspend 상태에서 취소가 발생하고, 클라이언트가 먼저 끊었다면, 아무 응답도 없이 종료된다. Spring Boot (정확히는 WebFlux + Netty)는 이 경우 TCP 연결이 끊겼다고 판단해, 예외 핸들러를 건너뛰게 된다.
In Code Review
Q. 클라이언트가 연결을 끊었는데 왜 예외 핸들러(RestControllerAdvice)도 타지 않고, 로그도 안 찍히나요?
A. 그건 정상적인 종료이기 때문이다.
예를 들어,WebClient,Redis,delay같은 suspend 지점에서 클라이언트가 연결을 끊으면, Netty는 해당 TCP 소켓이 닫혔다고 판단한다.
Spring은 더 이상 응답을 보낼 수 없기 때문에, 아무런 예외 핸들러도 동작시키지 않고 종료한다.즉, 이 상황에서 로그가 없다는 건 오히려 올바른 처리라는 뜻이다.
로그 시스템에 로그가 안 남는 이유도 위와 같다.
sink.error()를 호출하지 않고, 그냥 Mono.empty()처럼 처리한다(이 흐름은 MonoCoroutine.onCancelled 내부에서 확인할 수 있다.)
In Code Review
Q. 모든 CancellationException을 로그 플랫폼에 남기지 않으면 문제가 되는 부분은 없을까요?
A. 케이스에 따라 다르다.
withTimeout에서 발생할 수 있는TimeoutCancellationException은 로깅이 필요한 상황이 있을 수 있다.
예를 들어, 캐시 락 획득 실패, 외부 API 응답 지연 등은 실제 문제로 이어질 수 있으므로, catch 후 로깅하고 실패 처리를 하는 것이 합리적이다.다만 로깅 후에도 throw 해줘야 취소 전파가 올바르게 이루어진다.
suspend fun) 내부에서는 runCatchingCancellable을 사용하도록 전환.CancellationException 경우, 어떤 에러 로그도 남지 않고, 남기지 않는 것이 오히려 정상이라는 점도 공유사실 CancellationException 을 runCatching 이나 다른 방법을 통해 코틀린-코루틴에서 자체적으로 지원해주면 가장 깔끔할 것이다.
실제로 이것에 대한 굉장히 오래되고 긴 GitHub 토의가 있다.
위 이슈에서 역시 runCatching 이 CancellationException 을 잡아 버리며, 구조적 동시성을 위협할 수 있음을 지적하고 있다.
"운영 환경에서 CancellationException이 runCatching에 의해 삼켜져 취소가 지연되고 리소스 누수가 발생하는 버그가 여러 개 발생했습니다"
"We've hit multiple bugs in production where CancellationException was swallowed by runCatching, leading to delayed cancellation and resource leaks."
runCatchingCancellable과 같은 변형runCatching함수가 거의 모든 코루틴 기반 프로젝트에서 유틸로 직접 구현되고 있음
-> 이런 빈도 높은 코드 패턴이라면 표준 함수로 제공하는 것이 맞다.
"We shouldn’t need to reimplement this in every project."
사실 범용적으로 사용되는 Exception 자체를 잡아서 예외처리하는 것 자체가 안티 패턴이긴 하지만, 현업에서는 여전히 Exception 자체를 잡아서 예외 처리하는 방식을 많이 사용하고 있다.
그래서 위 이슈 내용을 보면 대부분 우리와 같이 별도의 변형된 runCatching 을 만들어서 사용한다.
어떤 프로젝트에서는 아예 runCatching 내에서 suspend 함수를 호출 금지하는 커스텀 룰을 사용하기도 하나보다.
코루틴 환경에서 CanellationException 을 제대로 전파하는 것은 굉장히 중요하다.
최근에 합류한 팀에서 이것에 대한 중요도나 필요성을 잘 모르는 분들이 있어, 코드 리뷰시 보일 때 마다 열심히 쫓아가서 설명 중이다.
아래는 그냥 기념 스샷

참고 문서