코루틴 실행 도중 예외가 발생하면, 예외가 발생한 코루틴이 취소되고 예외가 부모 코루틴으로 전파된다.
이때 만약 예외를 전파 받은 부모 코루틴도 예외를 적절히 처리하지 않는다면 취소되고, 그 상위의 코루틴으로 예외가 전파된다.

코루틴이 예외를 전파 받아 취소되면, 취소가 해당 코루틴의 하위에 있는 자식 코루틴들에게 전파된다.
주의) root Job에서 예외를 수신 받아서, 취소를 전파하는 것이다.

코루틴3에서 예외를 발생시켰다.
예정대로라면 코루틴1,2에서 "~코루틴 실행"이라는 문구를 출력해야하는데 자식 코루틴인 코루틴3에서 예외가 발생해서 제대로 출력되지 않는 것이다.
여기에서 알 수 있는 점은, 코루틴이 예외 상태나 취소 상태가 되면 해당 코루틴의 나머지 코드는 실행되지 않는다는 것이다.
코루틴의 구조화를 깨면 예외 전파를 제한할 수 있다.
단순히 Job 객체를 새로 만들어 구조화를 깨고 싶은 코루틴에 연결하면 구조화가 깨진다.

코루틴2에 대한 예외 전파를 제한하기 위한 방법으로, Job() 함수로 Parent 코루틴을 만들어서 자식 코루틴으로 코루틴2를 만들었다.
의도한대로 코루틴2에는 예외 전파가 되지 않았다.

코루틴의 구조화가 깨지면, 예외 전파 뿐만 아니라 취소 전파도 제한된다.
parentJob.cancel()하니까 코루틴2에만 취소가 전파되었다.
하지만 이러한 방법은 구조화를 깨기 때문에 좋지 않은 방법이다.
SupervisorJob 객체는 자식 코루틴으로부터 예외를 전파 받지 않는 특수한 Job 객체이다.
→ 예외를 전파 받지 않아 자식 코루틴에서 예외가 발생하더라도 취소되지 않는다.
SupervisorJob 객체는 자식 코루틴에서 발생한 예외가 다른 자식 코루틴에게 영향을 미치지 못하게 만드는데 사용된다.
생성 함수는 Job 생성함수와 비슷하다.
public fun SupervisorJob(parent: Job? = null): CompletableJob = SupervisorJobImpl(parent)


SupervisorJob(부모Job) 함수를 통해 SupervisorJob이라는 코루틴을 생성했다.
코루틴3에서 예외가 발생하면, 코루틴2는 예외를 전파받지 않는 반면,
코루틴1은 supervisorJob이 부모 job임에도 예외전파가 된다.
분명히 supervisorJob은 예외전파를 제한한다고 했는데, 오로지 코루틴2만 예외가 전파되지 않았다.
왜 이런 일이 발생하는지 알아보자.
SupervisorJob의 특성은 "자신의 직계 자식" 코루틴 간의 예외 전파를 막는 것이기 때문이다.
코루틴1과 코루틴2는 supervisorJob의 직계 자식이므로 서로 영향을 주지 않는다.
하지만 코루틴3은 코루틴1의 자식이며, supervisorJob의 직계 자식이 아니다.
코루틴3에서 발생한 예외는 다음과 같이 전파된다.
그리고 마찬가지로 supervisorJob도 complete()를 사용하여 코루틴을 종료시켜야 한다.
SupervisorJob 객체는 CoroutineScope 생성함수와 함께 자주 사용된다.

val coroutineScope = CoroutineScope(SupervisorJob())
단일 코루틴 빌더 함수의 context 인자로 SupervisorJob 객체를 넘기고 그 하위에 자식 코루틴들을 생성할 경우, SupervisorJob 객체는 아무런 역할을 하지 못한다.

의도한 것은 Parent Coroutine이 SupervisorJob을 갖는 것인데, 코드를 잘못 입력하는 바람에
Parent Coroutine의 부모 Job이 SupervisorJob이 되어버렸다.
// 틀린 예
launch(CoroutineScope("Parent Coroutine") + SupervisorJob()) {}
// 맞는 예
val coroutineScope = CoroutineScope(SupervisorJob())
코루틴을 효율적으로 다루기 위해선, 구조의 시각화가 매우 중요하다.
supervisorScope 함수는 SupervisorJob 객체를 가진 CoroutineScope 객체를 생성한다.
즉, supervisorScope = CoroutineScope + SupervisorJob이다.
supervisorScope 함수를 통해 생성된 SupervisorJob 객체는 supervisorScope 함수를 호출한 코루틴을 부모로 가진다.
supervisorScope 함수를 통해 생성된 SupervisorJob 객체는 코드가 모두 실행되고 자식 코루틴의 실행도 완료되면 자동으로 완료된다. (complete() 따로 쓸 필요 없음)
→ 복잡한 설정 없이 구조화를 깨지 않고 예외 전파를 제한할 수 있다.

위 예제를 통해 supervisorScope의 부모 코루틴은 runBlocking이고, 코루틴2는 예외를 전달받지 않음을 알 수 있다.
• CoroutineExceptionHandler는 CoroutineContext의 구성 요소 중 하나이다.
• CoroutineExceptionHandler는 처리되지 않은 예외만 처리한다.
• CoroutineExceptionHandler는 launch 코루틴으로 시작되는 코루틴 계층의 공통 예외 처리기로 동작하는 구성요소이다.
CoroutineExceptionHandler는 코루틴에서 발생한 예외를 처리할 수 있도록 도와주는 코틀린의 인터페이스다. 주로 전역적으로 처리되지 않은 예외를 다루기 위해 사용된다. 코루틴은 기본적으로 예외를 전파하여 처리하는데, 특정 예외를 처리하는 로직이 필요할 때 CoroutineExceptionHandler를 사용한다.
사용 예시는 다음과 같다.
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught exception: ${exception.localizedMessage}")
}
GlobalScope.launch(exceptionHandler) {
throw Exception("Something went wrong")
}
위 코드는 코루틴에서 예외가 발생하면, CoroutineExceptionHandler가 그 예외를 잡아서 로그를 출력한다
하지만 CoroutineExceptionHandler는 취소되지 않은 코루틴에서만 작동한다.
예외가 발생해 코루틴이 취소된 경우에는 이를 별도로 처리해야 한다.
코루틴 외부에서 발생하는 예외는 일반적인 예외 처리 방법을 사용한다. 코틀린이나 자바에서는 예외를 try-catch 블록으로 처리한다.
try {
// 예외가 발생할 수 있는 코드
} catch (e: Exception) {
// 예외 처리
}
코루틴은 비동기 작업을 처리하는 일종의 경량 스레드이기 때문에, 코루틴 외부에서 발생하는 예외는 일반적인 방식으로 처리되며, 특별한 핸들러가 있는 것은 아니다.
코루틴 내에서 발생하는 예외는 기본적으로 코루틴의 컨텍스트나 구조적 동시성에 따라 처리된다.
GlobalScope나 CoroutineScope)에서 최종적으로 예외를 처리한다.즉, 기본적으로 코루틴의 예외는 구조적 동시성에 의해 전파되며, 상위 레벨에서 예외가 전파될 때 처리되지 않으면 코루틴 자체가 취소된다. 만약 예외를 특정하게 처리하고 싶다면 try-catch를 직접 사용하여 코루틴 내부에서 예외를 처리할 수 있다.
GlobalScope.launch {
try {
// 코루틴 내 작업
} catch (e: Exception) {
// 코루틴 예외 처리
}
}
이와 같이 일반적인 코루틴 예외는 try-catch로 직접 처리할 수 있지만, 예외를 전역적으로 처리하고 싶다면 CoroutineExceptionHandler를 사용하여 처리되지 않은 예외를 관리할 수 있다.
먼저, 예외란 프로그램이 실행되는 중에 예상치 못한 상황(에러)이 발생했을 때를 말한다.
예를 들어, 파일을 열려고 했는데 그 파일이 없을 때, 또는 숫자가 아닌 문자를 숫자로 바꾸려고 할 때가 예외다.
예외 처리는 프로그램이 중단되지 않고 정상적으로 계속 실행될 수 있도록, 이런 에러 상황에 대처하는 방법이다.
try-catch는 예외가 발생할 수 있는 코드에 대해 미리 대비해서 그 오류가 나면 처리하는 코드다.
try {
// 문제가 발생할 수 있는 코드
val number = "a".toInt() // 숫자가 아닌 문자열을 숫자로 변환 시도
} catch (e: Exception) {
// 예외 발생 시 실행될 코드
println("예외 발생: ${e.message}")
}
위 코드에서는 "a"를 숫자로 변환하려다 실패하므로, catch 블록이 실행된다.
코루틴에서는 비동기 작업을 하다 보면 예외가 발생할 수 있는데, 이 예외도 처리해야 한다.
코루틴에서는 2가지 방법으로 예외를 처리할 수 있다.
코루틴 내부에서 특정 코드에 대해 예외를 처리하는 방법이다. 예외가 발생하면 그 즉시 catch 블록이 실행된다.
GlobalScope.launch {
try {
throw Exception("코루틴에서 발생한 예외")
} catch (e: Exception) {
println("예외 처리: ${e.message}")
}
}
코루틴에서 발생하는 예외를 전역적으로 처리하고 싶을 때 사용한다.
여러 개의 코루틴에서 발생하는 예외를 한 곳에서 관리할 수 있다.
val handler = CoroutineExceptionHandler { _, exception ->
println("전역적으로 처리된 예외: ${exception.message}")
}
GlobalScope.launch(handler) {
throw Exception("전역 처리 예외")
}
예외 처리와 구조적 동시성의 관계는, 코루틴에서 발생한 예외를 자동으로 상위 스코프나 코루틴으로 전파하여 작업의 일관성, 안정성을 유지하는 것에 있다.
구조적 동시성은 코루틴에서 발생하는 모든 작업(코루틴의 시작과 종료)이 명확한 스코프 안에서 관리되도록 하고, 이 과정에서 예외가 발생하면 그 예외도 스코프 내에서 체계적으로 처리된다.
구조적 동시성에서는 코루틴이 특정 스코프(CoroutineScope) 내에서 실행되며, 그 스코프가 끝나면 그 안에서 실행된 모든 코루틴도 함께 종료된다. 이러한 구조는 비동기 작업의 생명주기를 명확하게 관리하여, 리소스 누수나 작업의 비정상 종료를 방지한다.
이 과정에서 예외가 발생할 경우, 그 예외 역시 상위 스코프로 전파되어 코루틴의 흐름에 영향을 미치며, 스코프 내에서 발생한 모든 작업이 일관되게 처리될 수 있도록 보장한다.
구조적 동시성은 코루틴의 시작과 종료를 책임지는 반면,
예외 처리는 그 과정에서 발생하는 에러를 적절하게 관리하는 역할을 한다.
두 개념이 연결되는 중요한 포인트는, 코루틴에서 예외가 발생하면 그것이 상위 스코프나 코루틴으로 자동 전파된다는 점이다. 이때 처리되지 않은 예외는 전체 스코프에 영향을 미쳐 해당 스코프에 속한 모든 코루틴이 종료되거나 취소된다.
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
val job1 = launch {
println("Job1 실행 중...")
delay(1000)
println("Job1 완료")
}
val job2 = launch {
println("Job2 실행 중...")
throw Exception("Job2에서 예외 발생") // 예외 발생
}
println("스코프 내 모든 작업 대기 중...")
}
job2에서 예외가 발생하면 그 예외는 scope.launch(상위 스코프)로 전파된다.job1)도 함께 취소된다.CoroutineExceptionHandler객체는 CoroutineExceptionHandler 생성 함수를 통해 만들 수 있다.

CoroutineExceptionHandler 은 CoroutineContext의 구성요소이기 때문에 CoroutineContext 객체에 포함될 수 있다.
따라서 CoroutineScope(context = exceptionHandler).launch(CoroutineName("Coroutine1)) {}와 같이 선언한다.
코루틴에서 예외 처리가 이루어지는 방식과 CoroutineExceptionHandler의 동작 방식은 조금 특이할 수 있다.
CoroutineExceptionHandler는 처리되지 않은 예외만 처리한다CoroutineExceptionHandler는 코루틴에서 발생한 처리되지 않은 예외를 처리하는 용도로 사용된다. 여기서 "처리되지 않은 예외"라는 말은, 예외가 발생한 코루틴이 자체적으로 예외를 처리하지 못한 경우를 의미한다.
코루틴은 기본적으로 상위 스코프(부모 코루틴)로 예외를 전파하는데, 만약 그 상위 코루틴이나 상위 스코프에서 예외를 처리하지 않고 남겨둔다면, 이 예외는 처리되지 않은 상태로 남아 있다. 이러한 경우에만 CoroutineExceptionHandler가 동작하여 해당 예외를 처리하는 것이다.
중요한 점은 CoroutineExceptionHandler는 코루틴 내에서 명시적으로 처리되지 않은 예외를 전역적으로 처리하는 역할을 하며, 코드 내부에서 try-catch 블록으로 예외를 처리한 경우나, 상위 코루틴이 해당 예외를 받아 처리한 경우에는 동작하지 않는다.
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("[예외 발생] $throwable")
}
CoroutineScope(context = exceptionHandler).launch {
throw Exception("이 예외는 처리되지 않아서, exceptionHandler가 처리함")
}
위 코드에서 launch 블록 내에서 예외가 발생하지만, 이 예외는 try-catch로 처리되지 않으므로 CoroutineExceptionHandler가 호출되어 예외를 처리하게 된다.
launch 코루틴이 다른 launch 코루틴으로 예외를 전파하면, 예외가 처리된 것으로 보기 때문에 자식 코루틴에 설정된 CoroutineExceptionHandler는 동작하지 않는다launch는 상위 코루틴으로 예외를 전파한다. 이때, 부모-자식 관계가 있는 코루틴에서 자식 코루틴에서 발생한 예외가 부모 코루틴으로 전파되면, 부모 코루틴이 그 예외를 감지하고, 자식 코루틴의 CoroutineExceptionHandler는 동작하지 않는다. 이는 코루틴 간의 예외 전파가 이루어질 때, 부모 코루틴이 자식의 예외를 처리한 것으로 간주되기 때문이다.
CoroutineExceptionHandler는 호출되지 않는다.CoroutineExceptionHandler가 동작하게 된다.val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("[예외 발생] $throwable")
}
CoroutineScope(exceptionHandler).launch(CoroutineName("Parent")) {
launch(CoroutineName("Child")) {
throw Exception("자식 코루틴에서 예외 발생")
}
}
위 코드에서 Child 코루틴에서 예외가 발생하지만, 예외는 부모 코루틴인 Parent로 전파된다.
이 경우 자식 코루틴에 CoroutineExceptionHandler를 설정해도 그 핸들러는 동작하지 않는다.
부모 코루틴에서 예외를 감지하고 처리해야 한다.
부모가 자식 코루틴의 예외를 처리하는 과정에서 자식에 설정된 예외 핸들러는 호출되지 않는다.
말이 어려우니 다시 정리해보도록 하자.
try-catch 블록으로 예외를 처리해야 한다.CoroutineExceptionHandler가 처리한다. 즉, 예외가 부모 코루틴에서 처리되지 않았을 때만 CoroutineExceptionHandler가 동작한다.CoroutineExceptionHandler가 해당 예외를 처리한다.
이 코드는 코루틴2에만 exceptionHandler가 있다.
코루틴2에서 예외가 발생했고, 코루틴1로 예외가 전파된다.
부모-자식 관계가 있는 코루틴에서 자식 코루틴에서 발생한 예외가 부모 코루틴으로 전파되면, 부모 코루틴이 그 예외를 감지하고, 자식 코루틴의 CoroutineExceptionHandler는 동작하지 않는다.
따라서 코루틴2의 exceptionHandler는 동작하지 않는다. -> 예외 발생 및 프로그램 비정상 종료

CoroutineExceptionHandler는 launch 코루틴으로 시작되는 코루틴 계층의 공통 예외 처리기이다.
위 코드와의 다른점은, 각 코루틴마다 exceptionHandler가 적용되어 있다는 것이다.
코루틴2에서 발생한 예외가 코루틴1로 전파되는 것은 동일하지만, 이 코드는 exceptionHandler가 코루틴1에도 있다는 점이 다르다.
코루틴2에서 발생한 예외는 코루틴1의 exceptionHandler가 처리하기 때문에(예외가 부모 코루틴에서 처리되지 않았을 때만 CoroutineExceptionHandler가 동작하기 때문) 예외가 발생해도 비정상 종료되지 않고 정상작동한다.



try catch 문을 사용하면 코루틴에서 발생한 예외를 처리할 수 있다.

하지만, 코루틴 빌더 함수에 try catch 문을 사용하면 예외를 처리할 수 없다.
launch 함수를 try-catch 문으로 감싸면 try-catch문은 코루틴이 잘 생성되는지만 확인한다.
async 코루틴 빌더 함수는 코루틴의 결과값을 Deferred 객체에 감싸고, await 호출 시점에 결과값을 노출한다.
만약 코루틴 실행 도중에 예외가 발생해 결과값이 없다면, Deferred에 대한 await 호출 시 예외가 노출된다.

이 코드는 만약 deferred에서 예외가 발생하면 catch 문을 통해 노출된 예외를 프린트한다.
이렇게 하면 코루틴1에서 발생한 예외가 Deferred에서 감싸져 await 호출 시점에 노출되기 때문에 코드를 실행하면 위와 같이 출력된다.
이처럼 async 코루틴 빌더를 사용할 때, await을 호출하면 예외가 노출될 수 있다.
그럼 async 코루틴 빌더는 예외 전파는 하지 않는걸까?
그건 아니다.

async 코루틴 빌더 사용 시, 가장 많이 하는 실수가 await 함수 호출부에서만 예외 처리를 하는 것이다.
async 코루틴 빌더 함수도 launch 코루틴 빌더 함수와 마찬가지로 예외가 발생하면, 부모 코루틴으로 예외를 전파한다. 따라서 async도 마찬가지로 예외를 적절히 처리해야 한다.
예외를 처리하지 않으면 루트 코루틴까지 예외가 전파되어 모든 코루틴이 취소될 수 있기 때문이다.
위 코드도 코루틴1에서 발생한 예외를 다른 곳에서 처리하지 않기 때문에, 루트 코루틴까지 예외가 전파되어, 모든 코루틴이 취소가 됨을 확인할 수 있다.

async-await도 마찬가지로 예외전파를 처리하려면 supervisorScope을 사용하면 된다.
public override fun cancel(cause: CancellationException?) {
cancelInternal(cause ?: defaultCancellationException())
}
CancellationException은 코틀린 코루틴에서 코루틴을 취소할 때 발생하는 예외로, 코루틴의 정상적인 취소 흐름을 나타내기 위한 예외다.
앱 개발 시 작업을 중단하거나 취소해야 하는 여러 경우에서 사용된다.
CancellationException은 코루틴이 정상적으로 종료되는 것으로 처리된다.
CancellationException은 코루틴의 취소에 사용되는 특별한 예외이기 때문에 부모 코루틴으로 전파되지 않는다.

앱 개발에서 CancellationException이 사용되는 경우는 무엇이 있을까?
CancellationException이 발생하며, 이를 통해 네트워크 통신을 중단하고 불필요한 리소스 사용을 방지합니다.CancellationException을 발생시키고, 비정상 종료 없이 작업을 정상적으로 중단할 수 있다.네트워크 요청이나 긴 작업에서 응답이 일정 시간 안에 도착하지 않으면 작업을 취소해야 하는 경우, withTimeout 함수를 사용해 타임아웃을 설정할 수 있다. 이때 설정된 시간이 지나면 자동으로 코루틴이 취소되며, CancellationException이 발생한다.
예시: 서버 응답이 늦어지거나 무한 루프를 방지하기 위해 타임아웃 설정.
CoroutineScope(Dispatchers.IO).launch {
try {
withTimeout(5000L) { // 5초 후 자동 취소
// 오래 걸리는 작업
}
} catch (e: CancellationException) {
Log.d("Timeout", "Task timed out and was cancelled.")
}
}
사용자가 명시적으로 작업을 취소할 수 있는 UI 요소를 제공하는 경우, 해당 작업을 취소하면 CancellationException을 통해 해당 작업을 종료할 수 있다. 이 방식은 다이얼로그 취소 버튼이나 로드 중인 작업을 중단하는 버튼 등에 사용할 수 있다.
예시: 사용자 취소 버튼을 눌렀을 때 파일 다운로드를 취소하는 경우.
val job = CoroutineScope(Dispatchers.IO).launch {
try {
// 작업 수행
} catch (e: CancellationException) {
Log.d("Cancellation", "User cancelled the task.")
}
}
// 취소 버튼 클릭 시
cancelButton.setOnClickListener {
job.cancel() // 작업 취소
}
• 코루틴에 대한 적절한 예외 처리는 안정적인 애플리케이션을 만들기 위해 매우 중요하다.
• 코루틴에서 발생한 예외는 부모 코루틴으로 전파된다.
• 예외를 전파받은 코루틴이 취소되면 해당 코루틴의 모든 자식 코루틴에 취소가 전파된다.
• 새로운 루트 Job 객체를 통해 코루틴의 구조화를 깨서 코루틴의 예외 전파를 제한 할 수 있다.
• SupervisorJob 객체를 사용해 예외 전파를 제한할 수 있다.
• supervisorScope 함수를 사용해 구조화를 깨지 않고 예외 전파를 제한 할 수 있다.
• CoroutineExceptionHandler 객체는 공통 예외 처리기로 동작하고, 이미 처리된 예외에 대해서는 동작하지 않는다.
• CoroutineExceptionHandler는 예외 전파를 제한하지 않는다.
• 코루틴 내부에서 try catch 문을 사용해 예외를 처리할 수 있다.
• 코루틴 빌더 함수에 대한 try catch문은 코루틴에서 발생한 예외를 처리하지 못한다.
• async 함수로 생성된 코루틴에서 발생한 예외는 await 호출 시 노출된다.
• async 코루틴에서 발생한 예외 또한 부모 코루틴으로 전파된다.
• CancellationException은 부모 코루틴으로 전파되지 않는다.
• CancellationException이 전파되지 않는 이유는 CancellationException이 코루틴을 취소하기 위한 특별한 예외이기 때문이다.
강의자료 출처 및 참고한 강의:
코틀린 코루틴 완전 정복 by 조세영님
https://www.inflearn.com/course/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5/dashboard