코루틴: 예외처리

woga·6일 전
0

코틀린 공부

목록 보기
56/56
post-thumbnail

이번에는 코루틴 예외처리 관련해서 이야기를 해볼까 한다.
"코틀린 코루틴의 정석" 8장을 요약한거고 읽으면서 다시 한 번 정리와 복기하기 좋았다.

앱을 개발하면서 엣지케이스나 예외처리를 신경써서 하게 되는데 이유는 예외가 적절하게 처리되지 않으면 비정상 종료까지 야기되기 때문이다. 특히 코루틴에서는 입출력 작업이나 네트워크 요청 같은 비동기 작업이 이뤄지기 때문에 예외처리는 안정적인 앱을 만드는데 필수적이라고 할 수 있다.
그리고 코루틴은 이를 위해 여러 장치를 갖고 있는데 간단하게 하나씩 살펴보자.

예외를 전파하는 방식

코루틴에서 예외는 부모로까지 전파한다. 그래서 구조화된 코루틴이 있고 3 depth가 있는 자식 코루틴에서 예외가 발생하면 루트 코루틴까지 예외가 전파되고 루트 코루틴이 취소되면 하위까지 취소가 전파된다.

  • 취소: 자식들까지 취소 전파
  • 예외: 예외를 처리하지 않으면 부모로 전파

예외 전파를 제대로 막지 못해 루트 코루틴이 취소되면 구조화된 코루틴이 모두 취소될 수 있다.

코루틴의 구조화는 큰 작업을 연관된 작은 작업으로 나누는 방식으로 이뤄진다는 점을 기억하자. 만약 작은 작업에서 발생한 예외로 인해 큰 작업이 취소되면 앱 안정성에 문제가 생길 수 있다.
그래서 이를 해결하기 위한 예외 전파를 제한하는 방법에 대해 알아보자

Job 객체를 이용한 예외 전파 제한

부모로까지 예외 전파가 되는게 문제였다면 단순하다. 부모로 전파가 안되게끔 구조화를 깨면 된다. (물론 이는 취소 전파도 제한하고 비동기 작업을 불안정하게 만들기 때문에 추천하지 않지만 방법 중의 하나다)
새로운 Job 객체를 만들어 구조화를 깨고 싶은 코루틴을 연결하면 된다.

fun main() = runBlocking<Unit> {
	launch(CoroutineName("Parent Coroutine")) {
    	launch(CoroutineName("Coroutine1") + Job()) { // 새로운 Job 객체 연결
        	launch(CoroutineName("Coroutine3") {
            	throw Exception("예외 발생")
            }
            delay(100L)
            println("[${Thread.currentThread().name}] 코루틴 실행")
        }
        launch(CoroutineName("Corutine2")) {
        	delay(100L)
            println("[${Thread.currentThread().name}] 코루틴 실행")
        }
    }
    delay(1000L)
}

새로운 Job 객체를 부모 Job으로 설정함으로 Parent Coroutine 코루틴과의 구조화를 깬다. 그래서 코루틴3에서 예외를 발생하더라고 코루틴 2은 취소되지 않고 정상 실행된다.

SupervisorJob 객체를 사용한 예외 전파 제한

SupervisorJob 객체는 자식 코루틴으로부터 예외를 전파받지 않는 특수한 Job 객체로 하나의 자식 코루틴에서 발생한 예외가 다른 자식 코루틴에게 영향을 미치지 못하도록 만드는데 사용된다.
일반적인 Job 객체는 자식 코루틴에서 예외가 발생하면 예외를 전파받아 취소되지만 SupervisorJob 객체는 예외를 전파받지 않아 취소되지 않는다.

이 객체를 만드는건 간단하다

public fun SupervisorJob(parent: Job? = null) : CompletableJob = SupervisorJobImpl(parent)

parent 인자 없이 사용하면 SupervisorJob 객체를 루트 Job으로 만들 수 있고 parent 인자로 넘기면 부모 Job이 있는 SupervisorJob 객체를 만들 수 있다.

fun main() = runBlocking<Unit> {
	val supervisorJob = SupervisorJob()
    launch(CoroutineName("Coroutine1") + supervisorJob) {
        launch(CoroutineName("Coroutine3") {
            throw Exception("예외 발생")
         }
         delay(100L)
         println("[${Thread.currentThread().name}] 코루틴 실행")
     }
     launch(CoroutineName("Corutine2") + supervisorJob) {
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴 실행")
     }
     delay(1000L)
}

코루틴 1과 코루틴 2 모두 부모 Job으로 SupervisorJob을 가진다. 그리고 3에서 발생한 예외는 1로 전파돼 코루틴 1을 취소시키지만 코루틴 1은 supervisorJob이므로 예외를 전파시키지 않는다. 그래서 코루틴2가 정상 실행된다.

그치만 여전히 runBlocking Job이 아니라 SupervisorJob을 부모 Job으로 만들어 사용해서 구조화가 깨졌다.

깨지않으려면 아래와 같이 부모 Job 객체를 넘기면 된다,

fun main() = runBlocking<Unit> {
	// supervisorJob의 Parent로 runBlocking으로 생성된 Job 객체 설정
	val supervisorJob = SupervisorJob(parent = this.coroutineContext[Job])
    launch(CoroutineName("Coroutine1") + supervisorJob) {
        launch(CoroutineName("Coroutine3") {
            throw Exception("예외 발생")
         }
         delay(100L)
         println("[${Thread.currentThread().name}] 코루틴 실행")
     }
     launch(CoroutineName("Corutine2") + supervisorJob) {
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴 실행")
     }
     supervisorJob.complete() // supervisorJob 완료 처리
}

this.coroutineContext[Job]을 사용해 runBlocking이 호출돼 만들어진 Job 객체를 가져오며 SupervisorJob 인자로 넘긴다. 그리고 명시적으로 완료 처리 코드를 넣어준다. 이유는

SupervisorJob()을 통해 생성된 Job 객체는 Job()을 통해 생성된 Job 객체와 같이 자동으로 완료 처리 되지 않는다.

아니면 아예 CoroutineScope와 함께 사용해도 된다.

fun main() = runBlocking<Unit> {
	val coroutineScope = CoroutineScope(SupervisorJob())
	coroutineScope.apply {
    	launch(CoroutineName("Coroutine1") + Job()) { // 새로운 Job 객체 연결
        	launch(CoroutineName("Coroutine3") {
            	throw Exception("예외 발생")
            }
            delay(100L)
            println("[${Thread.currentThread().name}] 코루틴 실행")
        }
        launch(CoroutineName("Corutine2")) {
        	delay(100L)
            println("[${Thread.currentThread().name}] 코루틴 실행")
        }
    }
    delay(1000L)
}

근데 이렇게 되면 결국 SupervisorJob을 parent 없이 생성해 부모 Job을 대체하는 것과 같은 구조가 되긴 한다.

Supervisor 객체는 강력한 예외 전파 방지 도구지만 잘못 사용하면 기능을 제대로 수행하지 못할 수 있다. 이를 생성할 때 Supervisor 객체가 Job 계층 구조의 어떤 위치에 있어야하는지 충분히 고민 후 사용하자

supervisorScope을 사용한 예외 전파 제한

supervisorScope 함수는 SupervisorJob 객체를 사진 CoroutineScope 객체를 생성하며 이 SupervisorJob 객체는 supervisorScope 함수를 호출한 코루틴의 Job 객체를 부모로 가진다. 즉, 이 스콥을 사용하면 복잡한 설정 없이도 구조화를 깨지 않고 예외 전파를 제한할 수 있다는 것이다.

fun main() = runBlocking<Unit> {
	supervisorScope {
    	launch(CoroutineName("Coroutine1") + Job()) { // 새로운 Job 객체 연결
        	launch(CoroutineName("Coroutine3") {
            	throw Exception("예외 발생")
            }
            delay(100L)
            println("[${Thread.currentThread().name}] 코루틴 실행")
        }
        launch(CoroutineName("Corutine2")) {
        	delay(100L)
            println("[${Thread.currentThread().name}] 코루틴 실행")
        }
    }
    delay(1000L)
}

이러면 아래와 같은 구조가 되 구조화가 깨지지도 않고 예외 전파도 제한하게 된다.

CoroutineExceptionHandler를 사용한 예외 처리

CoroutineContext 구성 요소로 CoroutineExceptionHandler라고 하는 예외 처리기를 지원한다. 공통 예외처리기가 필요하다면 해당 클래스를 이용해보자.

public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineContext, Throwable) -> Unit): CoroutineExceptionHandler

이를 사용한다면 아래와 같다.

fun main() = runBlocking<Unit> {
	val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    println("[예외 발생] $throwable")
  }
  CoroutineScope(exceptionHandler).launch(CoroutineName("Coroutine1")) {
  	launch(CoroutineName("Coroutine2")) {
    	throw Exception("Coroutine2에서 예외 발생")
     }
   }
   delay(1000L)
}

result:

[예외 발생] java.lang.Exception: Coroutine2에서 예외 발생

참고로 laucnh 코루틴은 다른 launch 코루틴으로 예외를 전파하면 예외를 처리한 것으로 본다. 그리고 해당 익셉션 핸들러 객체는 이미 처리된 예외에 대해서는 동작하지 않기 때문에 launch 함수로 생성된 코루틴 중 최상위에 있는 코루틴에서 처리된다. 그래서 해당 exceptionHandler가 코루틴 1에도 있고 자식 코루틴인 2에도 있지만(자식은 상속받으니까) 1에서 exceptionHandler가 실행되는것도 그런 이유 때문이다.

게다가 중요한 점이 있는데, CoroutineExceptionHandler은 예외가 마지막으로 처리되는 위치에서 예외를 처리할 뿐 예외 전파를 제한하지 않는다.

try-catch문을 사용한 예외처리

물론 try-catch로 예외를 처리할 수 있다. 또한, 예외가 try-catch로 처리되기 때문에 부모 코루틴으로 전파되지 않는다.
그러나 try-catch문을 감쌀 때 block을 잘 보고 감싸야하는데 예외처리가 되는 케이스와 아닌 케이스를 보자.

// Success
// reulst:
// Coroutine1 exception
// Coroutine2 complete
fun main() = runBlocking<Unit> {
	launch(CoroutineName("Coroutine1")) }
    	try{
        	throw Exception("Coroutine1 exception")
        } catch (e: Exception) {
        	println(e.message)
        }
    }
    launch(CorouineName("Coroutine2")) {
    	delay(100L)
        println("Coroutine2 complete")
    }
}

// Fail
fun main() = runBlocking<Unit> {
	try {
    	launch(CoroutineName("Coroutine1")) }
        	throw Exception("Coroutine1 exception")
    	}
    } catch (e: Exception) {
    	println(e.message)
    }
    launch(CorouineName("Coroutine2")) {
    	delay(100L)
        println("Coroutine2 complete")
    }
}

launch는 코루틴을 생성하는데 사용되는 함수일 뿐으로 람다식의 실행은 생성된 코루틴이 CoroutineDispatcher에 의해 스레드로 분배되는 시점에 일어나기 때문에 해당 try-catch가 잡지 못한다.
즉, try-catch는 launch 코루틴 빌더 함수 자체의 실행만 체크하며, 람다식은 예외처리 대상이 아니다.
그러므로 람다식 내부에서 사용해야하고 코루틴 빌더 함수에 사용하지 않도록 주의하자.

async의 예외 처리

async 코루틴 빌더 함수는 다른 코루틴 빌더 함수와 달리 결과값을 Deferred 객체로 감싸고 await 호출 시점에 결과값을 노출한다. 이런 특성 때문에 await 호출 시 예외가 노출된다.

그래서 만약에 처리한다면 아래처럼 처리하자

fun main() = runBlocking<Unit> {
	supervisorScope {
    	val deferred: Deferred<String> = async(CoroutineName("Coroutine1")) {
        	throw Exception("Coroutine1 exception")
        }
        
        try {
        	deffered.await()
        } catch (e: Exception) {
        	pritnln("[exception] is ${e.message}")
        }
    }
}

// result:
// [exception] is Coroutine1 exception

await 호출부에서 예외처리 될 수 있도록하자.

async 코루틴 빌더 함수도 launch처럼 예외 전파가 된다는 걸 주의하자.

전파되지 않는 예외 CancellationException

CancellationException은 코루틴의 취소에 사용되는 특별한 예외라 부모 코루틴으로 전파되지 않는다.
Job 객체에 cancel 함수를 호출하면 CancellationException의 서브 클래스인 JobCancellationException을 발생시켜 코루틴을 취소시킨다.

마치며

코루틴의 여러가지 예외 처리와 제한에 대해 알아봤다. 코드도 자료도 책에는 더 많은데 간략하고 핵심적인 내용만 정리한 글이므로 이를 참조해서 코드 작업에 도움이 됐으면 한다. 실제로 비동기 작업을 많이 이용하기 때문에 코루틴에 대해 확실히 알고 넘어가는게 좋을 거 같다.

profile
와니와니와니와니 당근당근

0개의 댓글

관련 채용 정보