코루틴에서의 예외 처리 정리
예외 처리
코루틴에서 예외가 발생하면, 부모 코루틴으로 예외가 전파되고, 부모와 형제 코루틴이 모두 취소된다.
fun main(): Unit = runBlocking {
// A
launch {
// A-A
launch {
throw Error("A-A의 예외")
}
// A-B
launch {
println("A-B")
}
// A-C
launch {
println("A-C")
}
}
// B
launch {
println("B")
}
}
B
Exception in thread "main" java.lang.Error: A-A의 예외
A-A
에서 발생한 예외가 전파되어, 모든 코루틴[A-B, A-C, B]
이 취소된다. 다음 그림은 위 코드내 코루틴의 계층도를 나타낸다.
작업하나가 실패하더라도 다른 작업에 영향을 주지 않아야 하는 경우, 이를 처리하는 방법을 알아보자.
코루틴에서 발생한 오류를 전파시키지 않도록 하는 방법
1. SupervisorJob
SupervisorJob
를 사용하는 코루틴은 다른 코루틴에게 영향을 주지 않는다.
SupervisorJob
: 자식 코루틴들은 서로 독립적으로 동작하여, 해당 코루틴에서 발생한 예외가 부모와 형제 코루틴에게 영향을 주지 않는다.Job
: 부모와 자식 코루틴은 서로 연결되어 있어, 예외가 전파된다.
suspend fun main(): Unit = coroutineScope {
// A
launch {
println("A")
}
// B
launch {
println("B")
}
// C
val scope = CoroutineScope(SupervisorJob())
scope.launch {
try {
println("C")
throw Exception("C의 예외")
} catch (e: Exception) {
println(e)
}
}
}
A
B
C
java.lang.Exception: C의 예외
C
에서 예외가 발생하였으나, 다른 코루틴에게 영향을 주지 않았다.
사용 방법
// 1. Scope를 만들 때 SupervisorJob을 컨텍스트로 포함시키기
val scope = CoroutineScope(SupervisorJob())
scope.launch {
// ...
}
// 2. 기존 Scope에 SupervisorJob을 추가하기
launch(SupervisorJob()) {
// ...
}
부적절한 사용 : withContext(SupervisorJob())
withContext(SupervisorJob())
으로 사용하면, 의도와 달리 예외가 전파된다. 왜냐하면 withContext
은 새로운 코루틴을 만드는 것이 아니라 기존 코루틴의 context를 임시적으로 변경하는 것이기 때문이다. 다시 말해, SupervisorJob
으로 지정하여도 코루틴의 동작 방식을 바꿀 수 없기 떄문에 SupervisorJob
의 특성이 적용되지 않는다.
suspend fun analyzeData(dataList: List<Data>) =
withContext(SupervisorJob()) {
dataList.forEach { data ->
launch {
val result = analyze(data)
notifyResult(result)
}
}
}
launch
내에서 예외가 발생하면 다른 코루틴이 모두 취소된다.
2. supervisorScope
supervisorScope
내의 코루틴은 서로 독립적으로 동작하여, 예외가 발생해도 다른 코루틴에게 영향을 주지 않는다.
Scope내에서 생성되는 코루틴은 SupervisorJob
으로 동작한다.
suspend fun main(): Unit = coroutineScope {
// A
launch {
println("A")
}
// B
launch {
println("B")
}
// C
supervisorScope {
launch {
try {
println("C")
throw Exception("C의 예외")
} catch (e: Exception) {
println(e)
}
}
}
}
A
B
C
java.lang.Exception: C의 예외
3. Exception클래스가 CancellationException
를 상속받도록 하기
예시 : 값이 홀수일 때 예외를 발생시키는 코드
예외가 발생하면 부모, 자식 코루틴이 종료된다. 짝수일 때는 정상적으로 동작해야 하는데 취소되기 때문에 개선이 필요하다.
data class OddException(val value: Int) : Exception()
suspend fun main(): Unit = coroutineScope {
repeat(100) {
launch {
if (it % 2 == 1) {
throw OddException(it)
}
println(it)
}
}
}
0
2
4
Exception in thread "main" OddException(value=1)
OddException
를 CancellationException
를 상속받도록 하면, 예외는 부모로 전파되지 않고, 예외가 발생한 코루틴만 취소된다.
data class OddException(val value: Int) : CancellationException()
0
2
4
6
8
10
12
14
...
98
코루틴에서 발생한 예외를 감지하는 방법
1. try-catch, 일반적인 방법
가장 기초적인 방법으로 try-catch
를 사용하여 예외를 감지할 수 있다.
코루틴 빌더 내에서 try-catch
를 사용하면 된다.
launch {
try {
throw Exception("예외 발생")
} catch (e: Exception) {
// try 내에서 발생한 예외에 대한 대응 로직을 작성
}
}
그러나 try
문 내에 자식 코루틴이 있는 경우, 자식 코루틴에서 발생한 예외는 try-catch
로 감지할 수 없다.
try-catch로 예외를 감지할 수 없는 경우
try문 내에 자식 코루틴이 있을 때
다음 코드와 같이 내부적으로 또 다른 코루틴이 있다면 그 코루틴에서 발생하는 예외는 catch
로 감지할 수 없다. 즉, 계층 구조가 있는 코루틴에서는 try-catch
로 예외를 감지할 수 없다는 것이다.
try {
launch {
// 자식 코루틴 A
throw Exception("코루틴 A 예외")
}
// 새로운 Scope에서 코루틴 B를 실행
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
throw Exception("코루틴 B 예외")
}
} catch (e: Exception) {
// 호출되지 않음
// 자식 코루틴의 예외를 감지할 수 없다.
}
이러한 경우에는 루트 코루틴에서 CoroutineExceptionHandler
를 사용하면 자식 코루틴의 예외를 감지할 수 있다.
2. CoroutineExceptionHandler
Scope내의 코루틴에서 발생한 예외를 감지할 수 있다, 보통
launch
빌더와 함께 쓰인다
supervisorScope {
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("예외 감지: $throwable")
}
println("ROOT")
launch(exceptionHandler) {
println("job : ROOT-A")
launch {
println("job : ROOT-A-B")
launch {
println("job : ROOT-A-B-C")
supervisorScope {
launch {
throw Exception("ROOT-A-B-C의 예외")
}
}
}
}
}
launch(exceptionHandler) {
println("job : ROOT-B")
throw Exception("ROOT-B의 예외")
}
}
ROOT
job : ROOT-A
job : ROOT-B
예외 감지: java.lang.Exception: ROOT-B의 예외
job : ROOT-A-B
job : ROOT-A-B-C
예외 감지: java.lang.Exception: ROOT-A-B-C의 예외
주의 사항
예외에 대해 복구한 후 후속 작업을 해야한다면 부적합
예외가
CoroutineExceptionHandler
에서 감지되었을 때, 해당 코루틴은 이미 실패 상태로 완료된 것이다. 따라서CoroutineExceptionHandler
에서 예외를 복구하고 다음 작업을 진행하는 것은 불가능하다.
try-catch
를 사용하여 따로 처리- 예외에 대해서 대응하고 작업을 이어나가려면, 해당 코드를
try-catch
로 감싸서 예외를 직접 처리해야 한다.
- 예외에 대해서 대응하고 작업을 이어나가려면, 해당 코드를
async
에서는 try-catch
를 사용해야 한다.
다음과 같이 async
에서 발생한 예외는 CoroutineExceptionHandler
에서 감지되지 않는다.
suspend fun main(): Unit = supervisorScope {
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("Handler 예외 감지: $throwable")
}
launch(exceptionHandler) {
throw AssertionError()
}
val deferred = async(exceptionHandler) {
throw ArithmeticException()
}
try {
deferred.await()
} catch (e: Exception) {
println("catch 예외 감지: $e")
}
}
Handler 예외 감지: java.lang.AssertionError
catch 예외 감지: java.lang.ArithmeticException
핸들러에서 감지되지 않는 이유는 async
의 동작 방식의 특징 때문이다.
async
는 결과를 Deferred
객체에 담아서 반환하는데, async
에서 발생하는 예외는 이 Deferred
객체에 저장되고, await()
호출 시에 예외에 대해서 처리가 가능하다.
따라서, async
에서 발생한 예외는 CoroutineExceptionHandler
에 도달하지 않으므로, await()
를 호출하는 쪽에서 try-catch
로 예외를 처리해야 한다.
delay()
, yield()
등의 취소가능한 중단 함수는 취소될 때 CancellationException
을 발생시킨다.
다음은 50MS 동안 대기한 후에 코루틴 B
를 취소시키는 코드이다.
suspend fun main(): Unit = coroutineScope {
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("예외 감지: $throwable")
}
// A
launch {
println("A 시작, 200ms 대기")
delay(200)
println("200ms 대기 종료, A 완료")
}
// B
val b = launch {
try {
delay(100)
} catch (e: Exception) {
println("catch에서 예외 처리: $e")
}
println("B 완료")
}
delay(50)
b.cancel()
}
A 시작, 200ms 대기
catch에서 예외 처리: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@17036f98
B 완료
200ms 대기 종료, A 완료
위 코드에는 다음과 같은 문제가 있다.
B
가 취소되었음에도 불구하고B 완료
가 출력됨SupervisorJob
,supervisorScope
에서 처리되지 않음에도B
의 예외가 전파되지 않고A
가 정상적으로 동작함
문제의 원인 및 해결 방법
코루틴이 취소된다면 동작중인 delay
는 CancellationException
을 발생시키는데, catch
에서 이 예외를 잡아서 대응을 하였기 때문에 A 완료
가 출력된 것이며, 이 예외가 catch
에서 잡히면서 부모로 전파되지 않아 A
가 정상적으로 동작한 것이다.
이는 다음과 같이 정확하게 잡아야하는 예외를 감지하도록 하면 해결된다.
try {
delay(100)
} catch (e: IllegalArgumentException) {
println("catch에서 예외 처리: $e")
}
println("B 완료")
A 시작, 200ms 대기
200ms 대기 종료, A 완료
CancellationException
를 잡지 않고 정상적으로 코루틴 취소 예외가 부모 코루틴으로 전파되어 A
가 취소되며 B 완료
가 출력되지 않는다.
CoroutineExceptionHandler
에서CancellationException
는 감지하지 않는다.
CancellationException
은 코루틴이 취소 될 때 작동하는 메커니즘의 일부이므로, 오류가 아니기 때문에 굳이 이 예외를 감지하여 처리할 필요가 없다.
3. runCatching
runCatching
을 사용하여 좀 더 간결하게 예외를 감지할 수 있다.
예외가 발생하면 onFailure
함수에서 처리하며, onSuccess
함수에서는 정상적인 동작을 처리한다.
suspend fun main(): Unit = supervisorScope {
println("ROOT")
launch {
println("job : ROOT-A")
launch {
println("job : ROOT-A-B")
launch {
println("job : ROOT-A-B-C")
runCatching {
throw Exception("ROOT-A-B-C의 예외")
}.onFailure { throwable ->
println("예외 감지: $throwable")
}
}
delay(100L)
println("job : ROOT-A-B")
}
}
launch {
println("job : ROOT-B")
runCatching {
throw Exception("ROOT-B의 예외")
}.onFailure { throwable ->
println("예외 감지: $throwable")
}
}
}
ROOT
job : ROOT-A
job : ROOT-A-B-C
job : ROOT-B
예외 감지: java.lang.Exception: ROOT-B의 예외
예외 감지: java.lang.Exception: ROOT-A-B-C의 예외
job : ROOT-A-B
이 함수는 try-catch
와 유사한 역할을 하기 때문에, 자식 코루틴에서 발생하는 예외에 대해서는 감지하지 못한다.
다음 코드에서는 ROOT-A-B-C
에서 발생한 예외를 runCatching
으로 감지할 수 없으며, 예외가 전파되어 모든 코루틴이 종료된다.(CoroutineExceptionHandler
를 사용하면 된다)
runCatching {
launch {
throw Exception("ROOT-A-B-C의 예외")
}
}.onFailure { throwable ->
println("예외 감지: $throwable")
}
사용 방법
runCatching {
throw Exception("ROOT-A-B-C의 예외")
}.onFailure { throwable ->
println("예외 감지: $throwable")
}.onSuccess {
println("정상 동작")
}
// 함수 형태
inline fun <T, R> T.runCatching(block: T.() -> R): Result<R>
inline fun <R> runCatching(block: () -> R): Result<R>
위와 같이 onFailure
와 onSuccess
를 사용하면 되고, 반환 타입은 Result<R>
이다.
정리
기본적으로 코루틴에서 발생하는 예외는 부모 코루틴으로 전파되며, 이는 연결된 모든 자식 코루틴들에 영향을 미친다. 따라서, 각 코루틴에서 예외를 어떻게 처리할지를 명확히 결정하는 것이 중요하다. try-catch
는 특정 코루틴 내에서 발생한 예외를 직접 처리하는 가장 기본적인 방법이다. 반면 SupervisorJob
과 supervisorScope
는 코루틴 간 예외의 독립적 처리를 가능하게 하여, 하나의 코루틴 실패가 다른 코루틴에 영향을 미치지 않도록 한다.
CoroutineExceptionHandler
는 감지되지 못한 예외를 감지할 수 있는데, 주로 로깅, 에러 메시지 표시, 프로그램의 재시작 등에 사용된다. 하지만, CoroutineExceptionHandler
에서 예외를 복구하거나 코루틴의 실행을 계속하는 것은 불가능하다.
코루틴의 취소 메커니즘은 CancellationException
을 통해 이루어되며, 코루틴의 취소는 프로그램의 정상적인 흐름의 일부로, 별도의 처리를 요구하지 않는다.