저번 시간에는 코루틴 context에서 사용할 수 있는 요소들 중 CoroutineName과 Dispatchers에 대해 알아보았습니다.
이번 시간에 남은 두 가지 핵심 요소인 ExceptionHandler와 Job에 대해서 자세히 살펴보겠습니다.
CoroutineExceptionHandler를 context로 가진 코루틴에서 처리되지 않은 예외가 발생했을 때, 해당 코루틴 스코프 내부에서 처리되지 않은 예외를 잡아내서 처리하는 context입니다.
CoroutineExceptionHandler의 공식 문서를 보면 다음과 같이 설명되어 있습니다.
• • •
"CoroutineExceptionHandler is a last-resort mechanism for global "catch all" behavior.
You cannot recover from the exception in the CoroutineExceptionHandler.
The coroutine had already completed with the corresponding exception when the handler is called.
Normally, the handler is used to log the exception, show some kind of error message, terminate, and/or restart the application.
If you need to handle exception in a specific part of the code, it is recommended to use try/catch around the corresponding code inside your coroutine.
CoroutineExceptionHandler는 전역적인 "모든 것을 잡는" 동작을 위한 최후의 수단 메커니즘입니다.
CoroutineExceptionHandler에서는 예외로부터 복구할 수 없습니다.
핸들러가 호출될 때 코루틴은 이미 해당 예외로 인해 완료된 상태입니다.
일반적으로 핸들러는 예외를 로깅하고, 어떤 종류의 에러 메시지를 보여주고, 애플리케이션을 종료하거나 재시작하는 데 사용됩니다.
코드의 특정 부분에서 예외를 처리해야 한다면, 코루틴 내부의 해당 코드에서 try/catch를 사용하는 것이 권장됩니다.
• • •
정리하면, CoroutineExceptionHandler는 코루틴에서 발생한 예외를 최종적으로 잡아내는 역할을 하며, 만약 특정 예외에 대해 별도의 처리가 필요하다면 CoroutineExceptionHandler가 동작하기 전에 try-catch나 runCatching을 사용해서 먼저 처리하면 됩니다.
아래 예시코드를 통해 사용방법을 알아보겠습니다.
예1) coroutineExceptionHandler를 사용한 예외처리
class CoroutineExceptionViewModel: ViewModel() {
private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("CoroutineExceptionHandler 에러 : $throwable")
}
init {
fetchData()
}
fun fetchData() {
viewModelScope.launch(exceptionHandler) {
apiCall()
}
}
private fun apiCall() {
throw Exception("에러 입니다.")
}
}
// 결과 :
// CoroutineExceptionHandler 에러 : java.lang.Exception: 에러 입니다.
apiCall()에서 발생한 예외가 별도로 처리되지 않았기 때문에, 최종적으로 CoroutineExceptionHandler가 예외를 잡아내고 있습니다.
만약 CoroutineExceptionHandler가 예외를 처리하기 전에 미리 예외를 처리하고 싶다면 다음과 같이 구현할 수 있습니다.
예2) coroutineExceptionHandler를 context로 사용하지만 try-catch 및 runcatching 사용한 예외처리
class CoroutineExceptionViewModel: ViewModel() {
private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("CoroutineExceptionHandler 에러 : $throwable")
}
init {
fetchData()
}
fun fetchData() {
viewModelScope.launch(exceptionHandler) {
try {
apiCall()
} catch (e: Exception) {
println("try-catch 에러 : $e")
}
runCatching {
apiCall()
}.onFailure { e ->
println("onFailure 에러 : $e")
}.onSuccess {
println("성공")
}
}
}
private fun apiCall() {
throw Exception("에러 입니다.")
}
}
// 결과 :
// try-catch 에러 : java.lang.Exception: 에러 입니다.
// onFailure 에러 : java.lang.Exception: 에러 입니다.
이런 식으로 try-catch나 runCatching을 통해서 예외가 CoroutineExceptionHandler까지 도달하는 것을 막을 수 있습니다.
Job은 코루틴의 수명 주기를 제어하는 context입니다.
코루틴 context는 부모로부터 상속받으며, Job을 제외한 나머지 Context Elements는 동일합니다.
하지만 Job만은 새로운 객체로 생성되어 부모-자식 관계를 형성합니다.
코드를 통해 context의 차이를 확인해보겠습니다.
fun main() = runBlocking<Unit> {
val name = CoroutineName("테스트 코루틴")
launch(name) {
val parentContext = coroutineContext
launch {
val childContext = coroutineContext
println(parentContext)
println(childContext)
println("parentContext == childContext : ${parentContext == childContext}")
}
}
}
// 결과
// [CoroutineName(테스트 코루틴), StandaloneCoroutine{Completing}@1b4fb997, BlockingEventLoop@deb6432]
// [CoroutineName(테스트 코루틴), StandaloneCoroutine{Active}@28ba21f3, BlockingEventLoop@deb6432]
// parentContext == childContext : false
결과를 보면 부모 코루틴으로부터 context를 상속받아서 CoroutineName과 Dispatcher는 같지만, 자식 코루틴은 항상 새로운 Job을 생성하고 부모 Job을 자신의 parent로 설정합니다.
이렇게 설계된 이유는 구조적 동시성 때문입니다.
구조적 동시성에 관해서는 다음 시간에 알아보겠습니다.
결과에서 Completing이나 Active처럼 실행 상태를 볼 수 있습니다. 이는 안드로이드 앱에 생명주기가 있듯이, 코루틴도 자신만의 수명주기를 가지고 있기 때문입니다.
아래는 실제 코틀린에서 Job에 대한 설명으로 가져온 수명주기 표입니다.


Job은 각각의 상태에 따라 상태 속성을 가지게 됩니다.
isActive -> 실행 중인가?
isCompleted -> 작업이 완료 되었는 가?
isCancelled -> 취소 되었는 가?
코루틴이 아직 시작되지 않고 대기 중인 상태입니다.
코루틴이 바로 시작되지 않고 사용자가 원하는 시점에 코루틴이 실행되도록 제어하고 싶은 경우에 사용할 수 있습니다.
val job = CoroutineScope(Dispatchers.Default).launch(start = CoroutineStart.LAZY) {
println("실행 대기 New 상태")
}
LAZY 말고도 DEFAULT, ATOMIC, UNDISPATCHED가 있지만, 기본적으로 바로 코루틴이 시작되는 DEFAULT 외에는 잘 사용하지 않습니다.
isActive = false
isCompleted = false
isCancelled = false
코루틴이 시작되고 작업을 수행 중인 상태입니다.
val job = CoroutineScope(Dispatchers.Default).launch {
println("작업 중...")
}
isActive = true
isCompleted = false
isCancelled = false
Active 상태는 코루틴이 실제로 무언가를 하고 있는 상태입니다.
val job = CoroutineScope.launch {
launch { delay(1000); println("자식 1 완료") }
launch { delay(2000); println("자식 2 완료") }
println("부모 작업 완료")
}
부모 코루틴의 작업은 끝났지만 자식의 작업이 완료되기를 기다리고 있는 상태입니다.
부모가 자신의 일을 끝냈다고 해서 바로 종료되면, 자식 코루틴들이 고아가 되어 관리되지 않을 수 있습니다.
Completing 상태에서 모든 자식이 완료될 때까지 기다림으로써 깔끔한 정리가 보장됩니다.
isActive = true
isCompleted = false
isCancelled = false
val job = CoroutineScope.launch {
while (true) {
println("작업 중...")
}
}
job.cancel()
job.cancel()을 통해 코루틴을 취소 중 상태로 만들 수 있습니다.
취소 중인 상태이기 때문에 작업이 완전히 끝나지 않은 상태입니다.
isActive = false
isCompleted = false
isCancelled = true
job.cancel()을 통해 취소가 완료된 상태입니다.
4번 예제 코드로는 사실 Cancelled 상태가 되지 않습니다.
그 이유는 job.cancel()을 할 때 취소 확인 시점이 없기 때문에 취소 요청만 되고 취소가 완료되지 않습니다.
취소 확인 시점을 추가하려면 suspendable 함수인 delay나 yield를 통해 취소를 완료할 수 있습니다.
val job = CoroutineScope.launch {
while (true) {
delay(1000)
println("작업 중...")
}
}
job.cancel()
delay를 통해 취소 완료 확인 시점을 추가한 뒤 코루틴이 완전히 취소되면 값은 아래와 같이 바뀝니다.
isActive = false
isCompleted = true
isCancelled = true
모든 작업이 성공적으로 끝난 최종 상태입니다.
isActive = false
isCompleted = true
isCancelled = false
SupervisorJob()은 자식 코루틴 중 하나가 실패해도 다른 자식 코루틴들과 부모에게 영향을 주지 않는 특별한 Job 입니다.
fun main() = runBlocking {
val supervisorJob = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Default + supervisorJob)
// 자식 코루틴 1
scope.launch {
delay(1000)
println("자식 1 완료")
}
// 자식 코루틴 2 - 실패함
scope.launch {
delay(500)
throw Exception("자식 2 실패!")
}
// 자식 코루틴 3
scope.launch {
delay(1500)
println("자식 3 완료") // 이 코드는 정상 실행됨!
}
delay(2000)
println("모든 작업 완료")
}
// 결과
// 자식 1 완료
// 자식 3 완료
// 모든 작업 완료
자식 2가 실패하면 원래는 모든 코루틴이 취소되는데, SupervisorJob을 사용하면 다른 형제 코루틴과 부모 코루틴이 계속 실행되도록 할 수 있습니다.
"try-catch를 사용하면 SupervisorJob을 사용하지 않아도 되지 않나?"라는 생각이 들 수 있지만, 두 방법은 사용 의도에서 차이가 있습니다.
try-catch는 개별적인 예외 처리의 성격이 더 강합니다.
SupervisorJob은 구조적으로 실패를 관리하기 위한 Job이며, 해당 스코프 내에서 실패를 관리하는 역할을 합니다.
SupervisorJob에 대해서는 구조적 동시성에서 다시 자세히 알아보겠습니다.
두 번에 걸쳐 코루틴의 주요 Context들에 대해서 알아봤습니다.
코루틴 context에 대한 내용을 표로 정리해보겠습니다.
| Context | 역할 | 주요 특징 | 사용 목적 |
|---|---|---|---|
| Dispatcher | 스레드 관리 | 코루틴이 실행될 스레드 결정 | 성능 최적화 및 적절한 스레드 배치 |
| CoroutineName | 코루틴 식별자 | 디버깅과 로깅을 위한 이름 부여 | 개발 단계에서의 추적 및 디버깅 |
| CoroutineExceptionHandler | 최종 예외 처리 | 처리되지 않은 예외를 최종적으로 잡아냄 | 로깅, 에러 메시지 표시, 앱 안정성 |
| Job | 수명주기 관리 | New → Active → Completing → Completed/Cancelled | 코루틴 제어 및 부모-자식 관계 형성 |
| SupervisorJob | 예외 무시(관리) | 자식 실패가 형제/부모에게 전파되지 않음 | 구조적 실패 관리 및 독립적 작업 실행 |
| 방식 | 특징 | 사용 목적 |
|---|---|---|
| try-catch | 개별적 예외 처리 | 구체적인 예외 상황별 대응 |
| SupervisorJob | 구조적 실패 관리 | 전역적인 실패 격리 정책 |
| CoroutineExceptionHandler | 전역 예외 처리 | 최종 안전망 및 로깅 |
이제 이러한 Context들이 어떻게 구조적 동시성이라는 코루틴의 핵심 개념과 연결되는지 다음번에 살펴보겠습니다. 코루틴의 부모-자식 관계와 생명주기 관리가 어떻게 안전하고 예측 가능한 비동기 프로그래밍을 가능하게 하는지 알아보겠습니다.
제가 작성한 글은 틀린 내용이 있을 수 있습니다.
피드백은 언제나 환영입니다!