Coroutine

[Coroutine] 7. CoroutineExceptionHandler와 예외 전파

Marchbreeze 2025. 5. 20. 03:29
<코틀린 코루틴의 정석(조세영)> 도서의 "8장. 예외 처리"을 참고해서 작성된 글입니다.

 


요약

코루틴 실행 도중 예외 발생 시 취소 후 부모 코루틴으로 예외가 전파되며, 관련된 다른 자식 코루틴들도 모두 취소됩니다.

이떄 supervisorJob 또는 supervisorScope를 활용해서 자식 코루틴으로 전파되는 취소를 제한할 수 있습니다.

 

예외를 처리하는 경우, CoroutineExceptionHandler 또는 try-catch를 사용할 수 있습니다.

CoroutineExceptionHandler는 마지막으로 예외를 전파받은 지점에서 처리되지 않은 예외만 처리합니다.

try-catch로 코루틴 내부에서 예외를 처리하면, 해당 코루틴만 종료되고 부모로 전파되지 않습니다.

async 코루틴 빌더 함수는 await 호출 시점에 결과값을 노출합니다.


 

1. 코루틴의 예외 전파

코루틴 실행 도중 예외 발생 시 취소 후 부모 코루틴으로 예외가 전파되며, 관련된 다른 자식 코루틴들도 모두 취소됩니다.

  1. 예외 발생 : 코루틴에서 예외가 발생하면 해당 코루틴과 그 하위 코루틴은 모두 취소됩니다.
  2. 예외 전파 : 예외는 부모 코루틴으로 전파되며, 부모 코루틴도 취소됩니다.
  3. 전체 계층 취소 : 부모 코루틴이 취소되면 다른 자식 코루틴도 모두 취소됩니다.

 

코루틴의 구조화는 큰 작업을 연관된 작은 작업으로 나누는 방식입니다.

작은 작업에서 발생한 예외로 인해 큰 작업이 취소되면 안정성에서 문제가 생기게 되므로, 예외 전파를 제한할 필요성이 존재합니다.

 

 


 

2. 예외 전파 제한 기법

(1) 독립 Job 사용

새로운 Job 객체를 생성해 코루틴의 구조화를 깨는 방법으로 예외 전파를 제한할 수 있습니다.

+ Job()을 컨텍스트에 추가하면, 해당 코루틴이 새로운 루트가 되어 부모와 예외·취소가 분리됩니다.

 

그러나 완전 분리되어 취소 전파도 차단되므로, 중첩된 의존성 관리가 어려워집니다.

fun main() = runBlocking<Unit> {
  val parentJob = launch(CoroutineName("Parent Coroutine")) {
    launch(CoroutineName("Coroutine1") + Job()) { // Job 설정
      launch(CoroutineName("Coroutine3")) { // Coroutine3에서 예외 제거
        println("[${Thread.currentThread().name}] 코루틴 실행")
      }
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
    launch(CoroutineName("Coroutine2")) {
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
  }
  parentJob.cancel() // Parent Coroutine에 취소 요청
}
/*
// 결과:
[main @Coroutine1#3] 코루틴 실행
[main @Coroutine3#5] 코루틴 실행
*/

 

(2) SupervisorJob 사용

SupervisorJob

자식 코루틴으로부터 예외를 전파받지 않는 특수한 Job 객체입니다.

SupervisorJob 객체는 생성된 Job 객체와 같이 자동으로 완료 처리가 되지 않으며, 명시적으로 complete() 처리가 필요합니다.

// 내부 구현
public fun SupervisorJob(parent: Job? = null) : CompletableJob = 
		SupervisorJobImpl(parent)

 

루트와 자식 사이에 SupervisorJob을 추가하는 방식으로 진행됩니다.

SupervisorJob의 parent로 루트 CoroutineContext을 넣어 구조화를 지키며, 기존 루트의 자식들에 SupervisorJob를 이어줍니다.

fun main() = runBlocking<Unit> {
  // supervisorJob의 parent로 runBlocking으로 생성된 Job 객체 설정
  val supervisorJob = SupervisorJob(parent = this.coroutineContext[Job])
  
  launch(CoroutineName("Coroutine1") + supervisorJob) { // supervisorJob 연결
    launch(CoroutineName("Coroutine3")) {
      throw Exception("예외 발생")
    }
    println("[${Thread.currentThread().name}] 코루틴 실행")
  }
  
  launch(CoroutineName("Coroutine2") + supervisorJob) { // supervisorJob 연결
    println("[${Thread.currentThread().name}] 코루틴 실행")
  }
  
  supervisorJob.complete() // supervisorJob 완료 처리
}
/*
Exception in thread "main" java.lang.Exception: 예외 발생
[main @Coroutine2#2] 코루틴 실행
*/

 

(3) supervisorScope 사용

supervisorScope

함수를 호출한 코루틴의 Job 객체를 부모로 가지는 SupervisorJob 객체를 가진 CoroutineScope 객체를 생성합니다.

부모 Job으로서 SupervisorJob을 자동 구성해 자식 코루틴 예외가 동료에게 영향을 주지 않도록 합니다.

fun main() = runBlocking<Unit> {
  supervisorScope { // supervisorScope 연결
    launch(CoroutineName("Coroutine1")) {
      launch(CoroutineName("Coroutine3")) {
        throw Exception("예외 발생")
      }
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
    launch(CoroutineName("Coroutine2")) {
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
  }
}
/*
Exception in thread "main" java.lang.Exception: 예외 발생
[main @Coroutine2#3] 코루틴 실행
*/

 

 


 

3. CoroutineExceptionHandler 예외 처리

CoroutineExceptionHandler

handler를 매개 변수로 가지며, 예외가 발생했을 때 어떤 동작을 할지 입력해 예외를 처리할 수 있습니다.

// 내부 구현
public inline fun CoroutineExceptionHandler(
    crossinline handler : (CoroutineContext, Throwable) -> Unit
): CoroutineExceptionHandler

 

1. CoroutineExceptionHandler는 처리되지 않은 예외만 처리합니다.

자식 코루틴이 부모 코루틴으로 예외를 전파하면, 자식 코루틴에서는 예외가 처리된 것으로 간주합니다.

fun main() = runBlocking<Unit> {
  val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    println("exceptionHandler: ${throwable}")
  }
  launch(CoroutineName("Coroutine1") + exceptionHandler) {
    throw Exception("Coroutine1에서 발생한 throw")
  }
}
/*
Exception in thread "main" java.lang.Exception: Coroutine1에서 발생한 throw
*/
  • 예외가 전파되어, 자식 코루틴에 설정된 CoroutineExceptionHandler 는 동작하지 않습니다.

 

2. 마지막으로 예외를 전파받는 위치의 CoroutineExceptionHandler만 작동합니다.

예외를 전파하지 않고 예외 정보만 받는 SupervisorJob 객체와 함께 사용하면, 공통 예외 처리기로서 동작할 수 있습니다.

fun main() = runBlocking<Unit> {
  val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    println("[예외 발생] ${throwable}")
  }
  val supervisedScope = CoroutineScope(SupervisorJob() + exceptionHandler)
  supervisedScope.apply {
    launch(CoroutineName("Coroutine1")) {
      throw Exception("Coroutine1에 예외가 발생했습니다")
    }
    launch(CoroutineName("Coroutine2")) {
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
  }
}
/*
[예외 발생] java.lang.Exception: Coroutine1에 예외가 발생했습니다
[DefaultDispatcher-worker-2] 코루틴 실행
*/​

 

3. CoroutineExceptionHandler는 예외 전파를 제한하지 않습니다.

fun main() = runBlocking<Unit> {
  val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    println("[예외 발생] ${throwable}")
  }
  launch(CoroutineName("Coroutine1") + exceptionHandler) {
    throw Exception("Coroutine1에 예외가 발생했습니다")
  }
}
/*
Exception in thread "main" java.lang.Exception: Coroutine1에 예외가 발생했습니다
*/
  • 예외를 처리하지만 제한하지 않아, 프로세스가 비정상 종료됩니다.

 

 


 

4. try-catch 예외 처리

코루틴 내부에서 try-catch로 예외를 처리하면, 해당 코루틴만 종료되고 부모로 전파되지 않습니다.

fun main() = runBlocking<Unit> {
  launch(CoroutineName("Coroutine1")) {
    try {
      throw Exception("Coroutine1에 예외가 발생했습니다")
    } catch (e: Exception) {
      println(e.message)
    }
  }
  launch(CoroutineName("Coroutine2")) {
    delay(100L)
    println("Coroutine2 실행 완료")
  }
}
/*
Coroutine1에 예외가 발생했습니다
Coroutine2 실행 완료
*/


단, launch 자체를 감싸는 try-catch 작동하지 않습니다.

람다식의 실행은 생성된 코루틴이 Dispatcher에 의해 스레드로 분배되는 시점이므로, 반드시 block 내부에서 처리해야 합니다.

 

예외 처리 방식 비교

구분 처리 시점 적용 범위 전파 여부 장점 단점
기본 예외 전파 예외 발생 직후 범위 내 전체 전파 구조화된 동시성 일치 작은 실패가 전역 취소로 전파됨
try-catch 예외 발생 직후 해당 블록 내부 전파 차단 직관적, 원하는 부분만 국소적 처리 가능 블록 외부 처리 불가능
CoroutineExceptionHandler 코루틴이 종료될 때 범위 내 전체 전파 (마지막에 캐치) 전역 예외 처리 유용 이미 잡힌 예외는 동작하지 않음

 

 


 

5. async의 예외 처리

async 코루틴 빌더 함수는 await 호출 시점에 결과값을 노출합니다.

따라서 코루틴 실행 도중 예외가 발생해 결과값이 없으면, 실행 중이 아닌 await 호출 시점에서 예외가 노출됩니다.

fun main() = runBlocking<Unit> {
  supervisorScope {
    val deferred: Deferred<String> = async(CoroutineName("Coroutine1")) {
      throw Exception("Coroutine1에 예외가 발생했습니다")
    }
    try {
      deferred.await()
    } catch (e: Exception) {
      println("[노출된 예외] ${e.message}")
    }
  }
}
/*
[노출된 예외] Coroutine1에 예외가 발생했습니다
*/

 

따라서 await 호출부에서 예외 처리와 전파 처리가 될 수 있도록 해야 합니다.

이때 supervisorScope를 사용해 예외 전파를 제한할 수 있습니다.

fun main() = runBlocking<Unit> {
  supervisorScope {
    async(CoroutineName("Coroutine1")) {
      throw Exception("Coroutine1에 예외가 발생했습니다")
    }
    launch(CoroutineName("Coroutine2")) {
      delay(100L)
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
  }
}
/*
[main @Coroutine2#3] 코루틴 실행
*/

 

 


 

6. 전파되지 않는 CancellationException

CancellationException

취소 시그널용 예외로, 예외가 발생해도 부모 코루틴으로 전파되지 않습니다.

fun main() = runBlocking<Unit>(CoroutineName("runBlocking 코루틴")) {
  launch(CoroutineName("Coroutine1")) {
    launch(CoroutineName("Coroutine2")) {
      throw CancellationException()
    }
    println("[${Thread.currentThread().name}] 코루틴 실행")
  }
  println("[${Thread.currentThread().name}] 코루틴 실행")
}
/*
[main @runBlocking 코루틴#1] 코루틴 실행
[main @Coroutine1#2] 코루틴 실행
*/

 

 


이처럼 코루틴 예외 처리는 기본 전파 모델을 이해한 뒤, 필요에 따라 분리된 Job, SupervisorJob, supervisorScope,  CoroutineExceptionHandler, try-catch  async-await 패턴을 조합해 상황별로 안전하게 예외를 관리해야 합니다.