Theme:

"코루틴에서 예외가 발생하면 try-catch로 잡으면 되는 거 아닌가요? 왜 SupervisorJob이니 CoroutineExceptionHandler니 하는 것들이 따로 필요한 걸까요?"

코루틴의 예외 처리는 일반적인 try-catch와 다른 규칙을 따릅니다. 구조화된 동시성(Structured Concurrency) 때문에 예외가 부모-자식 관계를 따라 전파되기 때문입니다. 이 규칙을 모르면 "분명히 catch했는데 왜 크래시가 나지?"라는 상황을 겪게 됩니다.

예외 전파의 기본 규칙

코루틴에서 예외가 발생하면 다음 순서로 전파됩니다.

  1. 자식 코루틴에서 예외 발생
  2. 자식이 취소됨
  3. 예외가 부모로 전파
  4. 부모가 다른 모든 자식을 취소
  5. 부모 자신도 취소
  6. 부모의 부모로 전파 (루트까지 반복)
KOTLIN
fun main() = runBlocking {
    launch {
        launch {
            delay(100)
            throw RuntimeException("자식2 실패")
        }
        launch {
            delay(1000)
            println("이 코드는 실행되지 않습니다") // 형제가 실패했으므로 취소됨
        }
    }
}

자식 하나의 실패가 전체 코루틴 트리를 무너뜨립니다. 이것이 구조화된 동시성의 기본 동작입니다.

launch vs async의 예외 처리 차이

launch — 예외가 즉시 전파

KOTLIN
val scope = CoroutineScope(Job())

scope.launch {
    throw RuntimeException("launch 예외")
    // 예외가 즉시 부모로 전파됨
}

async — 예외가 await() 시점에 전달

KOTLIN
val scope = CoroutineScope(Job())

val deferred = scope.async {
    throw RuntimeException("async 예외")
}

try {
    deferred.await() // 여기서 예외가 throw됨
} catch (e: RuntimeException) {
    println("잡았다: ${e.message}")
}

하지만 주의할 점이 있습니다. async도 부모로 예외를 전파합니다. try-catch로 await()의 예외를 잡아도, 부모 Job은 이미 취소될 수 있습니다.

KOTLIN
val scope = CoroutineScope(Job())

scope.launch {
    val deferred = async {
        throw RuntimeException("async 예외")
    }
    try {
        deferred.await()
    } catch (e: Exception) {
        println("catch 했지만...")
    }
    // 부모로 예외가 전파되어 이 launch도 취소될 수 있음
}

SupervisorJob — 자식의 실패를 격리

SupervisorJob은 자식의 예외가 부모나 다른 자식에게 전파되지 않도록 합니다.

KOTLIN
val scope = CoroutineScope(SupervisorJob())

scope.launch {
    delay(100)
    throw RuntimeException("자식1 실패")
}

scope.launch {
    delay(200)
    println("자식2는 정상 실행") // 자식1의 실패와 무관하게 실행됨
}

SupervisorJob의 동작 원리

일반 Job과 SupervisorJob의 차이를 그림으로 보면 다음과 같습니다.

PLAINTEXT
일반 Job:
  Parent(Job)
  ├── Child1 ← 실패! → 부모에게 전파 → 부모가 Child2 취소
  └── Child2 ← 취소됨

SupervisorJob:
  Parent(SupervisorJob)
  ├── Child1 ← 실패! → 부모에게 전파 안 됨
  └── Child2 ← 영향 없음, 계속 실행

supervisorScope

함수 내부에서 SupervisorJob 스코프를 만들 때 사용합니다.

KOTLIN
suspend fun loadDashboard() = supervisorScope {
    val userDeferred = async { fetchUser() }
    val newsDeferred = async { fetchNews() }
    val weatherDeferred = async { fetchWeather() }

    val user = userDeferred.await()
    val news = try { newsDeferred.await() } catch (e: Exception) { emptyList() }
    val weather = try { weatherDeferred.await() } catch (e: Exception) { null }

    Dashboard(user, news, weather)
}

뉴스나 날씨 API가 실패해도 대시보드 전체가 무너지지 않습니다.

SupervisorJob 사용 시 흔한 실수

실수 1: SupervisorJob을 launch의 파라미터로 전달

KOTLIN
// 잘못된 사용
scope.launch(SupervisorJob()) {
    // 이 안의 자식들은 SupervisorJob의 보호를 받지 않음!
    launch { throw RuntimeException() }
    launch { /* 이것도 취소됨 */ }
}

launch(SupervisorJob())으로 전달하면 새로운 Job이 부모-자식 관계에서 분리되어 구조화된 동시성이 깨집니다.

KOTLIN
// 올바른 사용
scope.launch {
    supervisorScope {
        launch { throw RuntimeException() }
        launch { /* 이것은 정상 실행 */ }
    }
}

실수 2: SupervisorJob의 직접 자식이 아닌 곳에서 기대

KOTLIN
val scope = CoroutineScope(SupervisorJob())

scope.launch { // SupervisorJob의 직접 자식
    launch { // 이것은 일반 Job의 자식
        throw RuntimeException()
        // 이 예외는 위의 launch(직접 자식)로 전파됨
    }
    launch {
        // 이것도 취소됨 — SupervisorJob의 보호는 직접 자식에만 적용
    }
}

SupervisorJob의 격리 효과는 직접 자식에만 적용됩니다.

CoroutineExceptionHandler — 처리되지 않은 예외 잡기

CoroutineExceptionHandler(CEH)는 처리되지 않은 예외를 마지막으로 잡아주는 안전망입니다.

KOTLIN
val handler = CoroutineExceptionHandler { context, exception ->
    println("예외 발생: ${exception.message}")
    // 로그 전송, 알림 등
}

val scope = CoroutineScope(SupervisorJob() + handler)

scope.launch {
    throw RuntimeException("처리되지 않은 예외")
}
// 출력: 예외 발생: 처리되지 않은 예외

CEH가 동작하는 조건

CEH는 아무 곳에서나 동작하지 않습니다.

  1. launch로 시작된 코루틴에서만 동작합니다 (async는 await에서 예외 전달)
  2. 루트 코루틴 또는 SupervisorJob의 직접 자식에 설치해야 합니다
  3. 자식 코루틴에 설치하면 동작하지 않습니다
KOTLIN
val handler = CoroutineExceptionHandler { _, e -> println("CEH: ${e.message}") }

val scope = CoroutineScope(Job())

// 동작 안 함 — 자식 코루틴에 설치
scope.launch {
    launch(handler) {
        throw RuntimeException("이건 CEH에 도달 안 함")
    }
}

// 동작함 — 루트 코루틴에 설치
scope.launch(handler) {
    throw RuntimeException("이건 CEH에 도달함")
}

CancellationException — 취소는 예외가 아니다

코루틴에서 CancellationException은 특별하게 취급됩니다.

KOTLIN
val job = scope.launch {
    try {
        delay(Long.MAX_VALUE)
    } catch (e: CancellationException) {
        println("취소됨: ${e.message}")
        throw e // 반드시 다시 throw해야 함!
    }
}

job.cancel(CancellationException("사용자 요청"))

CancellationException의 특수 규칙은 다음과 같습니다.

  • 부모에게 전파되지 않습니다 — 정상적인 취소로 간주
  • CEH에 전달되지 않습니다
  • catch에서 삼키면 안 됩니다 — 취소가 제대로 전파되지 않음
KOTLIN
// 위험한 코드 — CancellationException을 삼킴
launch {
    try {
        delay(1000)
    } catch (e: Exception) { // CancellationException도 잡힘
        println("에러 무시") // 취소가 전파되지 않아 좀비 코루틴이 될 수 있음
    }
}

// 안전한 코드
launch {
    try {
        delay(1000)
    } catch (e: CancellationException) {
        throw e // 취소는 다시 전파
    } catch (e: Exception) {
        println("에러 처리: ${e.message}")
    }
}

실무 패턴 — 예외 처리 전략

패턴 1: 개별 작업 격리

KOTLIN
class NotificationService(
    private val scope: CoroutineScope
) {
    private val handler = CoroutineExceptionHandler { _, e ->
        logger.error("알림 전송 실패", e)
    }

    fun sendNotification(userId: Long, message: String) {
        scope.launch(handler) {
            // 각 알림은 독립적 — 하나 실패해도 다른 알림에 영향 없음
            val user = userRepository.findById(userId)
            emailService.send(user.email, message)
        }
    }
}

패턴 2: 부분 실패 허용

KOTLIN
suspend fun loadPage(): PageData = supervisorScope {
    val essential = async { fetchEssentialData() } // 필수
    val optional1 = async { fetchRecommendations() } // 선택
    val optional2 = async { fetchAds() } // 선택

    PageData(
        data = essential.await(), // 실패하면 전체 실패
        recommendations = runCatching { optional1.await() }.getOrDefault(emptyList()),
        ads = runCatching { optional2.await() }.getOrDefault(emptyList())
    )
}

패턴 3: 재시도 로직

KOTLIN
suspend fun <T> retryWithBackoff(
    times: Int = 3,
    initialDelay: Long = 100,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(times - 1) {
        try {
            return block()
        } catch (e: Exception) {
            if (e is CancellationException) throw e // 취소는 재시도하지 않음
        }
        delay(currentDelay)
        currentDelay = (currentDelay * factor).toLong()
    }
    return block() // 마지막 시도는 예외를 그대로 전파
}

정리

도구역할사용 시점
try-catch특정 코루틴 내부의 예외 처리예외를 복구할 수 있을 때
SupervisorJob자식 실패 격리독립적인 작업이 여러 개일 때
supervisorScope함수 내부에서 SupervisorJob 스코프 생성부분 실패를 허용할 때
CEH처리되지 않은 예외의 최종 안전망로깅, 알림 등
CancellationException정상 취소 신호절대 삼키지 말 것

코루틴의 예외 처리를 제대로 이해하려면 "예외가 어디로 전파되는가"를 항상 생각해야 합니다. SupervisorJob과 CEH는 그 전파 경로를 제어하는 도구이고, CancellationException은 그 경로에서 특별 대우를 받는 예외입니다.

댓글 로딩 중...