면접 질문 목록으로 가기
면접 질문실전 질문꼬리 질문

코루틴 예외 처리: launch vs async, SupervisorJob, 그리고 CoroutineExceptionHandler

skydovesJaewoong Eum (skydoves)||9분 소요

코루틴 예외 처리: launch vs async, SupervisorJob, 그리고 CoroutineExceptionHandler

코틀린 코루틴은 구조화된 동시성(Structured Concurrency)을 기반으로 Job 계층 구조를 통해 예외를 전파합니다. 자식 코루틴이 실패하면 예외는 부모로 전파되며, 부모는 나머지 자식을 모두 취소한 뒤 자신도 실패 상태로 전환됩니다. 이러한 동작 덕분에 동시 작업 간의 일관성이 보장되지만, 동시에 코루틴의 예외 처리가 일반적인 try/catch와는 근본적으로 다른 규칙을 따른다는 것을 의미하기도 합니다.

면접에서는 launchasync가 예외를 어떻게 다르게 처리하는지, CoroutineExceptionHandler가 실제로 언제 동작하는지, 그리고 runCatching이 코루틴 내부에서 왜 위험한지를 질문함으로써 단순히 API 사용법이 아닌 예외 전파 계약(exception propagation contract) 자체를 이해하고 있는지 검증합니다. 이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.

  • childCancelled()cancelParent()를 통해 자식에서 부모로 예외가 전파되는 흐름을 설명할 수 있습니다.
  • launchasync가 소스 코드 수준에서 미처리 예외를 어떻게 다르게 처리하는지 비교할 수 있습니다.
  • CoroutineExceptionHandler가 동작하는 조건과 동작하지 않는 조건을 구분할 수 있습니다.
  • SupervisorJob이 예외 전파를 차단하는 원리와, 자식의 미처리 예외가 어떻게 되는지 설명할 수 있습니다.
  • runCatchingCancellationException을 조용히 삼키는 문제와 이를 회피하는 방법을 파악할 수 있습니다.

예외 전파 경로

코루틴이 예외를 던지면, 런타임은 정해진 메서드 호출 체인을 따라 예외의 처리 방식을 결정합니다. 이 체인은 JobSupport.finalizeFinishingState()에서 시작되며, 모든 자식의 예외를 집계하고, 근본 원인(root cause)을 선택한 다음, cancelParent()를 호출합니다.

if (finalException != null) {
    val handled = cancelParent(finalException) || handleJobException(finalException)
    if (handled) (finalState as CompletedExceptionally).makeHandled()
}

예외를 처리할 수 있는 경로는 두 가지입니다. 첫째, cancelParent()가 부모 Job에 예외를 전파하여 부모가 스스로 취소되면 해당 예외는 처리된 것으로 간주됩니다. 둘째, 부모가 예외를 처리하지 않는 경우 handleJobException()이 폴백(fallback)으로 호출됩니다.

cancelParent()는 내부적으로 부모의 childCancelled() 메서드를 호출하며, 이 메서드가 예외 전파 여부를 결정하는 핵심 분기점 역할을 합니다.

public open fun childCancelled(cause: Throwable): Boolean {
    if (cause is CancellationException) return true
    return cancelImpl(cause) && handlesException
}

CancellationException은 부모를 취소하지 않고 true를 반환하여 조용히 흡수됩니다. 그 외 모든 예외는 cancelImpl()을 호출하여 부모를 취소 상태(cancelling state)로 전환하고, 그 실패를 모든 자식에게 전파합니다. 하나의 자식이 실패하면 coroutineScope 내의 모든 형제 코루틴이 함께 취소되는 이유가 바로 이 메커니즘 때문입니다.

면접 팁: 예외 전파 경로를 finalizeFinishingState() -> cancelParent() -> childCancelled() -> handleJobException() 순서로 설명하면 소스 코드 수준의 이해도를 보여줄 수 있습니다. 단순히 "부모에게 전파된다"라고 답하는 것보다 훨씬 깊이 있는 답변이 됩니다.

launch vs async: 서로 다른 두 가지 계약

launchasync 빌더는 서로 다른 코루틴 타입을 생성하며, 각 타입은 handleJobException()을 다르게 오버라이드합니다. 이 오버라이드가 부모에 의해 처리되지 않은 예외의 최종 행방을 결정합니다.

launchStandaloneCoroutine을 생성하며, handleJobException()을 오버라이드하여 CoroutineExceptionHandler를 호출하고 true를 반환합니다.

private open class StandaloneCoroutine(
    parentContext: CoroutineContext,
    active: Boolean
) : AbstractCoroutine<Unit>(parentContext, initParentJob = true, active = active) {
    override fun handleJobException(exception: Throwable): Boolean {
        handleCoroutineException(context, exception)
        return true
    }
}

launch 코루틴이 예외를 던지고 부모가 이를 처리하지 않으면, handleCoroutineException()이 코루틴 컨텍스트에서 CoroutineExceptionHandler를 탐색합니다. 핸들러가 존재하면 handleException()을 호출하고, 핸들러가 없으면 스레드의 uncaught exception handler로 전달되어 안드로이드에서는 앱 크래시로 이어집니다.

이 면접 질문은 구독자 전용입니다

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

구독하기
면접 질문 목록으로 가기