요약: 코틀린 코루틴을 사용할 때, 특정 코루틴에서 발생한 예외가 전체 코루틴 계층에 전파되어 시스템이 불안정해질 수 있습니다. 이 글에서는 구조화된 동시성 원칙을 바탕으로 코루틴 예외 전파와 취소의 동작 원리를 설명하고, 안전하게 예외를 처리할 수 있는 다양한 방법을 소개합니다.
💡 리뷰어 한줄평
cdragon.cd 코드를 작성할 때 성공 케이스에 대한 로직만큼이나 실패 케이스에 대한 안전한 예외처리는 필수입니다. 구조화된 동시성을 지원하는 코틀린 코루틴에서는 어떻게 예외를 핸들링할 수 있을까요? 반복적으로 try-catch를 작성하고 있었다면 제이코의 글을 통해 더 유려한 예외처리로 리팩토링 해보세요!
greg.ss 코틀린 코루틴의 열렬한 지지자, 제이코가 예외 처리에 대해 소개하는 글입니다. 예제와 함께 올바른 방법을 학습하고 자신의 코드를 점검해 보면 어떨까요?
시작하며
안녕하세요, 카카오페이 크레딧클랜에서 대출 플랫폼을 개발하고 있는 제이코입니다. 여러분은 코틀린 코루틴을 사용할 때, 특정 코루틴에서 발생한 예외가 다른 코루틴에 영향을 미쳐 전체 시스템이 불안정해진 경험이 있으신가요? 코루틴은 비동기 프로그래밍의 복잡성을 줄이고 효율적인 동시성을 제공하는 강력한 도구입니다. 하지만 예외 전파와 취소 과정을 제대로 이해하지 못하고 사용하면 전체 시스템이 불안정해질 위험이 있습니다.
이번 글에서는 코루틴을 사용할 때 발생할 수 있는 예외로 인한 취소 문제와 이를 해결하기 위한 다양한 방법들을 소개하려고 합니다. 구조화된 동시성 원칙을 중심으로 예외가 어떻게 전파되고, 그로 인해 다른 코루틴에 어떤 영향을 미치는지 구체적인 예제를 통해 살펴볼게요. 이 글이 코루틴을 처음 접하는 분들뿐만 아니라, 실무에서 코루틴을 활용해 안정적이고 신뢰할 수 있는 시스템 설계를 고민하는 모든 분들에게 도움이 되기를 기대합니다.
예외 처리의 중요성
코루틴은 구조화된 동시성 (Structured Concurrency) 원칙을 따릅니다. 이 원칙에 따라 코루틴의 생명 주기와 실행 흐름은 부모-자식 관계로 체계적으로 관리됩니다. 부모 코루틴은 자식 코루틴의 생명 주기를 책임지며, 모든 자식 코루틴이 완료되어야 부모 코루틴도 완료될 수 있습니다. 구조화된 동시성 원칙은 예외 처리에도 중요한 영향을 미칩니다. 자식 코루틴에서 예외가 발생하면 해당 예외는 부모 코루틴으로 전파됩니다. 이를 통해 오류를 체계적으로 관리할 수 있지만, 부모 코루틴이 예외를 적절히 처리하지 못할 경우 전체 코루틴 계층이 취소될 수 있습니다.
예외 전파 및 취소의 기본 원리는 다음과 같습니다.
- 예외 발생: 코루틴에서 예외가 발생하면 해당 코루틴과 그 하위 코루틴은 모두 취소됩니다.
- 예외 전파: 예외는 부모 코루틴으로 전파되며, 부모 코루틴도 취소됩니다.
- 전체 계층 취소: 부모 코루틴이 취소되면 다른 자식 코루틴도 모두 취소됩니다.
다음 예제를 통해 코루틴의 예외 전파 및 취소 동작을 살펴보겠습니다.
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
class CoroutineExceptionsHandling {
@Test
fun coroutines() {
// Coroutine 1
runBlocking {
println("Coroutine 1: Started")
// Coroutine 2
launch {
println("Coroutine 2: Started")
delay(500)
println("Coroutine 2: Completed")
}
// Coroutine 3
launch {
println("Coroutine 3: Started")
delay(500)
println("Coroutine 3: Completed")
}
// Coroutine 4
launch {
println("Coroutine 4: Started")
// Coroutine 5
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(500)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
throw RuntimeException("Coroutine 4: Error occurred")
}
delay(500)
println("Coroutine 1: Completed")
}
}
}
실행 결과를 보면, 코루틴 4에서 발생한 예외로 인해 모든 코루틴이 취소된 것을 확인할 수 있습니다.
Coroutine 1: Started
Coroutine 2: Started
Coroutine 3: Started
Coroutine 4: Started
Coroutine 5: Started (Nested in Coroutine 4)
Coroutine 4: Error occurred
java.lang.RuntimeException: Coroutine 4: Error occurred
at ...
이제 어떤 과정을 거쳐 전체 코루틴이 취소되었는지 살펴볼게요. 아래 그림은 각 코루틴 간의 부모-자식 관계를 나타냅니다.
우선, 코루틴 4의 예외 발생으로 인해 코루틴 4가 취소됩니다.
코루틴 4의 취소로 인해, 코루틴 4의 자식인 코루틴 5 역시 취소됩니다.
코루틴 4에서 발생한 예외는 코루틴 4의 부모 코루틴인 코루틴 1에게 전파되고, 코루틴 1 역시 취소됩니다.
코루틴 1의 취소로 인해, 코루틴 1의 자식인 코루틴 2와 코루틴 3 역시 취소됩니다.
이처럼 예외를 적절히 처리하지 않으면 예외가 전파되어 전체 코루틴이 취소될 위험이 있습니다. 이를 방지하려면 예외 처리 방식을 정확히 이해하고 올바르게 적용해야 시스템의 안정성을 유지할 수 있습니다. 그렇다면 코루틴에서 예외를 안전하게 처리하려면 어떻게 해야 할까요?
예외 처리 방법
예외를 처리하기 위해 다음과 같은 방법을 시도해 볼 수 있습니다.
- CoroutineContext에 CoroutineExceptionHandler 설정
- 코루틴 빌더에 대한 예외 처리
- 예외 발생 지점에 대한 예외 처리
- Job을 사용해 부모-자식 관계 끊기
- SupervisorJob을 사용해 예외 전파 차단
- supervisorScope을 사용해 예외 전파 차단
이제 각 방법이 예외를 처리하는 데 있어 적합한 선택인지 하나씩 살펴보겠습니다.
CoroutineExceptionHandler ( ❌ )
코루틴 4를 생성하는 코루틴 빌더인 launch의 CoroutineContext에 CoroutineExceptionHandler를 설정하여, 코루틴 4에서 발생한 예외가 전파되지 않도록 해보겠습니다.
@Test
fun coroutineExceptionHandler() {
// Coroutine 1
runBlocking {
...
val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
println(throwable)
}
// Coroutine 4
launch(coroutineExceptionHandler) {
println("Coroutine 4: Started")
// Coroutine 5
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(500)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
throw RuntimeException("Coroutine 4: Error occurred")
}
delay(500)
println("Coroutine 1: Completed")
}
}
실행 결과를 보면, 여전히 모든 코루틴이 취소되는 것을 확인할 수 있습니다. 이는, CoroutineExceptionHandler는 예외를 처리하거나 로깅하는 데는 유용하지만, 예외가 부모 코루틴으로 전파되는 것을 차단하는 기능은 없기 때문입니다. 따라서 이 방법은 적절한 해결책이 아닙니다.
Coroutine 1: Started
Coroutine 2: Started
Coroutine 3: Started
Coroutine 4: Started
Coroutine 5: Started (Nested in Coroutine 4)
Coroutine 4: Error occurred
java.lang.RuntimeException: Coroutine 4: Error occurred
at ...
코루틴 빌더에 대한 예외 처리 ( ❌ )
코루틴 4에서 발생하는 예외를 차단하기 위해, 코루틴 4를 생성하는 launch를 try-catch로 감싸 예외를 처리해 보겠습니다.
@Test
fun tryCatchForCoroutineBuilder() {
// Coroutine 1
runBlocking {
...
// Coroutine 4
try {
launch {
println("Coroutine 4: Started")
// Coroutine 5
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(500)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
throw RuntimeException("Coroutine 4: Error occurred")
}
} catch (e: Exception) {
println(e)
}
delay(500)
println("Coroutine 1: Completed")
}
}
실행 결과를 보면, 여전히 모든 코루틴이 취소되는 것을 확인할 수 있습니다. 이는 launch 코루틴 빌더를 감싼 try-catch 블록이 코루틴 생성에 대한 예외만 처리할 뿐, 생성된 코루틴이 실제로 실행된 후에 발생하는 예외는 처리하지 못하기 때문입니다. 따라서 이 방법 역시 적절한 해결책이 아닙니다.
Coroutine 1: Started
Coroutine 2: Started
Coroutine 3: Started
Coroutine 4: Started
Coroutine 5: Started (Nested in Coroutine 4)
Coroutine 4: Error occurred
java.lang.RuntimeException: Coroutine 4: Error occurred
at ...
예외 발생 지점에 대한 예외 처리 ( 🤔 )
코루틴 내부에서 예외가 발생할 수 있는 코드 블록을 try-catch로 감싸 처리할 수 있습니다. 이는 예외가 발생한 코루틴이 다른 코루틴에 영향을 미치치 않도록 하는 가장 간단한 방법입니다.
@Test
fun tryCatchForException() {
// Coroutine 1
runBlocking {
...
// Coroutine 4
launch {
println("Coroutine 4: Started")
// Coroutine 5
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(500)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
try {
throw RuntimeException("Coroutine 4: Error occurred")
} catch (e: Exception) {
println(e)
}
println("Coroutine 4: Completed")
}
delay(500)
println("Coroutine 1: Completed")
}
}
실행 결과를 보면, 모든 코루틴이 취소되지 않고 정상적으로 완료됩니다. 이 방식은 try-catch 블록이 특정 코루틴 내부에서만 적용되므로, 예외가 발생한 코루틴 내에서만 처리됩니다. 만약 다른 코루틴들에서 발생하는 예외까지 처리하려면, 각 코루틴마다 별도로 try-catch를 추가해야 하기 때문에 코드 중복이 발생할 수 있습니다.
Coroutine 1: Started
Coroutine 2: Started
Coroutine 3: Started
Coroutine 4: Started
Coroutine 5: Started (Nested in Coroutine 4)
java.lang.RuntimeException: Coroutine 4: Error occurred
Coroutine 4: Completed
Coroutine 1: Completed
Coroutine 2: Completed
Coroutine 3: Completed
Coroutine 5: Completed (Nested in Coroutine 4)
Job ( 🤔 )
구조화된 동시성 원칙을 기반으로, 부모-자식 관계를 끊어 자식 코루틴의 예외가 부모 코루틴으로 전파되지 않도록 설정할 수 있습니다. 코루틴 4와 코루틴 1의 부모-자식 관계를 끊어보겠습니다.
@Test
fun job() {
// Coroutine 1
runBlocking {
...
// Coroutine 4
launch(Job()) {
println("Coroutine 4: Started")
// Coroutine 5
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(500)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
throw RuntimeException("Coroutine 4: Error occurred")
}
delay(500)
println("Coroutine 1: Completed")
}
}
실행 결과를 보면, 코루틴 4와 코루틴 5만 취소되고 나머지 코루틴은 정상적으로 완료됩니다.
Coroutine 1: Started
Coroutine 2: Started
Coroutine 3: Started
Coroutine 4: Started
Coroutine 5: Started (Nested in Coroutine 4)
Exception in thread "Test worker @coroutine#7" java.lang.RuntimeException: Coroutine 4: Error occurred
at ...
Coroutine 1: Completed
Coroutine 2: Completed
Coroutine 3: Completed
먼저 부모-자식 관계를 확인해 보겠습니다. 코루틴 4의 부모는 더 이상 코루틴 1이 아니며, Job으로 변경되었습니다.
이제, 어떤 과정을 거쳐 코루틴 4와 5만 취소되었는지 확인해 볼게요. 코루틴 4의 예외 발생으로 인해 코루틴 4가 취소됩니다.
코루틴 4의 취소로 인해, 코루틴 4의 자식인 코루틴 5 역시 취소됩니다.
코루틴 4에서 발생한 예외는 Job에게 전파되고, Job 역시 취소됩니다.
이 방법은 전체 코루틴이 취소되는 문제를 해결하지만, 코루틴 간의 구조화된 동시성이 유지되지 않는 단점이 있습니다.
SupervisorJob ( 🤔 )
SupervisorJob은 예외 처리와 관련된 특수한 종류의 Job으로, SupervisorJob의 자식 코루틴들이 독립적으로 실패할 수 있도록 합니다. 즉, 자식 코루틴의 실패나 취소가 부모 코루틴이나 다른 자식 코루틴에 영향을 미치지 않도록 합니다. 이제, SupervisorJob을 사용해 예외 전파를 차단하는 방법을 살펴볼게요.
@Test
fun supervisorJob() {
// Coroutine 1
runBlocking {
println("Coroutine 1: Started")
val supervisorJob = SupervisorJob()
// Coroutine 2
launch(supervisorJob) {
println("Coroutine 2: Started")
delay(500)
println("Coroutine 2: Completed")
}
// Coroutine 3
launch(supervisorJob) {
println("Coroutine 3: Started")
delay(500)
println("Coroutine 3: Completed")
}
// Coroutine 4
launch(supervisorJob) {
println("Coroutine 4: Started")
// Coroutine 5
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(500)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
throw RuntimeException("Coroutine 4: Error occurred")
}
delay(500)
println("Coroutine 1: Completed")
}
}
실행 결과를 보면, 코루틴 4와 코루틴 5만 취소되고 나머지 코루틴은 정상적으로 완료된 것을 알 수 있습니다.
Coroutine 1: Started
Coroutine 2: Started
Coroutine 3: Started
Coroutine 4: Started
Coroutine 5: Started (Nested in Coroutine 4)
Exception in thread "Test worker @coroutine#7" java.lang.RuntimeException: Coroutine 4: Error occurred
at ...
Coroutine 1: Completed
Coroutine 2: Completed
Coroutine 3: Completed
먼저, 부모-자식 관계를 살펴볼게요. 코루틴 4의 부모가 SupervisorJob으로 변경되었습니다.
이제, 어떤 과정을 거쳐 코루틴 4와 5만 취소되었는지 확인해 볼게요. 코루틴 4의 예외 발생으로 인해 코루틴 4가 취소됩니다.
코루틴 4의 취소로 인해, 코루틴 4의 자식인 코루틴 5 역시 취소됩니다.
SupervisorJob은 자식 코루틴의 예외를 전파받지 않기 때문에, 코루틴 4의 예외는 부모에게 전파되지 않습니다. 하지만, 여전히 전체 코루틴 간 구조화된 동시성이 완전히 지켜지지 않는 문제가 있습니다.
하지만 SupervisorJob을 사용할 때, 모든 상황에서 구조화된 동시성이 지켜지지 않는 것은 아닙니다. 이 글의 예시 코드처럼 runBlocking 내부에서 SupervisorJob을 생성하는 대신, CoroutineScope를 직접 생성하여 CoroutineContext에 SupervisorJob을 설정한 후 코루틴을 시작하면, 전체적인 구조화된 동시성을 유지하면서 자식 코루틴의 예외가 부모 코루틴으로 전파되지 않도록 할 수 있습니다.
supervisorScope ( 👍 )
supervisorScope 함수를 사용하면 자식 코루틴에서 발생한 예외가 부모 코루틴에 전파되지 않도록 처리할 수 있습니다. 이를 통해 다른 자식 코루틴들이 중단되지 않고 정상적으로 실행될 수 있으며, 동시에 전체 코루틴 간의 구조화된 동시성을 유지할 수 있습니다.
@Test
fun supervisorScope() {
// Coroutine 1
runBlocking {
...
supervisorScope {
// Coroutine 4
launch {
println("Coroutine 4: Started")
// Coroutine 5
launch {
println("Coroutine 5: Started (Nested in Coroutine 4)")
delay(500)
println("Coroutine 5: Completed (Nested in Coroutine 4)")
}
delay(200)
throw RuntimeException("Coroutine 4: Error occurred")
}
}
delay(500)
println("Coroutine 1: Completed")
}
}
실행 결과를 보면, 코루틴 4와 코루틴 5만 취소되고 나머지 코루틴은 정상적으로 완료된 것을 알 수 있습니다.
Coroutine 1: Started
Coroutine 2: Started
Coroutine 3: Started
Coroutine 4: Started
Coroutine 5: Started (Nested in Coroutine 4)
Exception in thread "Test worker @coroutine#7" java.lang.RuntimeException: Coroutine 4: Error occurred
at ...
Coroutine 2: Completed
Coroutine 3: Completed
Coroutine 1: Completed
먼저, 부모-자식 관계를 살펴볼게요. 코루틴 4의 부모는 SupervisorJob이며, SupervisorJob의 부모는 코루틴 1로 변경되었습니다.
이제, 어떤 과정을 거쳐 코루틴 4와 5만 취소되었는지 확인해 볼게요. 코루틴 4의 예외 발생으로 인해 코루틴 4가 취소됩니다.
코루틴 4의 취소로 인해, 코루틴 4의 자식인 코루틴 5 역시 취소됩니다.
SupervisorJob은 자식 코루틴의 예외를 전파받지 않기 때문에, 코루틴 4의 예외는 부모에게 전파되지 않습니다. 이렇듯, supervisorScope을 사용하면 구조화된 동시성 원칙을 지키면서 자식 코루틴에서 발생한 예외가 부모 코루틴에게 전파되지 않도록 할 수 있습니다.
마치며
이번 글에서는 코틀린 코루틴에서의 예외 처리의 중요성과 다양한 예외 처리 방법에 대해 살펴보았습니다. 코루틴에서는 예외가 부모-자식 관계를 따라 전파되며, 처리되지 않은 예외로 인해 전체 코루틴이 취소될 수 있기 때문에 적절한 예외 처리 전략이 반드시 필요합니다. 명시적인 예외 처리 방식을 통해 개별 코루틴의 예외를 처리하거나, Job/SupervisorJob/supervisorScope을 활용하여 자식 코루틴에서 발생한 예외가 부모 코루틴에 전파되지 않도록 방지하는 등 다양한 방법을 통해 예외를 효과적으로 관리할 수 있습니다. 이를 통해 특정 코루틴이 실패하더라도 나머지 코루틴이 정상적으로 실행되도록 할 수 있습니다. 제시된 방법들 중에서 여러분의 시스템 상황에 맞는 적절한 방법을 선택하여, 안정적이고 신뢰할 수 있는 서비스 설계에 도움이 되기를 바랍니다.