[코루틴의 정석] - 예외 전파 제한(Chapter 8-1)
이전 글
[코루틴의 정석] async와 Deferred(Chapter5)
[코루틴의 정석] 코루틴 빌더와 Job(Chapter4-2)
[코루틴의 정석] 코루틴 빌더와 Job(Chapter4-1)
개요
우리가 사용하고, 개발하는 애플리케이션은 여러 상황에서 에러에 노출됩니다.
예외가 발생했을 때 예외가 적절히 처리되지 않으면 예측하지 못한 방향으로 동작하거나 비정상 종료될 수 있습니다.
따라서 안정적인 애플리케이션을 위해선 예외를 적절하게 처리하는 것이 중요합니다.
같은 맥락에서 비동기 작업을 수행하는 코루틴의 예외 처리 또한 중요한데요,
특히 네트워크 요청이나 데이터 베이스 작업 같은 입출력 작업과 같이 중요한 작업에서 쓰입니다.
보통 이러한 작업은 예측할 수 없는 예외가 발생할 가능성이 높아 코루틴에 대한 적절한 예외 처리는 안정적인 애플리케이션을 만드는 데 필수적입니다.
코루틴은 예외를 안전하게 처리할 수 있도록 만드는 여러 장치를 갖고 있습니다.
예외가 발생했을 때 코루틴이 어떻게 동작하는지, 그리고 어떻게 처리하는지 알아봅시다.
코루틴의 예외 전파
코루틴에서 예외가 전파되는 방식
앞선 챕터에서 코루틴의 취소 전파에 대해서 다뤘습니다.
코루틴의 취소는 부모 코루틴 -> 자식 코루틴과 같이 위에서 아래 방향으로 전파됩니다.
이와 비슷하게 코루틴에서의 예외는 자식 -> 부모로 전파됩니다.
예외가 발생한 코루틴은 취소되고 부모 코루틴으로 전파됩니다. 만약 그 부모 코루틴도 적절한 대체가 없다면 다시 상위 코루틴으로 전파되는데, 이것이 반복된다면 최상위 코루틴, 즉 루트 코루틴까지 예외가 전파될 수 있습니다.
이러한 전파는 굉장히 위험합니다.
왜냐하면 루트 코루틴이 취소된다면, 모든 코루틴에게 취소가 전파되는 최악의 상황이 나옵니다.
아래와 같이 구조화된 코루틴이 있습니다.
만약 코루틴 5에서 예외가 발생하면 어떻게 될까요?
예외는 부모로 전파되기 때문에 Coroutine2 -> Coroutine1으로 예외가 전파됩니다.
만약 그전에 예외가 적절하게 대처되면 좋겠지만, 만약 적절히 처리되지 않는다면 Coroutine1은 취소됩니다.
코루틴이 취소되면 모든 자식 코루틴에게 취소가 전파되는 특성 때문에 모든 코루틴에 취소가 전파됩니다.
즉, 코루틴의 예외 전파를 제대로 막지 못하면 구조화된 코루틴이 모두 취소될 수 있습니다.
코드를 통해 알아봅시다.
fun main() = runBlocking<Unit> {
launch(CoroutineName("Coroutine1")) {
launch(CoroutineName("Coroutine3")) {
throw Exception("예외 발생!")
}
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
launch(CoroutineName("Coroutine2")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
delay(1000L)
}
runBlocking 코루틴은 Coroutine1,2를 자식 코루틴으로 갖고, Coroutine1은 Coroutine3을 자식 코루틴으로 갖습니다.
Coroutine3에서 예외가 발생했고, 코드의 실행 결과는 어떻게 될까요?
다른 출력문 없이 예외가 발생했다는 로그만 확인할 수 있습니다.
즉 Coroutine3에서 발생한 예외가 모든 코루틴을 취소시킨 것을 알 수 있습니다.
코루틴의 구조화는 큰 작업을 연관된 작은 작업으로 나누는 방식으로 이루어집니다.
만약 작은 작업에서 발생한 예외로 인해 큰 작업이 취소된다면 안정적인 애플리케이션은 아닐 것입니다.
예외 전파 제한
Job 객체를 사용한 예외 전파 제한
Job 객체를 사용해 예외 전파 제한하기
코루틴의 예외 전파를 제한하기 위한 방법은 코루틴의 구조화를 깨는 것입니다.
앞선 챕터에서 코루틴의 구조화를 깨는 두 가지 방법을 다뤄봤었습니다.
- Job 생성 후 전달
- CoroutineScope 생성 후 전달
이코루틴은 자신의 부모 코루틴으로만 예외를 전파하는 특성을 가지고 있으므로, 구조화를 깬다면 예외가 전파되지 않습니다.
첫 번째 방법은 새로운 Job 객체를 만들어 구조화를 깨는 것입니다.
fun main() = runBlocking<Unit> {
launch(CoroutineName("Parent Coroutine")) {
launch(CoroutineName("Coroutine1") + Job()) {
launch(CoroutineName("Coroutine3")) {
throw Exception("예외 발생!")
}
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
launch(CoroutineName("Coroutine2")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
delay(1000L)
}
이 코드에서 Coroutine1은 새로운 Job 객체를 부모 Job으로 설정함으로써 Parent Coroutine 과의 구조화를 깨고 있습니다.
따라서 Coroutine3이 예외를 발생시켜도, Coroutine1은 예외가 전파되고, Parent Coroutine은 예외가 전파되진 않습니다. 대신 새롭게 만들어진 Job 객체에 예외를 전파합니다.
Parent Coroutine에는 예외가 전파되지 않아 Coroutine2는 정상적으로 실행되는 것을 확인할 수 있습니다.
Job 객체를 사용한 예외 전파 제한의 한계
Job 객체를 생성해 코루틴의 구조화를 깨는 것은 예외뿐만 아니라 취소 전파도 제한시키는데요,
작은 작업의 구조화가 깨진다면 큰 작업에 취소가 요청되더라도 작은 작업은 취소되지 않으며 이는 비동기 작업을 불안정하게 만든다.
코드를 통해 살펴보자.
fun main() = runBlocking<Unit> {
val parentJob = launch(CoroutineName("Parent Coroutine")) {
launch(CoroutineName("Coroutine1") + Job()) {
launch(CoroutineName("Coroutine3")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
}
launch(CoroutineName("Coroutine2")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
delay(20L)
parentJob.cancel()
delay(1000L)
}
Coroutine1은 더 이상 Parent Coroutine의 자식 코루틴이 아니기 때문에 취소 전파가 제한됩니다.
따라서 코드를 실행해 보면 Parent Coroutine이 취소됐음에도 Coroutine1과 Coroutin3은 정상 실행되는 것을 확인할 수 있습니다.
일반적인 흐름에서는 Parent Coroutine이 취소된다면 Coroutine1과 Coroutine3도 함께 취소 돼야 합니다.
하지만 예외 전파 방지를 위해 새로운 Job 객체를 사용해 구조화가 깨져 버려 두 코루틴은 정상 실행 됩니다.
즉 구조화를 깨지 않으면서 예외 전파를 제한할 수 있는 방법이 필요합니다.
코루틴 라이브러리는 이것을 가능하게 하기 위해 SupervisorJob 객체를 제공하고 있습니다.
SupervisorJob 객체를 사용한 예외 전파 제한
SupervisorJob 객체를 사용해 예외 전파 제한하기
SupervisorJob
자식 코루틴으로부터 예외를 전파받지 않는 특수한 Job 객체로 하나의 자식 코루틴에서 발생한 예외가 다른 자식 코루틴에게 영향을 미치지 못하도록 만드는 데 사용된다.
일반적인 Job 객체는 예외가 발생하면 예외를 전파받아 취소되지만 SupervisorJob 객체를 취소되지 않는다.
SupervisorJob 객체도, Parent 인자 없이 사용하면 루트 Job으로 만들 수 있으며 parent 인자를 넘기면 부모 Job이 있는 SupervisjorJob 객체를 만들 수 있다.
코드를 통해 살펴보자.
fun main() = runBlocking<Unit> {
val supervisorJob = SupervisorJob()
launch(CoroutineName("Coroutine1") + supervisorJob) {
launch(CoroutineName("Coroutine3")) {
throw Exception("예외 발생!!")
}
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
launch(CoroutineName("Coroutine2") + supervisorJob) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
코루틴 1의 부모 Job을 supervisorJob으로 전달한 코드입니다.
그렇다면 코루틴 3의 에러가 코루틴 1로 전파돼 코루틴 1을 취소시키지만, 코루틴 1은 supervisorJob으로 예외를 전파하지 않습니다.
따라서 코드를 실행해 보면 supervisorJob의 다른 자식 코루틴인 Coroutine2 코루틴이 정상 실행되는 것을 볼 수 있습니다.
SupervisorJob 객체는 자식 코루틴의 예외를 전파받지 않는 특성을 가지고 있습니다.
하지만 위 구조화의 그림처럼 SupervisorJob 객체는 runBlocking이 호출돼 만들어진 Job 객체와의 구조화를 깨는 점이 문제입니다.
코루틴의 구조화를 깨지 않고 SupervisorJob 사용하기
구조화를 깨지 않고 SupervisorJob을 사용하기 위해서는 인자로 부모 Job 객체를 넘기면 됩니다.
fun main() = runBlocking<Unit> {
val supervisorJob = SupervisorJob(parent = this.coroutineContext[Job])
launch(CoroutineName("Coroutine1") + supervisorJob) {
launch(CoroutineName("Coroutine3")) {
throw Exception("예외 발생!!")
}
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
launch(CoroutineName("Coroutine2") + supervisorJob) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
supervisorJob.complete()
}
SupervisorJob()을 통해 생성된 Job 객체는 Job()을 통해 생성된 객체와 같이 자동으로 완료 처리가 되지 않기 때문에 명시적 완료 처리인 complete를 호출해 완료 처리를 해줘야 합니다.
위 코드처럼 supervisorJob의 인자로 부모 Job을 넘겨주면, runBlocking 코루틴과의 구조화를 깨지 않습니다.
SupervisorJob을 CoroutineScope와 함께 사용하기
CoroutineScope의 CoroutineContext에 SupervisorJob 객체가 설정된다면 CoroutineScope의 자식 코루틴에서 발생하는 예외가 다른 자식 코루틴으로 전파되지 않습니다.
fun main() = runBlocking<Unit> {
val coroutineScope = CoroutineScope(SupervisorJob())
coroutineScope.apply {
launch(CoroutineName("Coroutine1")) {
launch(CoroutineName("Coroutine3")) {
throw Exception("예외 발생!!")
}
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
launch(CoroutineName("Coroutine2")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
위 코드에서는 CoroutineScope 생성 함수의 SupervisorJob()을 인자로 넘겨 CoroutineScope 객체가 생성됩니다.
하지만 새로운 CoroutineScope의 생성으로 runBlocking 코루틴과의 구조화는 깨지게 됩니다.
코드를 실행해 보면 예외가 발생했음에도 코루틴 2가 정상 실행되는 것을 확인할 수 있습니다.
SupervisorJob을 사용할 때 흔히 하는 실수
예외 전파 방지를 위해 코루틴 빌더 함수의 context 인자에 SupervisorJob()을 넘겨 생성되는 코루틴의 하위에 자식 코루틴을 생성하는 것은 큰 문제를 내포하고 있습니다.
fun main() = runBlocking<Unit> {
launch(CoroutineName("Parent Coroutine")+ SupervisorJob()) {
launch(CoroutineName("Coroutine1")) {
launch(CoroutineName("Coroutine3")) {
throw Exception("예외 발생!")
}
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
launch(CoroutineName("Coroutine2")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
delay(1000L)
}
겉보기에는 문제가 없어 보이지만, launch 함수는 context 인자에 Job 객체가 입력될 경우 해당 Job 객체를 부모로 하는 새로운 Job 객체를 새로 생성합니다.
즉, launch 함수에 SupervisorJob()을 인자로 넘기면 SupervisorJob()을 통해 만들어지는 새로운 Job 객체가 만들어져 다음과 같은 구조가 형성됩니다.
만약 이런 구조에서 코루틴 3에 예외가 발생하면 어떻게 될까요?
코루틴 3에서 발생한 예외가 코루틴 1을 통해 부모 코루틴까지 전파돼 취소되며, 자식 코루틴인 코루틴 2도 취소됩니다.
부모 코루틴의 예외가 SupervisorJob 객체로 전파되지는 않지만 이는 아무런 쓸모가 없습니다.
이렇게 SupervisorJob 객체는 예외 전파를 방지하는 강력한 도구이지만, 잘못 사용하면 기능을 제대로 수행하지 못할 수 있습니다.
따라서 Job 계층 구조를 정확하게 파악하고, 어떤 위치에 있어야 하는지 숙지한 후 사용해야 합니다.
supervisorScope를 사용한 예외 전파 제한
예외 전파의 세 번째 방법은 supervisorScope 함수를 사용하는 것입니다.
해당 함수는 SupervisorJob 객체를 가진 CoroutineScope 객체를 생성합니다.
즉 내부 구현으로 CoroutineScope에 SupervisorJob를 인자로 넘겨 복잡한 설정 없이 구조화를 깨지 않고 예외 전파를 제한하는 CoroutineScope를 생성할 수 있습니다. 또한 명시적 완료 호출 없이 자식 코루틴이 모두 실행 완료되면 자동으로 완료 처리됩니다.
fun main() = runBlocking<Unit> {
supervisorScope {
launch(CoroutineName("Coroutine1")) {
launch(CoroutineName("Coroutine3")) {
throw Exception("예외 발생!")
}
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
launch(CoroutineName("Coroutine2")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
}
}
위 코드는 runBlocking 함수에 의해 루트 Job 객체가 생성되고, 자식 코루틴으로 SupervisorJob 객체를 가집니다.
그 아래에 코루틴 1, 코루틴 2, 코루틴 3을 자식 코루틴으로 가집니다.
이 경우 만약 코루틴 3에서 예외가 발생한다면 코루틴 1까지만 예외가 전파됩니다.
따라서 코드 실행 결과 코루틴 2가 정상적으로 실행되는 것을 확인할 수 있습니다.
이렇게 코루틴의 예외 전파를 제한하는 세 가지 방법을 알아봤습니다.
- Job 생성
- SupervisorJob
- SupervisorScope
각각의 장점이 있음로 상황에 맞게 고민하고 사용하는 것이 정답이라고 생각합니다.
이번 글에서는 예외 전파를 제한 하는 방법에 대해 알아봤다면, 다음 글에서는 예외를 처리하는 방법을 다루겠습니다.
참고
https://product.kyobobook.co.kr/detail/S000212376884
코틀린 코루틴의 정석 | 조세영 - 교보문고
코틀린 코루틴의 정석 | 많은 개발자들이 어렵게 느끼는 비동기 프로그래밍을 다양한 시각적 자료와 이해하기 쉬운 설명을 통해 누구나 쉽게 이해할 수 있도록 설명한다. 안드로이드, 스프링 등
product.kyobobook.co.kr