코루틴 예외 처리 — SupervisorJob, CoroutineExceptionHandler의 동작 원리
"코루틴에서 예외가 발생하면 try-catch로 잡으면 되는 거 아닌가요? 왜 SupervisorJob이니 CoroutineExceptionHandler니 하는 것들이 따로 필요한 걸까요?"
코루틴의 예외 처리는 일반적인 try-catch와 다른 규칙을 따릅니다. 구조화된 동시성(Structured Concurrency) 때문에 예외가 부모-자식 관계를 따라 전파되기 때문입니다. 이 규칙을 모르면 "분명히 catch했는데 왜 크래시가 나지?"라는 상황을 겪게 됩니다.
예외 전파의 기본 규칙
코루틴에서 예외가 발생하면 다음 순서로 전파됩니다.
- 자식 코루틴에서 예외 발생
- 자식이 취소됨
- 예외가 부모로 전파
- 부모가 다른 모든 자식을 취소
- 부모 자신도 취소
- 부모의 부모로 전파 (루트까지 반복)
fun main() = runBlocking {
launch {
launch {
delay(100)
throw RuntimeException("자식2 실패")
}
launch {
delay(1000)
println("이 코드는 실행되지 않습니다") // 형제가 실패했으므로 취소됨
}
}
}
자식 하나의 실패가 전체 코루틴 트리를 무너뜨립니다. 이것이 구조화된 동시성의 기본 동작입니다.
launch vs async의 예외 처리 차이
launch — 예외가 즉시 전파
val scope = CoroutineScope(Job())
scope.launch {
throw RuntimeException("launch 예외")
// 예외가 즉시 부모로 전파됨
}
async — 예외가 await() 시점에 전달
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은 이미 취소될 수 있습니다.
val scope = CoroutineScope(Job())
scope.launch {
val deferred = async {
throw RuntimeException("async 예외")
}
try {
deferred.await()
} catch (e: Exception) {
println("catch 했지만...")
}
// 부모로 예외가 전파되어 이 launch도 취소될 수 있음
}
SupervisorJob — 자식의 실패를 격리
SupervisorJob은 자식의 예외가 부모나 다른 자식에게 전파되지 않도록 합니다.
val scope = CoroutineScope(SupervisorJob())
scope.launch {
delay(100)
throw RuntimeException("자식1 실패")
}
scope.launch {
delay(200)
println("자식2는 정상 실행") // 자식1의 실패와 무관하게 실행됨
}
SupervisorJob의 동작 원리
일반 Job과 SupervisorJob의 차이를 그림으로 보면 다음과 같습니다.
일반 Job:
Parent(Job)
├── Child1 ← 실패! → 부모에게 전파 → 부모가 Child2 취소
└── Child2 ← 취소됨
SupervisorJob:
Parent(SupervisorJob)
├── Child1 ← 실패! → 부모에게 전파 안 됨
└── Child2 ← 영향 없음, 계속 실행
supervisorScope
함수 내부에서 SupervisorJob 스코프를 만들 때 사용합니다.
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의 파라미터로 전달
// 잘못된 사용
scope.launch(SupervisorJob()) {
// 이 안의 자식들은 SupervisorJob의 보호를 받지 않음!
launch { throw RuntimeException() }
launch { /* 이것도 취소됨 */ }
}
launch(SupervisorJob())으로 전달하면 새로운 Job이 부모-자식 관계에서 분리되어 구조화된 동시성이 깨집니다.
// 올바른 사용
scope.launch {
supervisorScope {
launch { throw RuntimeException() }
launch { /* 이것은 정상 실행 */ }
}
}
실수 2: SupervisorJob의 직접 자식이 아닌 곳에서 기대
val scope = CoroutineScope(SupervisorJob())
scope.launch { // SupervisorJob의 직접 자식
launch { // 이것은 일반 Job의 자식
throw RuntimeException()
// 이 예외는 위의 launch(직접 자식)로 전파됨
}
launch {
// 이것도 취소됨 — SupervisorJob의 보호는 직접 자식에만 적용
}
}
SupervisorJob의 격리 효과는 직접 자식에만 적용됩니다.
CoroutineExceptionHandler — 처리되지 않은 예외 잡기
CoroutineExceptionHandler(CEH)는 처리되지 않은 예외를 마지막으로 잡아주는 안전망입니다.
val handler = CoroutineExceptionHandler { context, exception ->
println("예외 발생: ${exception.message}")
// 로그 전송, 알림 등
}
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch {
throw RuntimeException("처리되지 않은 예외")
}
// 출력: 예외 발생: 처리되지 않은 예외
CEH가 동작하는 조건
CEH는 아무 곳에서나 동작하지 않습니다.
- launch로 시작된 코루틴에서만 동작합니다 (async는 await에서 예외 전달)
- 루트 코루틴 또는 SupervisorJob의 직접 자식에 설치해야 합니다
- 자식 코루틴에 설치하면 동작하지 않습니다
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은 특별하게 취급됩니다.
val job = scope.launch {
try {
delay(Long.MAX_VALUE)
} catch (e: CancellationException) {
println("취소됨: ${e.message}")
throw e // 반드시 다시 throw해야 함!
}
}
job.cancel(CancellationException("사용자 요청"))
CancellationException의 특수 규칙은 다음과 같습니다.
- 부모에게 전파되지 않습니다 — 정상적인 취소로 간주
- CEH에 전달되지 않습니다
- catch에서 삼키면 안 됩니다 — 취소가 제대로 전파되지 않음
// 위험한 코드 — 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: 개별 작업 격리
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: 부분 실패 허용
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: 재시도 로직
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은 그 경로에서 특별 대우를 받는 예외입니다.