아티클 목록으로 가기

코루틴에서의 CancellationException 심층 분석

skydovesJaewoong Eum (skydoves)||15분 소요

코루틴에서의 CancellationException 심층 분석

코틀린 코루틴은 구조화된 동시성(structured concurrency)을 핵심 원칙으로 도입하여, 코루틴이 부모 스코프의 생명주기에 맞게 적절히 관리되고 취소되도록 보장합니다. 이 메커니즘의 중심에는 CancellationException이라는 특수한 예외가 존재하며, 취소 신호를 전달하는 역할을 하기 때문에 반드시 신중하게 다루어야 합니다. 대부분의 개발자가 이 예외를 잡으면 안 된다는 사실은 알고 있지만, 더 근본적인 질문이 남아 있습니다. 왜 CancellationException은 특별한 취급을 받아야 하며, 실수로 이 예외를 삼켜 버리면 어떤 일이 벌어질까요?

이 글에서는 CancellationException의 내부 메커니즘을 깊이 있게 살펴봅니다. 왜 반드시 재전파(re-throw)해야 하는지, runCatching이 어떻게 구조화된 동시성을 파괴할 수 있는지, 더 안전한 대안을 위한 제안들은 무엇인지, 그리고 취소 전파를 정확하면서도 효율적으로 만드는 설계 결정까지 폭넓게 다룹니다. 코루틴을 사용하시는 모든 안드로이드 및 코틀린 개발자에게 실질적으로 도움이 되는 내용이니, 끝까지 읽어보시길 권장합니다.

핵심 문제: 취소를 잡으면 구조화된 동시성이 무너집니다

다음의 코드를 한번 살펴보겠습니다. 언뜻 보기에는 전혀 문제가 없어 보입니다.

suspend fun processData(): Result<Data> = runCatching {
    val user = fetchUser()
    val profile = fetchProfile(user.id)
    Data(user, profile)
}

suspend 함수 내에서 runCatching을 사용하여 예외를 Result 값으로 변환하고, 보다 안전하게 에러를 처리하려는 의도입니다. 하지만 여기에는 매우 미묘한 버그가 숨어 있습니다. fetchUser()fetchProfile() 실행 도중 코루틴이 취소되면, CancellationExceptionrunCatching에 의해 잡혀 Result.failure()로 감싸집니다. 취소 신호가 부모 스코프로 전파되지 않아 구조화된 동시성이 깨지는 것입니다.

핵심 원인은 runCatching의 내부 구현에 있습니다.

public inline fun <R> runCatching(block: () -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

catch (e: Throwable) 절에 주목해야 합니다. 이 코드는 CancellationException을 포함한 모든 예외를 잡아버립니다. 취소가 발생하면 코루틴 계층 구조를 따라 위로 전파되어야 하는데, 대신 Result 객체에 갇히게 되고 코루틴은 아무 일도 없었던 것처럼 계속 실행됩니다. 실무에서 이런 실수를 하면 디버깅하기 매우 어려운 문제로 이어질 수 있으므로 각별히 유의해야 합니다.

CancellationException 이해하기: 단순한 예외가 아닙니다

CancellationException은 코틀린 코루틴에서 다른 예외와 근본적으로 다른 성격을 가집니다. 먼저 정의를 살펴보겠습니다.

public actual open class CancellationException(
    message: String?,
    cause: Throwable?
) : IllegalStateException(message, cause)

IllegalStateException을 상속하지만, 에러를 알리기 위한 것이 아니라 의도적인 취소를 알리기 위한 예외입니다. 이 차이를 정확히 이해하는 것이 CancellationException을 올바르게 다루는 출발점입니다.

취소 계약(Cancellation Contract)

코루틴이 취소될 때 내부적으로 다음과 같은 단계를 거칩니다.

  1. 취소 신호 전달: 부모 스코프 또는 Job이 코루틴의 Job에 대해 cancel()을 호출합니다.
  2. CancellationException 발생: 다음 suspend 지점(suspension point)에서 코루틴이 CancellationException을 throw합니다.
  3. 전파: 예외가 코루틴 계층 구조를 따라 위로 전파됩니다.
  4. 정리 작업: 체인의 각 코루틴이 finally 블록에서 리소스 정리 로직을 실행할 수 있습니다.
  5. 부모 통지: 부모 스코프에 자식 코루틴이 취소로 인해 완료되었다는 사실이 통지됩니다.

만약 CancellationException을 잡고 재전파하지 않으면 3~5단계가 절대 실행되지 않습니다. 부모 스코프는 자식이 아직 실행 중인 것으로 인식하고, 리소스 정리가 이루어지지 않을 수 있으며, 구조화된 동시성이 보장하는 모든 안전장치가 무력화됩니다. 이 점을 반드시 기억해야 합니다.

투명성 원칙(Invisibility Principle)

CancellationException은 코루틴 라이브러리 내부에서 다음과 같이 특별 취급됩니다.

// kotlinx.coroutines 소스 코드에서 발췌
internal fun Throwable.isCancellation(): Boolean =
    this is CancellationException

// 취소 예외는 예외 핸들러를 트리거하지 않음
internal fun handleCoroutineException(context: CoroutineContext, exception: Throwable) {
    if (exception is CancellationException) return // 정상적인 취소
    // ... 그 외 예외 처리
}

코루틴이 CancellationException과 함께 완료되면 이는 에러로 취급되지 않습니다. CoroutineExceptionHandler가 이 예외를 수신하지 않고, 크래시 리포팅 도구에도 기록되지 않으며, 부모 Job도 이로 인해 실패하지 않습니다. 이것이 바로 투명성 원칙입니다. 즉, 취소는 예외적인 상황이 아니라 정상적인 제어 흐름 메커니즘인 것입니다.

그런데 이 원칙은 CancellationException이 정상적으로 전파될 때만 올바르게 동작합니다. 만약 이 예외를 잡아 버리면, 투명해야 할 신호가 눈에 보이는 상태로 전환되어 전체 추상화가 무너지게 됩니다.

runCatching 문제: 표준 라이브러리 코드가 코루틴 계약을 깨뜨리는 경우

runCatching의 근본적인 문제는 이것이 코루틴 전용 함수가 아닌 표준 라이브러리 함수라는 데 있습니다. 코루틴이 안정화되기 전에 설계되었으며, suspend 여부와 관계없이 모든 () -> R 블록에서 동작합니다.

// suspend 함수와 일반 함수 모두에서 동작
val result1: Result<String> = runCatching { "Hello" }
val result2: Result<String> = runCatching { suspendFunction() }

이 이중적 특성이 문제를 만듭니다. 일반(비-suspend) 코드에서는 모든 Throwable을 잡는 것이 합리적입니다. 하지만 suspend 코드에서 CancellationException까지 잡는 것은 위험합니다.

GitHub 이슈 논의

kotlinx.coroutines issue #1814에서 바로 이 문제가 논의되었습니다. 원래 제안은 새로운 함수를 도입하는 것이었습니다.

public inline suspend fun <R> runSuspendCatching(block: () -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (c: CancellationException) {
        throw c  // CancellationException은 반드시 재전파
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

이 변형 함수는 CancellationException을 명시적으로 재전파하면서 나머지 예외만 잡습니다. 구현 방식도 직관적입니다. 일반적인 Throwable catch 앞에 CancellationException 전용 catch 절을 추가하면 됩니다.

기존 runCatching을 수정하지 않는 이유

runCatching 자체를 수정하여 CancellationException을 재전파하면 되지 않을까 하는 의문이 들 수 있습니다. 하위 호환성(backwards compatibility) 때문에 그렇게 할 수 없습니다. 현재 동작에 의존하는 기존 코드가 존재할 수 있기 때문입니다.

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기