1. 코루틴 예외 처리의 기본 원리
- 코틀린 코루틴은 일반적인 코드와 다른 독특한 예외 처리 메커니즘을 가지고 있습니다.
- 코루틴의 예외 처리를 이해하려면 코루틴의 구조화된 동시성(Structured Concurrency) 개념을 알아야 합니다.
- 구조화된 동시성이란 코루틴이 부모-자식 관계를 형성하며, 이 계층 구조를 따라 예외가 전파된다는 것을 의미합니다.
1.1 일반 코드와 코루틴의 예외 처리 차이
- 일반 코드에서는 예외가 발생하면 호출 스택을 따라 위로 전파됩니다.
- 코루틴에서는 예외가 발생하면 계층 구조를 따라 부모 코루틴으로 전파됩니다.
- 일반 함수와 달리, 코루틴은 비동기적으로 실행되기 때문에 예외 전파 방식이 다릅니다.
중요 개념
코루틴에서 처리되지 않은 예외는 해당 코루틴을 즉시 취소하고, 부모 코루틴에도 전파되어 결국 모든 관련 코루틴이 취소될 수 있습니다.
1.2 예외 전파 메커니즘
- 코루틴에서 예외가 발생하면 다음과 같은 과정으로 처리됩니다:
- 예외가 발생한 코루틴은 즉시 취소됩니다.
- 예외는 부모 코루틴으로 전파됩니다.
- 부모 코루틴은 모든 자식 코루틴을 취소합니다.
- 이 과정이 루트 코루틴까지 계속됩니다.
1.2.1 예외 전파 예시
fun main() = runBlocking {
try {
coroutineScope {
launch {
println("자식 코루틴 1 시작")
delay(100)
throw RuntimeException("자식 코루틴 1 예외 발생")
}
launch {
println("자식 코루틴 2 시작")
delay(1000)
println("이 코드는 실행되지 않습니다") // 첫 번째 코루틴의 예외로 인해 취소됨
}
}
} catch (e: Exception) {
println("예외 잡힘: ${e.message}")
}
println("프로그램 계속 실행")
}
위 코드에서는 첫 번째 자식 코루틴에서 발생한 예외가 부모 코루틴으로 전파되어 두 번째 자식 코루틴이 취소되고, 최종적으로 try-catch 블록에서 예외가 잡힙니다.
2. 코루틴에서 예외 처리하기
- 코루틴에서 예외를 처리하는 여러 방법이 있습니다.
- 각 방법은 서로 다른 상황과 요구사항에 적합합니다.
2.1 try-catch 블록 사용하기
- 가장 기본적인 방법은 코루틴 내부에서 try-catch 블록을 사용하는 것입니다.
- 이 방법은 특정 코루틴 내에서 발생하는 예외만 처리할 수 있습니다.
suspend fun fetchData(): String = coroutineScope {
try {
// 네트워크 호출 또는 기타 예외가 발생할 수 있는 작업
delay(1000)
"데이터"
} catch (e: Exception) {
// 예외 처리
println("예외 발생: ${e.message}")
"기본 데이터" // 오류 발생 시 반환할 기본값
}
}
2.2 CoroutineExceptionHandler 사용하기
CoroutineExceptionHandler는 코루틴 빌더에 전달할 수 있는 컨텍스트 요소입니다.- 코루틴 계층 구조의 루트 코루틴에만 효과가 있습니다.
- 자식 코루틴에서 발생한 예외를 부모 코루틴에서 처리할 수 있게 해줍니다.
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println("예외 핸들러에서 잡힌 예외: ${exception.message}")
}
val job = launch(handler) {
launch {
throw RuntimeException("자식 코루틴에서 예외 발생")
}
}
job.join() // 코루틴이 완료될 때까지 대기
println("프로그램 계속 실행")
}
주의사항
CoroutineExceptionHandler는 launch와 같은 루트 코루틴에만 효과가 있으며, async나 자식 코루틴에는 적용되지 않습니다. async에서 발생한 예외는 await() 호출
시에만 전파됩니다.
2.3 supervisorScope 사용하기
supervisorScope는 자식 코루틴의 실패가 다른 자식 코루틴에 영향을 주지 않도록 합니다.- 한 자식 코루틴의 예외가 다른 자식 코루틴을 취소하지 않도록 할 때 유용합니다.
fun main() = runBlocking {
supervisorScope {
val job1 = launch {
try {
println("자식 1 시작")
delay(500)
throw RuntimeException("자식 1 예외")
} catch (e: Exception) {
println("자식 1 예외 처리: ${e.message}")
}
}
val job2 = launch {
println("자식 2 시작")
delay(1000)
println("자식 2 완료") // 자식 1의 예외에도 불구하고 실행됨
}
job1.join()
job2.join()
}
println("모든 작업 완료")
}
3. async와 예외 처리
async코루틴 빌더는launch와 다른 예외 처리 방식을 가집니다.async는 결과 또는 예외를Deferred객체에 저장하고,await()호출 시에만 예외가 전파됩니다.
3.1 async의 예외 처리 특성
async내부에서 예외가 발생해도 즉시 부모로 전파되지 않습니다.await()호출 시에만 예외가 던져집니다.- 이로 인해
async를 사용할 때는 항상await()를 호출해야 예외를 적절히 처리할 수 있습니다.
fun main() = runBlocking {
val deferred = async {
println("비동기 작업 시작")
delay(1000)
throw RuntimeException("비동기 작업 실패")
}
// 여기서는 예외가 발생하지 않음
delay(2000)
println("await() 호출 전")
try {
deferred.await() // 여기서 예외가 전파됨
} catch (e: Exception) {
println("예외 잡힘: ${e.message}")
}
println("프로그램 계속 실행")
}
3.2 여러 async 작업 처리하기
- 여러
async작업을 처리할 때는awaitAll이나 개별await호출로 예외를 처리할 수 있습니다. - 각 방법에 따라 예외 처리 전략이 달라집니다.
fun main() = runBlocking {
val deferreds = listOf(
async {
delay(100)
println("작업 1 완료")
"결과 1"
},
async {
delay(200)
throw RuntimeException("작업 2 실패")
},
async {
delay(300)
println("작업 3 완료")
"결과 3"
}
)
try {
val results = deferreds.awaitAll() // 첫 번째 예외에서 중단됨
println("모든 결과: $results") // 이 코드는 실행되지 않음
} catch (e: Exception) {
println("예외 발생: ${e.message}")
// 이미 완료된 작업의 결과 수집
deferreds.forEachIndexed { index, deferred ->
if (deferred.isCompleted && !deferred.isCancelled) {
try {
println("작업 ${index + 1} 결과: ${deferred.await()}")
} catch (e: Exception) {
println("작업 ${index + 1} 예외: ${e.message}")
}
}
}
}
}
4. 취소와 예외 처리
- 코루틴 취소는 내부적으로
CancellationException을 사용합니다. - 이 예외는 정상적인 코루틴 종료로 간주되어 부모 코루틴으로 전파되지 않습니다.
4.1 취소 예외 처리하기
CancellationException은 일반적으로 처리할 필요가 없습니다.- 취소 시 리소스를 정리해야 할 경우에만 이 예외를 처리합니다.
fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("작업 $i 실행 중...")
delay(500)
}
} catch (e: CancellationException) {
println("코루틴 취소됨: ${e.message}")
throw e // 취소 예외는 다시 던져야 함
} finally {
println("리소스 정리 작업 수행")
}
}
delay(1300)
println("코루틴 취소")
job.cancel("사용자에 의한 취소")
job.join()
println("메인 코루틴 종료")
}
주의사항
취소된 코루틴 내에서는 다른 일시 중단 함수를 호출할 수 없습니다. 취소 후 정리 작업이 필요하면 withContext(NonCancellable) { ... } 블록을 사용해야 합니다.
4.2 NonCancellable 컨텍스트 사용하기
- 취소된 코루틴에서 정리 작업을 위해 일시 중단 함수를 호출 해야 할 경우
NonCancellable컨텍스트를 사용합니다.
fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("작업 $i 실행 중...")
delay(500)
}
} finally {
withContext(NonCancellable) {
println("정리 작업 시작")
delay(1000) // 취소 상태에서도 실행 가능
println("정리 작업 완료")
}
}
}
delay(1300)
job.cancelAndJoin()
println("메인 코루틴 종료")
}
5. 고급 예외 처리 패턴
5.1 과제: 재시도 메커니즘 구현하기
- 네트워크 요청과 같은 실패할 수 있는 작업에 대해 재시도 로직을 구현하는 것은 일반적인 요구사항입니다.
- 코루틴을 사용하면 이를 깔끔하게 구현할 수 있습니다.
suspend fun <T> retry(
times: Int,
initialDelay: Long,
maxDelay: Long,
factor: Double = 2.0,
block: suspend () -> T
): T {
var currentDelay = initialDelay
repeat(times) { attempt ->
try {
return block()
} catch (e: Exception) {
println("시도 ${attempt + 1}/${times} 실패: ${e.message}")
if (attempt == times - 1) throw e
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
}
}
throw IllegalStateException("This line should never be reached")
}
// 사용 예:
suspend fun fetchData(): String {
return retry(
times = 3,
initialDelay = 100,
maxDelay = 1000
) {
// 네트워크 요청 시뮬레이션
if (Math.random() < 0.7) throw IOException("네트워크 오류")
"데이터 가져오기 성공"
}
}
5.2 Circuit Breaker 패턴
- Circuit Breaker 패턴은 반복적인 실패를 감지하고 일정 시간 동안 작업 시도를 중단하는 방식입니다.
- 시스템 과부하를 방지하고 빠른 실패(fail-fast)를 제공합니다.
class CircuitBreaker(
private val maxFailures: Int,
private val resetTimeout: Long,
private val openTimeout: Long
) {
private var failures = 0
private var state = State.CLOSED
private var lastFailureTime = 0L
enum class State { CLOSED, OPEN, HALF_OPEN }
suspend fun <T> execute(block: suspend () -> T): T {
when (state) {
State.OPEN -> {
val timeSinceLastFailure = System.currentTimeMillis() - lastFailureTime
if (timeSinceLastFailure >= resetTimeout) {
state = State.HALF_OPEN
} else {
throw CircuitBreakerOpenException("서킷 브레이커가 열린 상태입니다")
}
}
State.HALF_OPEN, State.CLOSED -> {
// 계속 진행
}
}
return try {
val result = block()
if (state == State.HALF_OPEN) {
state = State.CLOSED
failures = 0
}
result
} catch (e: Exception) {
failures++
lastFailureTime = System.currentTimeMillis()
if (failures >= maxFailures || state == State.HALF_OPEN) {
state = State.OPEN
delay(openTimeout) // OPEN 상태로 전환 시 지연
}
throw e
}
}
class CircuitBreakerOpenException(message: String) : Exception(message)
}
6. 실제 사용 사례
6.1 Android에서의 코루틴 예외 처리
- Android에서는 ViewModel이나 lifecycle 범위에서 코루틴을 실행하는 경우가 많습니다.
- 이런 환경에서의 예외 처리는 앱 안정성에 중요합니다.
class MyViewModel : ViewModel() {
private val exceptionHandler = CoroutineExceptionHandler { _, exception ->
// 오류 로깅
Log.e("MyViewModel", "코루틴 예외 발생", exception)
// UI 상태 업데이트
_uiState.value = UiState.Error(exception.message ?: "알 수 없는 오류")
}
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
fun loadData() {
viewModelScope.launch(exceptionHandler) {
_uiState.value = UiState.Loading
try {
val result = repository.fetchData()
_uiState.value = UiState.Success(result)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "