안녕하세요! 테코톡 코루틴 예외
발표한 우아한테크코스 6기 오둥이
입니다.
10 분이라는 짧은 시간적인 제약 때문에 전달하고 싶은 지식을 최대한 축약해서 전달할 수 밖에 없었기에
코루틴 예외에 대한 보충 설명을 글로써 전달하려 합니다.
코루틴 예외 전파 방식
부터 시작해 아마 3 ~ 4개 정도의 글을 추가로 포스팅할 듯 합니다.
한참 미루다가 이제서야 쓰는데.. 빠르게 모두 작성해보도록 하겠습니다!
만약, 해당 글이나 테코톡을 보고 궁금하신 점이나 함께 논의하고 싶은 부분이 있다면 댓글이나 메일로 남겨주세요 😁
아래 지식들을 알고 있으면 해당 글을 이해하기 쉬울 거에요!
- CoroutineContext
- Job, launch, async
- CoroutineScope
- 코루틴 구조화된 동시성
- 코루틴 취소 메커니즘
- suspend function
- coroutineScope
kotlin을 활용하는 대부분의 프로그램(대표적으로 Android) 에서는 Coroutine 을 활용하여 비동기 처리하고 있다.
그럼 코루틴을 활용하는 대표적인 비동기 작업들은 뭐가 있을까?
File I/O, 네트워크 통신, DB
이 대표적인 비동기 처리 대상인 작업이다. 해당 작업들은 개발자가 예상하기 힘든 다양한 예외를 발생시킬 수 있다.
따라서, 적절한 예외처리를 해주지 않는다면 다음 같이 비정상 종료를 쉽게 마주칠 수 있을 것이다 😵
그런데, 비정상 종료는 왜 앱에 크리티컬한 요인일까?
Sentry : 모바일 사용자 한 명을 확보하는 데 5달러, 유료 모바일 사용자를 확보하는 데 75달러의 비용이 듭니다. 62%의 사용자가 충돌, 멈춤 또는 기타 오류가 발생하면 앱을 삭제합니다. 한 번의 충돌로 인해 사용자 1000명당 최대
4650달러
의 비용이 발생할 수 있습니다.
즉, 비정상 종료
는 서비스의 생명인 돈(Money🤑)과 직결되는 크리티컬한 요인이다.
따라서, 갑작스러운 종료 대신 예외의 종류에 따라 아래와 같이 어떤 이유로 에러가 발생했고, 어떻게 바로잡을 수 있는지 가이드해줄 수 있는 UI를 보여주는 것이 좋을 것이다.
이렇듯 코루틴을 사용하다 예외 발생 시 적절한 처리를 해주려면 코루틴 예외 처리 방식
에 대해 반드시 잘 숙지하고 있어야 한다.
먼저, 코루틴에서는 어떻게 예외를 전파시키는지 전통적인 비동기 처리 방식인 스레드와 비교해보면서 알아보자 💪
parent 스레드, child-1, child-2 스레드가 있고, child-1에서 예외가 발생하고 있다.
해당 코드에서는 2가지 문제점이 있다.
child 스레드가 예외를 던져도 child2 스레드는 나 parent 스레드의 작업은 취소되지 않는다
는 것이다.
에러가 발생했음에도 바로 다른 스레드의 작업이 중단하지 않기에 CPU와 메모리를 낭비
하는 행위이며, 만약 프로세스가 강제 종료된다면 따로 백업하는 과정도 없기에데이터 손상
으로 이어질 수도 있다.
예외가 발생했을 때 다른 스레드의 작업을 취소하기 위해서는 콜백 혹은 Flag 값을 설정하는 등 많은 개발 리소스가 드는 방식으로 해결해야한다, 🥲
현재, child 스레드에서 발생한 예외를 처리해주고 있지 않다.
스레드 외부에서 스레드 내부의 예외를 처리하기가 어렵다.
위와 같이 try-catch 블록으로 예외를 처리하고 싶어도 try-catch는 thread()
를 호출하자마자 탈출하기 때문에 아무런 의미가 없는 코드이다.
해결책으로 CompletableFuture api 를 활용하여 처리할 수 있으나 많은 개발 리소스가 든다는 단점이 있다.
그럼 코루틴에서는 어떨까?
코루틴은 부모-자식 관계로 구조화되어 있기에 아래와 같은 방식으로 예외를 전파시켜 모든 코루틴의 작업을 안전하게 취소시킨다.
1) 예외가 발생할 시,
자기 자신
을 취소시킴
2)부모로 예외가 전파
된다.
- 코루틴은 자신을 취소될 때 자식 코루틴을 모두 취소시킨다.
스레드 예제와 비슷하게 child 코루틴에서 예외가 발생했다!
이번에는 스레드와 다르게 모든 코루틴의 작업이 취소되는 것을 확인할 수 있다.
1) child 코루틴에서 예외가 발생했다. child 코루틴은 자식이 없으므로 자기 자신을 취소시킨다.
2) child 코루틴에서 발생한 예외가 parent 코루틴으로 전파된다.
3) parent 코루틴에서 child2 코루틴에게 취소 요청을 보냄
4) child2 가 취소 된후 Parent 코루킨은 취소된다.
모든 코루틴의 작업을 안전하게 취소시켰다! 그럼, 예외처리는 어떻게 해야할까? 🤔
발생한 예외에 대해서는 try-catch
나 Kotlin Coroutine 에서 제공하는 CoroutineExceptionHandler 로 처리할 수 있다.
suspend fun main() {
// 1. try-catch
try {
runBlocking {
error("error 발생!")
}
} catch (e: IllegalStateException) {
println("try-catch - 잡았다 요놈! ✌️")
}
// 2. CoroutineException Handler
val coroutineScope = CoroutineExceptionHandler { coroutineContext, throwable ->
println("CoroutineExceptionHandler - 잡았다 요놈! ✌️")
}
CoroutineScope(coroutineScope).launch {
error("error 발생!")
}
delay(100)
}
코루틴 예외 처리 방식에 대해서는 추후 다른 포스팅에서 자세히 다룰 것입니다.
이번 글에서는 예외처리를 이렇게도 할 수 있구나~ 라고 가볍게 보시고 가면 좋을 것 같아요 😁
코루틴 예외 전파 메커니즘
1) 예외가 발생할 시,
자기 자신
을 취소시킴
2)부모로 예외가 전파
된다.
- 코루틴은 자신을 취소될 때 자식 코루틴을 모두 취소시킨다.
코루틴 예외처리 방식
1) CoroutineExceptionHandler
2) try-catch
➡️ 코루틴은 부모-자식 관계로 구조화하여 작업과 자원을 안정적이고 쉽게 관리할 수 있고, 예외 처리도 쉽게 처리할 수 있다.
오늘은 코루틴이 예외 처리에 대한 중요성과 코루틴 에외 전파 매커니즘 그리고 예외처리 하는 방법을 찍먹해보았습니다.
다음 포스팅에서는 코루틴에서 예외 전파를 제한하는 방식
에 대해 알아봅시다~
인텔리제이 환경에서는 main 스레드에서 에러가 발생하지 않는 이상 강제 종료되지 않습니다.
그래서 스레드 예외 발생 시나리오 예제에서 child Thread 에서 예외가 발생해도 프로세스가 종료가 안된 것입니다.
코루틴도 아래와 같이 IO 스레드에서 예외가 발생시 main 함수를 강제 종료시키지 않습니다.
runBlocking {
CoroutineScope(Dispatchers.IO).launch {
error("코루틴 error ⚠️")
}
delay(2000)
println("얘가 실행안될 것 같죠? 실행됩니다~")
}
자칫 해당 코드가 정상적으로 돌아가는구나~ 라는 오해를 갖기 쉬운데요..!
실프로젝트에서 위와 같은 코드를 작성하면 펑펑 터집니다 🤯
그래서, 실제 프로덕트 환경과 비슷하게 테스트해보고 싶다면 main()
함수 보다는 runTest
를 활용하는 것을 추천드립니다.
@Test
fun `test`() = runTest {
CoroutineScope(Dispatchers.IO).launch {
error("코루틴 error ⚠️")
}
println("얘가 실행 안될 것 같죠? 되긴하는데 테스트는 실패합니다")
}
runTest
는 종료시 처리하지 못한 예외를 TestScope 내부에 있는 CoroutineExceptionHandler 로 잡아 예외를 발생시키기 때문에 비정상적인 코드라는 것을 바로 캐치할 수 있어요.
그래서 공부하실 때 테스트 코드를 통한 학습 테스트하는 것을 강력 추천드립니다
runTest 에 대해 조금 더 알고싶다면 kotlin Coroutine: 코루틴 테스트 쌩기초 탈출하기 💪 추천드립니다.
저는 다음 launchWithName() 함수를 자주 애용합니다. 해당 함수는 Coroutine 의 생명 주기와 동작에 맞게 log 메세지를 띄워주기 때문에 디버깅할 때 유용해요. 여러분도 한 번 사용해보세요 ✨
fun CoroutineScope.launchWithName(
name: String,
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
): Job {
val newJob = if (context[Job] == null) {
CoroutineName(name)
} else {
CoroutineName(name) + coroutineContext.job
}
log("$name 실행 준비")
return launch(newJob) {
log("$name 실행중")
block()
}.apply {
invokeOnCompletion {
log("$name 종료")
}
}
}
fun log(msg: String,) {
buildString {
append("[")
append(Thread.currentThread().name)
append("] - ")
append(msg)
}.also(::println)
}
>> CoroutineScope(Dispatchers.IO).launchWithName("Coroutine") {}