[Kotlin in Action 2/e] 18장 오류 처리와 테스트

왕왕조현·2026년 2월 22일

Kotlin in Action 2/e

목록 보기
18/18
post-thumbnail

안녕하세요!

오류 처리와 테스트에 대한 정리글로 돌아온 개발자 꿈나무 김조현입니다.

이번 글에서는 코루틴에서 오류 처리를 하는 다양한 개념과 테스트 등에 대해 정리해보겠습니다.


코루틴 내부에서 던져진 오류 처리

일시 중단 함수나 코루틴 빌더 안에 작성한 코드도 예외를 발생시킬 수 있습니다. 이런 예외를 처리하기 위해 launch나 async 호출을 try-catch로 감싼다면 효과가 없습니다. 이들이 코루틴 빌더 함수이기 때문입니다.

코루틴 빌더는 실행할 새로운 코루틴을 생성하는데, 이 새로운 코루틴에서 발생한 예외는 catch 블록에 의해 잡히지 않습니다.

import kotlinx.coroutines.*

fun main(): Unit = runBlocking {
	try {
		launch {
			throw UnsupportedOperationException("Ouch!")
		}
	} catch (u: UnsupportedOperationException) {
		println("Handled $u")
	}
}

이 코드를 실행하면 launch 빌더 안에서 예외가 잡히지 않습니다. 이 예외를 올바르게 처리하기 위해서는 launch에 전달되는 람다 블록 안에 try-catch 블록을 넣는 것입니다.

import kotlinx.coroutines.*

fun main(): Unit = runBlocking {
	launch {
		try {
			throw UnsupportedOperationException("Ouch!")
		} catch (u: UnsupportedOperationException) {
			println("Handled $u")
		}
	}
}

async로 생성된 코루틴이 예외를 던진다면 그 결과에 대해 await를 호출할 때 이 예외가 다시 발생합니다. await가 원하는 타입의 의미 있는 값을 돌려줄 수 없기 때문에 예외를 던져야만 합니다.

import kotlinx.coroutines.*

fun main(): Unit = runBlocking {
	val myDeferredInt: Deferred<Int> = async {
		throw UnsupportedOperationException("Ouch!")
	}
	try {
		val i: Int = myDeferredInt.await()
		println(i)
	} catch (u: UnsupportedOperationException) {
		println("Handled $u")
	}
}

이 코드를 실행하면 await()를 감싼 try-catch에서 예외를 잡는 것을 확인할 수 있습니다.


코틀린 코루틴에서 오류 전파

코루틴의 구조적 동시성 패러다임은 자식 코루틴에서 발생한 잡히지 않은 예외가 부모 코루틴에 의해 어떻게 처리되는지에 영향을 줍니다. 자식에게 작업을 나누는 방식에 따라 자식의 오류를 처리하는 방식도 달라집니다.

  • 코루틴이 작업을 동시적으로 분해해 처리하는 경우 자식 중 하나의 실패는 더 이상 최종 결과를 얻을 수 없다는 점을 의미합니다. 즉, 한 자식의 실패가 부모의 실패로 이어집니다.
  • 하나의 자식이 실패해도 전체 실패로는 이어지지 않을 때가 있습니다. 자식들에게 벌어진 실패를 부모가 처리해야 하지만 자식의 실패로 인해 시스템 전체가 실패하면서 멈추지 말아야 하는 경우를, 자식이 부모의 실행을 감독한다고 합니다. 즉, 이런 경우 한 자식의 실패가 부모의 실패로 이어지지 않습니다.

자식이 실패하면 모든 자식을 취소하는 코루틴

코루틴 간의 부모-자식 계층이 Job 객체를 통해 구축되며, SupervisorJob 없이 생성된 경우 자식 코루틴에서 발생한 잡히지 않은 예외는 부모 코루틴을 예외로 완료시키는 방식으로 처리됩니다. 즉, 실패한 자식 코루틴은 자신의 실패를 부모에게 전파하며 부모는 불필요한 작업을 막기 위해 다른 모든 자식을 취소합니다.

그러면서 같은 예외를 발생시키면서 자신의 실행을 완료시키고, 자신의 상위 계층으로 예외를 전파합니다.

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

fun main(): Unit = runBlocking {
	launch {
		try {
			while(true) {
				println("Heartbeat!")
				delay(500.milliseconds)
			}
		} catch (e: Exception) {
			println("Heartbeat terminated: $e")
			throw e
		}
	}
	launch {
		delay(1.seconds)
		throw UnsupportedOperationException("Ow!")
	}
}
// Heartbeat!
// Heartbeat
// Heartbeat terminated: kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job="coroutine#1":BlockingCoroutine{Cancelling}@73ad2d6

이런 식으로 runBlocking을 포함한 모든 코루틴 빌더는 일반적인 감독이 아닌 코루틴을 생성합니다. 그렇기에 한 코루틴이 잡히지 않은 예외로 종료되면 다른 자식 코루틴도 취소됩니다.


구조적 동시성은 코루틴 스코프를 넘는 예외에만 영향을 미친다

형제 코루틴을 취소하고 예외를 코루틴 계층 상위로 전파하는 이 동작은 코루틴 스코프를 넘는 처리되지 않은 예외에만 영향을 미칩니다. 따라서 처음부터 스코프를 넘는 예외를 던지지 않으면 위 동작을 피할 수 있습니다.

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

fun main(): Unit = runBlocking {
	launch {
		try {
			while(true) {
				println("Heartbeat!")
				delay(500.milliseconds)
			}
		} catch (e: Exception) {
			println("Heartbeat terminated: $e")
			throw e
		}
	}
	launch {
		try {
			delay(1.seconds)
			throw UnsupportedOperationException("Ow!")
		} catch {
			println("Caught $u")
		}
	}
}
// Heartbeat!
// Heartbeat!
// Caught java.lang.UnsupportedOperationException: Ow!
// Heartbeat!
// Heartbeat!

이렇게 예외를 던지는 코루틴을 try로 감싼다면 예외가 발생한 다음에도 하트비트 코루틴이 계속 텍스트를 출력하는 것을 볼 수 있습니다.

처리하지 않은 예외를 코루틴 계층 위쪽으로 전파하고 형제 코루틴을 취소하는 것은 애플리케이션에서 구조적 동시성 패러다임을 강제하는 데 도움이 됩니다. 하지만 처리하지 않은 예외 하나가 전체 애플리케이션을 무너뜨려서는 안됩니다.


슈퍼바이저는 부모와 형제가 취소되지 않게 한다

슈퍼바이저는 자식이 실패하더라도 생존합니다. 일반 Job을 사용하는 스코프와 달리 슈퍼바이저는 일부 자식이 실패를 보고하더라도 실패하지 않습니다. 코루틴이 자식 코루틴의 슈퍼바이저가 되려면 그 코루틴에 연관된 Job이 일반적인 Job이 아니라 SupervisorJob이어야 합니다.

SupervisorJob은 Job과 마찬가지 역할을 하지만 예외를 부모에게 전파하지 않으며, 다른 자식 작업이 실패해도 취소되지 않게 합니다.

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

fun main(): Unit = runBlocking {
	supervisorScope {
		launch {
			 try {
				 while(true) {
					 println("Heartbeat!")
					 delay(500.milliseconds)
					}
				} catch(e: Exception) {
					println("Heartbeat terminated: $e")
					throw e
				}
			}
			launch {
				delay(1.seconds)
				throw UnsupportedOperationException("Ow!")
			}
	}
}

앞에서 실행했던 예제인 하드비트 코루틴이 계속 실행되도록 코드를 수정하면 launch 호출을 supervisorScope로 감싸면 됩니다.


CoroutineExceptionHandler

자식 코루틴은 처리되지 않은 예외를 부모 코루틴에 전파합니다. 이때 예외가 슈퍼바이저에 도달하거나 계층의 최상위로 가서 부모가 없는 루트 코루틴에 도달하면 예외는 더 이상 전파되지 않습니다. 이 시점에서 처리되지 않은 예외는 CoroutineExceptionHandler라는 특별한 핸들러에게 전달됩니다.

CoroutineExceptionHandler를 코루틴 콘텍스트에 제공하면 처리되지 않은 예외를 처리하는 동작을 커스텀화 할 수 있습니다. 이 핸들러는 코루틴 콘텍스트와 처리되지 않은 예외를 람다의 파라미터로 받습니다.

val exceptionHandler = CoroutineExceptionHandler { context, exception ->
	println("[ERROR] $exception")
}

이 예외 핸들러는 콘솔에 [ERROR]라는 커스텀 접두어와 함께 처리되지 않은 예외를 로그로 남깁니다.

자식 코루틴, 즉 코루틴 스코프에서 시작된 코루틴이나 다른 코루틴에서 시작된 코루틴은 처리되지 않은 예외의 처리를 부모에게 위임하며 계층의 최상위에 이를 때까지 이런 위임이 계속됩니다. 따라서 중간에 있는 CoroutineExceptionHandler라는 것은 존재하지 않습니다.

import kotlinx.coroutines.*

private val topLevelHandler = CoroutineExceptionHandler { _, e ->
	println("[TOP] ${e.message}")	
}

private val intermediateHandler = CoroutineExceptionHandler { _, e ->
	println("[INTERMEDIATE] ${e.message}")	
}

@OptIn(DelicateCoroutinesApi::class)
fun main() {
	GlobalScope.launch(topLevelHandler) }
		launch(intermediateHandler) {
			throw UnsupportedOperationException("Ouch!")
		}
	}
	Thread.sleep(1000)
}
// [TOP] Ouch!

CoroutineExceptionHandler를 launch와 async에 적용할 때의 차이점

CoroutineExceptionHandler를 살펴볼 때 예외 핸들러는 계층의 최상위 코루틴이 launch로 생성된 경우에만 호출됩니다. 최상위 코루틴이 async로 생성된 경우에는 CoroutineExceptionHandler가 호출되지 않습니다.

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.seconds

class ComponentWithScope(dispatcher: CoroutineDispatcher = 
	Dispatchers.Default) {
	private val exceptionHandler = CoroutineExceptionHandler { _, e ->
		println("[ERROR] ${e.message}")
	}
	
	private val scope = CoroutineScope(SupervisorJob() + dispatcher +
		exceptionHandler)
	
	fun action() = scope.launch {
		async {
			throw UnsupportedOperationException("Ouch!")
		}
	}
}

fun main() = runBlocking {
	val supervisor = ComponentWithScope()
	supervisor.action()
	delay(1.seconds)
}
// [ERROR] Ouch!

이 코드의 경우 에러 메세지가 출력이 되는 것을 확인할 수 있습니다.

import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.seconds

class ComponentWithScope(dispatcher: CoroutineDispatcher = 
	Dispatchers.Default) {
	private val exceptionHandler = CoroutineExceptionHandler { _, e ->
		println("[ERROR] ${e.message}")
	}
	
	private val scope = CoroutineScope(SupervisorJob() + dispatcher +
		exceptionHandler)
	
	fun action() = scope.async {
		launch {
			throw UnsupportedOperationException("Ouch!")
		}
	}
}

fun main() = runBlocking {
	val supervisor = ComponentWithScope()
	supervisor.action()
	delay(1.seconds)
}
// [ERROR] Ouch!

바깥의 코루틴을 async로 시작하도록 구현을 변경하면 코루틴 예외 핸들러가 호출되지 않는 것을 볼 수 있습니다.

최상위 코루틴이 async로 시작되면 이 예외를 처리하는 책임은 await()를 호출하는 Deferred의 소비자에게 있습니다. 따라서 코루틴 예외 핸들러는 이 예외를 무시할 수 있습니다. 그리고 소비자 코드는 await 호출을 try-catch 블록으로 감싸는 방식으로 예외를 처리할 수 있습니다.


플로우에서 예외 처리

일반적인 코틀린 함수나 일시 중단 함수와 마찬가지로 플로우도 예외를 던질 수 있습니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

class UnhappyFlowException: Exception()

val exceptionalFlow = flow {
	repeat(5) { number ->
		emit(number)
	}
	throw UnhappyFlowException()
}

fun main() = runBlocking {
	val transformedFlow = exceptionalFlow.map {
		it * 2
	}
	try {
		transformedFlow.collect {
			print("$it ")
		}
	} catch (u: UnhappyFlowException) {
		println("\nHandled: $u")
	}
	// 0 2 4 6 8 
	// Handled: UnhappyFlowException
}

이 예제는 플로우를 수집하면 5개의 원소가 배출된 다음에 UnhappyFlowException라는 커스텀 예외가 발생합니다.

일반적으로 플로우의 일부분에서 예외가 발생하면 collect에서 예외가 던져집니다. 이는 collect 호출을 try-catch 블록으로 감싸면 예상대로 동작한다는 의미입니다. 이때 플로우에 중간 연산자가 적용됐는지 여부와는 관계가 없습니다.


catch 연산자로 업스트림 예외 처리

catch는 플로우에서 발생한 예외를 처리할 수 있는 중간 연산자입니다. 이 함수에 연결된 람다 안에서 플로우에 발생한 예외에 접근할 수 있습니다. 이 연산자는 취소 예외를 자동으로 인식하기 때문에 취소가 발생한 경우에는 catch 블록이 호출되지 않습니다.

fun main() = runBlocking {
	exceptionalFlow
		.catch { cause ->
			println("\nHandled: $cause")
			emit(-1)
		}
		.collect {
			print("$it ")
		}
		// 0 1 2 3 4 
		// Handled: UnhappyFlowException
		// -1 
}

catch 연산자는 오직 업스트림에 대해서만 작동하며, 플로우 처리 파이프라인의 앞쪽에서 발생한 예외들만 잡아냅니다. collect 람다 안에서 발생한 예외를 처리하려면 collect 호출을 try-catch 블록으로 감싸면 됩니다.


retry 연산자로 플로우 수집 재시도하기

retry 연산자는 catch와 마찬가지로 업스트림의 예외를 잡습니다. 개발자는 예외를 처리하고 Boolean 값을 반환하는 람다를 사용하여 람다가 true를 반환하면 재시도를 할 수 있으며, 재시도 동안 업스트림의 플로우가 처음부터 다시 수집되면서 모든 중간 연산이 다시 실행됩니다.

import kotlinx.coroutines.flow.*
import kotlinx.coroutines.*
import kotlin.random.Random

class CommunicationException: Exception("Communication failed!")

val unreliableFlow = flow {
	println("Starting the flow!")
	repeat(10) { number ->
		if (Random.nextDouble() < 0.1) throw CommunicationException()
		emit(number)
	}
}

fun main() = runBlocking {
	unreliableFlow
		.retry(5) { cause ->
			println("\nHandled: $cause")
			cause is CommunicationException
		}
		.collect { number ->
			print("$number ")
		}
}

// Starting the flow!
// 0 1 
// Handled: CommunicationException: Communication failed!
// Starting the flow!
// 0 1 2 3 4 5 6 
// Handled: CommunicationException: Communication failed!
// Starting the flow!
// 0 1 2 3 4 5 6 7 8 
//Handled: CommunicationException: Communication failed!
// Starting the flow!
// 0 1 2 3 4 5 6 
// Handled: CommunicationException: Communication failed!
// Starting the flow!
// 
// Handled: CommunicationException: Communication failed!
// Starting the flow!
// 0 1 2 3 4 5 6 7 8 9 

이런 식으로 몇 번의 반복되는 과정을 통해 플로우의 모든 원소가 성공적으로 수집되는 모습을 볼 수 있습니다.


코루틴과 테스트 플로우

코루틴을 사용하는 코드를 위한 테스트도 일반적인 테스트와 마찬가지로 작동합니다. 테스트 메소드에서 코루틴을 사용하려면 runTest 코루틴 빌더를 사용하면 됩니다.

runBlocking 빌더 함수는 일반 코틀린 코드와 동시성 코들린 코드 사이에 다리를 놓는 역할을 하기 때문에 일시 중단 함수나 코루틴, 플로우를 사용하는 코드를 테스트할 때도 이를 쓸 수 있지만, 테스트가 실시간으로 실행된다는 단점이 있습니다. 이는 delay가 지정된 경우에 지연 시간이 전부 실행된다는 뜻입니다.

즉, 테스트 스위트가 커지면 불필요한 긴 실행 시간이 누적되면서 전체 애플리케이션 테스트 속도가 느려질 수 있습니다.


테스트를 빠르게 만들기

코틀린 코루틴은 가상 시간을 사용해 테스트 실행을 빠르게 진행할 수 있게 해줍니다. 가상 시간을 사용할 때는 지연이 자동으로 빠르게 진행되기 때문에 runBlocking 빌더 함수를 사용했을 때의 단점을 해결할 수 있습니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import kotlin.test.*
import kotlin.time.Duration.Companion.seconds

class PlaygroundTest {
	@Test
	fun testDelay() = runTest {
		val startTime = System.currentTimeMillis()
		delay(20.seconds)
		println(System.currentTimeMillis() - startTime)
	}
}

runTest는 속도를 높이기 위해 특별한 테스트 디스패처와 스케줄러를 사용합니다.

runBlocking과 마찬가지로 runTest의 디스패처는 단일 스레드입니다. 따라서 기본적으로 모든 자식 코루틴은 동시에 실행되며 테스트 코드와 병렬로 실행되지 않습니다. 단일 스레드 디스패처를 공유하는 경우 다른 코루틴이 코드를 실행하려면 코드가 일시 중단 지점을 제공해야 하며, runTest도 마찬가지입니다.

delay()나 yield() 또는 다른 일시 중단 함수 호출을 중간에 추가하면 됩니다. 또한 테스트 디스패처에서는 TestCoroutineScheduler를 통해 가상 시간을 더 세밀하게 제어할 수 있습니다.

runTest 빌더 함수의 블록 안에서는 TestScope라는 특수한 스코프에 접근할 수 있으며, 이 스코프는 TestCoroutineScheduler 기능을 사용할 수 있게 해줍니다. 이 스케줄러의 핵심 함수는 다음과 같습니다.

  • runCurrent는 현재 실행하게 예약된 모든 코루틴을 실행합니다.
  • advanceUntilIdel는 예약된 모든 코루틴을 실행합니다.

터빈으로 플로우 테스트

플로우 기반 코드는 종종 더 복잡하며 무한한 플로우나 더 까다로운 불변성을 다뤄야 할 수도 있습니다. 플로우 테스트 작성에 도움을 주는 터빈 라이브러리를 통해 이런 경우를 지원할 수 있습니다.

터빈의 핵심 기능은 플로우의 확장 함수인 test 함수입니다. test 함수는 새 코루틴을 실행하며 내부적으로 플로우를 수집합니다. test의 람다에서 awaitItem, awaitComplete, awaitError 함수를 테스트 프레임워크의 일반 단언문과 함께 사용해서 플로우에 대한 불변 조건을 지정하고 검증할 수 있습니다. 또한 플로우가 방출한 모든 원소가 테스트에 의해 적절히 소비되도록 보장해줍니다.

@Test
fun doTest() = runTest {
	val results = myFlow.test {
		assertEquals(1, awaitItem())
		assertEquals(2, awaitItem())
		assertEquals(3, awaitItem())
		awaitComplete()
	}
}

마무리입니다!

이번 글에서는 오류 처리와 테스트에 대해 정리해봤습니다.

코루틴에서 예외처리도 일반적인 코드와 마찬가지로 처리할 수 있으며, 코루틴의 경계를 주의하면서 예외처리를 하지 않으면 모든 자식 코루틴이 취소될 수 있다는 사실을 배웠습니다.

또한 코루틴 테스트를 진행할 때 runBlocking 빌더 함수를 사용해도 되지만 실시간 테스트를 진행하기 때문에 지연 시간을 전부 계산해야되서 테스트에 오래걸린다는 단점이 있어 runTest를 사용해 테스트를 진행하면 더 빠르게 테스트를 할 수 있다는 것을 알게 되었습니다.

이렇게 18장을 마무리로 Kotlin in Action 2/e 책의 개념 정리를 마치겠습니다.

읽어주셔서 감사합니다!🙂‍↕️

profile
천천히, 꾸준히, 한 걸음씩

0개의 댓글