Q&A
코틀린 코루틴 예외처리
장동혁
9일 전
4801
여기서 발생하는 코루틴 예외처리의 문제점은 무엇일까요?
댓글 1개
관
관리자9일 전
다음 이미지 코드는 코루틴 예외처리의 작동 방식과 한계를 잘 보여줍니다. 요약하면, CoroutineExceptionHandler를 특정 코루틴에 걸어두는 방식은 예외 흐름이 어떻게 흘러가는지에 따라 기대한 대로 동작하지 않을 수 있습니다. 특히 중첩된 자식 코루틴이 있을 때 더 그렇습니다.
가장 큰 문제점
- CoroutineExceptionHandler의 역할 범위가 제한적임
- 중첩된 자식 코루틴에서 예외가 발생하거나, 예외를 try/catch로 내부에서 처리하면 핸들러가 호출되지 않습니다.
- 부모-자식 관계에서 예외가 흐르는 방식이 예측하기 어려움
- 이로 인해 5의 실행 경로가 의도대로 끝나지 않거나, 예외가 어디서 어떻게 처리됐는지 추적하기 어렵습니다.
- 구조적 동시성의 의도와 다르게 동작 가능
- 결국 “예외를 어디서 어떻게 로그/처리할지”를 명확히 하기보단, 예외 흐름이 흔들리기 쉽습니다.
왜 이런 문제가 발생하나요?
- 예외가 전파되는 경로가 단순한 상위 코루틴의 예외가 아닐 수 있습니다. 자식 코루틴이 실패해도, 부모가 이미 취소되며 예외가 어디서 핸들링되는지가 불명확해질 수 있습니다.
- CoroutineExceptionHandler는 최상위 루트에서의 예외를 기록하는 용도로 설계된 경우가 많고, 정상적인 에러 흐름 제어용으로 사용하면 오해의 소지가 있습니다.
- 중첩된 launch 내부에서 예외를 포획하지 않으면, 핸들러 호출 여부나 취소의 범위가 의도와 다를 수 있습니다.
현실적인 해결책과 권장 패턴
1) 예외를 명확한 try/catch로 관리
- 대부분의 에러는 각 코루틴 내부에서 try/catch로 포획하고, 필요 시 상위로 propagate 하거나 별도 로그를 남기는 방식이 더 명확합니다.
2) 구조적 동시성으로 예외 흐름 제어
- 부모-자식 관계에서 한 자식의 예외가 다른 자식에 영향을 미치지 않도록 하려면 supervisorScope를 활용합니다. 다만 supervisorScope도 모든 예외를 동등하게 분리하는 것은 아니니 상황에 맞게 사용해야 합니다.
3) 최상위에서의 핸들링과 로깅 분리
- 전체 흐름의 로그/알림은 CoroutineExceptionHandler를 전역적으로 한 번만 두고, 실제 비즈니스 로직의 예외는 try/catch로 처리하는 것이 더 예측 가능합니다.
예시 코드
- 예외를 try/catch로 명확히 처리하는 패턴
@Test
fun coroutineExceptionHandling_withTryCatch() = runBlocking {
try {
coroutineScope {
launch {
println("Coroutine 4: Started")
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(500)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
throw RuntimeException("Coroutine 4: Error occurred")
}
}
} catch (e: Throwable) {
println("Caught by outer catch: $e")
}
println("Coroutine 1: Completed (outer)")
}
- 예외를 전역 핸들러로 로깅하되, 에러 흐름은 try/catch로 제어하는 패턴
@Test
fun coroutineExceptionHandling_withGlobalHandler() = runBlocking {
val handler = CoroutineExceptionHandler { _, ex ->
println("Global handler: $ex")
}
try {
coroutineScope {
launch(handler) {
println("Coroutine 4: Started")
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(500)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
throw RuntimeException("Coroutine 4: Error occurred")
}
}
} catch (e: Throwable) {
println("Caught by outer catch after scope: $e")
}
println("Main: Completed")
}
- 예외를 격리시키는 패턴( Supervisor를 활용)
@Test
fun coroutineExceptionHandling_withSupervisor() = runBlocking {
val handler = CoroutineExceptionHandler { _, ex -> println("Unhandled: $ex") }
supervisorScope {
val c4 = launch(handler) {
println("Coroutine 4: Started")
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(600)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
throw RuntimeException("Coroutine 4: Error occurred")
}
// 다른 작업이 있다면 기다림
c4.join()
}
println("Top-level: Completed")
}
주요 포인트 요약
- CoroutineExceptionHandler는 전역적인 예외 로깅용으로는 유용하지만, 모든 예외를 포괄적으로 처리하는 일반적인 에러 핸들러로 사용하면 안 됩니다.
- 중첩된 자식 코루틴이 있을 때 예외 흐름은 예측하기 어렵습니다. try/catch와 join/await를 활용한 명확한 제어가 필요합니다.
- 필요 시 SupervisorJob/supervisorScope를 사용해 자식 간 실패를 격리하되, 상위 레벨에서 최종적으로 예외를 어떻게 보고/logging 할지 설계합니다.
이런 패턴들을 적용하면, 이미지의 예제처럼 예외가 발생했을 때의 동작이 더 명확해지고, "Coroutine 1: Completed" 같은 로그의 타이밍도 예측 가능해집니다. 필요하면 실제 코드 구조나 사용하는 라이브러리(Android, Kotlin Multiplatform 등)에 맞춘 예외 핸들링 설계도 함께 구체화해 드리겠습니다.