코루틴 디버깅하기
코루틴 디버깅하기 (Debug coroutines)
코루틴을 쓰는 애플리케이션을 디버깅하는 건 쉽지 않을 때가 많아요. 여러 코루틴이 동시에 실행되고, 한 스레드에서 중단(suspend)되었다가 다른 스레드에서 재개(resume)되거든요. 실행 순서와 사용하는 스레드가 실행 때마다 바뀌기도 해서 특정 코루틴이 어떻게 실행되는지 따라가기 어려워요. 이번에는 JVM에서 코루틴 디버깅을 훨씬 쉽게 만들어 주는 기능들을 하나씩 살펴볼게요.
본문
코루틴을 사용하는 애플리케이션은 여러 코루틴이 동시에 실행되고, 한 스레드에서 중단되었다가 다른 스레드에서 재개될 수 있어서 디버깅하기가 까다로워요. 실행 순서와 사용하는 스레드가 실행 때마다 달라질 수도 있고요. JVM에서는 다음 기능들을 쓰면 코루틴 디버깅이 훨씬 쉬워져요.
- 디버그 모드는 각 코루틴에 고유한 이름을 붙여서 디버거와 진단 출력에서 코루틴을 식별할 수 있게 해줘요.
- 스택 트레이스 복구는 코루틴이 예상된 결과 대신 예외를 받은 위치에 대한 정보를 추가해줘요.
- 디버그 에이전트는 활성 코루틴을 추적하고 그 상태를 보고하는 등의 일을 해줘요.
디버그 모드와 스택 트레이스 복구는 kotlinx-coroutines-core 모듈에서 사용할 수 있어요. 디버그 에이전트는 kotlinx-coroutines-debug 모듈에 있어요.
참고: 디버그 에이전트는 Android에서는 지원되지 않아요.
디버그 모드 활성화
디버그 모드는 실행되는 모든 코루틴에 고유한 이름을 부여해요. Java 디버거, 코루틴의 문자열 표현, 그리고 코루틴을 실행하는 동안의 스레드 이름에서 그 코루틴 이름을 확인할 수 있어요. 디버그 모드는 런타임 오버헤드가 무시할 만한 수준이라, 로깅과 진단을 단순화하려면 계속 켜 두셔도 좋아요.
코드를 Java 어서션(assertion) 활성화 상태로 실행하면 kotlinx.coroutines 라이브러리가 자동으로 디버그 모드를 켜요. 유닛 테스트는 기본적으로 어서션 활성화 상태로 실행되므로, 테스트를 위해 디버그 모드를 명시적으로 켤 필요는 없어요.
디버그 모드를 명시적으로 활성화하려면 빌드 도구(예: Gradle이나 Maven) 또는 IDE 실행 구성을 구성해서 애플리케이션을 실행하는 JVM에 -Dkotlinx.coroutines.debug 인자를 전달하면 돼요.
IntelliJ IDEA에서는 다음 단계로 디버그 모드를 활성화할 수 있어요.
- Run 위젯에서 업데이트하려는 실행/디버그 구성을 선택한 다음, More Actions | Edit을 선택해요. 실행/디버그 구성이 없다면 Run 위젯에서 Current File을 선택하고 More Actions | Run with Parameters를 선택해 실행 구성 설정을 열어요.
- Run/Debug Configurations 대화상자의 VM options 필드에
-Dkotlinx.coroutines.debug를 입력하고 OK를 클릭해요.
스택 트레이스 복구
코루틴이 Deferred.await() 같은 중단 함수를 통해 다른 코루틴으로부터 예외를 받을 때, 그 예외의 스택 트레이스에는 받는 쪽 코루틴의 스택 프레임이 포함되지 않아요. 이런 스택 프레임이 없으면 스택 트레이스에서 Deferred.await()가 어디서 호출됐는지, 그 호출로 이어지는 함수가 무엇인지 알 수 없어 디버깅이 어려워져요.
kotlinx.coroutines 라이브러리는 스택 트레이스 복구(stack trace recovery) 를 사용해 이 정보를 추가해줘요. 예외의 사본을 만들고 거기에 추가 스택 프레임을 붙이는 방식이에요.
받는 쪽 코루틴이 재개될 때, 원본 예외 대신 그 사본을 던져요. 원본 예외는 사본의 cause가 돼요. 원본 예외에 억제된(suppressed) 예외가 있다면 그대로 원본에 남아요. 사본으로 복사되지 않아요. 억제된 예외를 원본 예외에 붙여 두면 예외 체인에서 순환(cycle)이 생기는 것과 일부 프레임워크에서의 크래시를 막을 수 있어요.
디버그 모드에서는 스택 트레이스 복구가 기본적으로 활성화돼요. 디버그 모드에서 스택 트레이스 복구를 끄려면 -Dkotlinx.coroutines.stacktrace.recovery=false VM 옵션을 전달하면 돼요.
스택 트레이스 복구가 있을 때와 없을 때의 차이를 보여주는 예시를 볼까요.
import kotlinx.coroutines.*
object UserProfileService :
CoroutineScope by CoroutineScope(CoroutineName("UserProfileService")) {
private fun parseUserProfile(): String {
error("Invalid user profile")
}
private fun loadUserProfile(): String {
return parseUserProfile()
}
// Runs in the coroutine that calls this function
suspend fun awaitUserProfile() {
// Starts a new coroutine
val userProfile = async(Dispatchers.Default) {
// The new coroutine throws the exception
loadUserProfile()
}
// The coroutine running awaitUserProfile()
// receives the exception through the await() function
userProfile.await()
}
}
suspend fun main() {
UserProfileService.awaitUserProfile()
}
이 예시에서 parseUserProfile() 함수는 .async() 빌더 함수가 시작한 코루틴 안에서 예외를 던져요. awaitUserProfile()을 호출한 코루틴은 Deferred.await() 함수를 통해 그 예외를 받아요.
스택 트레이스 복구를 끄면, 스택 트레이스는 .async() 함수가 만든 코루틴에서 parseUserProfile()이 어디서 예외를 던지는지 보여주지만, awaitUserProfile() 함수 안의 Deferred.await() 호출은 포함하지 않아요. 스택 트레이스 복구를 켜면, 스택 트레이스에 awaitUserProfile() 함수 안의 Deferred.await() 호출도 포함돼요.
커스텀 예외에 대한 스택 트레이스 복구
예외 클래스에 메시지, cause, 둘 다, 또는 아무 인자도 받지 않는 public 생성자가 있다면, 스택 트레이스 복구가 예외를 자동으로 복사할 수 있어요.
행 번호나 에러 코드처럼 추가 생성자 인자가 필요한 예외의 스택 트레이스를 kotlinx.coroutines 라이브러리가 복구하길 원한다면, StackTraceRecoverable 인터페이스를 구현하면 돼요.
StackTraceRecoverable 인터페이스는 Kotlin 표준 라이브러리의 일부이므로, kotlinx.coroutines 라이브러리에 대한 의존성 없이도 구현할 수 있어요. 이 인터페이스는 모든 타깃에서 쓸 수 있지만, kotlinx.coroutines 라이브러리가 스택 트레이스 복구에 이 인터페이스를 사용하는 것은 JVM뿐이에요.
이 인터페이스를 구현하려면 copyForStackTraceRecovery() 함수를 오버라이드해요. 오버라이드에서 스택 트레이스 복구용 새 예외 인스턴스를 반환하거나, 라이브러리가 예외를 복사하지 않길 원한다면 null을 반환하면 돼요.
이 API들은 실험적(Experimental)이며 @OptIn(ExperimentalStdlibCoroutineSupportApi::class) 애너테이션으로 옵트인해야 해요.
다음은 스택 트레이스 복구를 위해 새 인스턴스를 만들 때 line 프로퍼티를 보존하는 커스텀 예외의 예시예요.
import kotlinx.coroutines.*
import kotlin.coroutines.ExperimentalStdlibCoroutineSupportApi
import kotlin.coroutines.debug.StackTraceRecoverable
@OptIn(ExperimentalStdlibCoroutineSupportApi::class)
class FileEditException
// The implementation requires a private constructor
// to pass the cause to the IllegalStateException constructor
private constructor(
val line: Int,
private val detail: String,
cause: Throwable?,
) : IllegalStateException("When editing line $line: $detail", cause),
// Implements StackTraceRecoverable for stack trace recovery
StackTraceRecoverable<FileEditException> {
constructor(line: Int, detail: String) : this(line, detail, null)
// Copies the line number and message details
override fun copyForStackTraceRecovery(): FileEditException =
FileEditException(line, detail, this)
}
private fun editFile() {
throw FileEditException(15, "Unexpected token")
}
suspend fun main() {
supervisorScope {
// Starts a new coroutine
val fileEdit = async(Dispatchers.Default) {
// Throws the original exception
editFile()
}
// Stack trace recovery creates a copy of the exception,
// adds the calling coroutine's stack frames, and throws the copy
fileEdit.await()
}
}
디버그 모드를 켜면 출력에는 복구된 사본이 먼저 나오고, 그다음 원본 예외가 cause로 이어져요.
Exception in thread "main" com.example.FileEditException: When editing line 15: Unexpected token
at com.example.RecoveryExampleKt.editFile(RecoveryExample.kt:54)
at com.example.RecoveryExampleKt.access$editFile(RecoveryExample.kt:1)
at com.example.RecoveryExampleKt$main$2$fileEdit$1.invokeSuspend(RecoveryExample.kt:62)
at _COROUTINE._BOUNDARY._(CoroutineDebugging.kt:42)
at com.example.RecoveryExampleKt$main$2.invokeSuspend(RecoveryExample.kt:67)
Caused by: com.example.FileEditException: When editing line 15: Unexpected token
at com.example.RecoveryExampleKt.editFile(RecoveryExample.kt:54)
at com.example.RecoveryExampleKt.access$editFile(RecoveryExample.kt:1)
at com.example.RecoveryExampleKt$main$2$fileEdit$1.invokeSuspend(RecoveryExample.kt:62)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:586)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:807)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:717)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
디버그 에이전트
kotlinx-coroutines-debug 모듈은 JVM 애플리케이션을 위한 디버그 에이전트를 제공해요. 이 에이전트는 코루틴이 생성되고, 중단되고, 재개될 때 그 코루틴들을 추적해요.
DebugProbes API는 디버그 에이전트의 주요 진입점이에요. 이 API로 활성 코루틴과 그 현재 상태를 출력할 수 있어요. 출력에는 각 코루틴이 어디서 생성됐고 어디서 중단됐는지 보여주는 스택 트레이스가 포함돼요. 특정 Job이나 CoroutineScope의 계층 구조를 위한 코루틴 덤프도 출력할 수 있어요.
프로덕션 환경에서 DebugProbes를 켜면, 새 코루틴마다 스택 트레이스를 만들기 때문에 애플리케이션 성능이 크게 저하될 수 있어요. 이 오버헤드를 피하려면 DebugProbes.enableCreationStackTraces를 false로 설정하세요.
kotlinx-coroutines-debug 모듈은 자동 BlockHound 통합을 제공해요. 블로킹 연산이 허용되지 않는 코루틴 컨텍스트에서 블로킹 연산을 감지하는 데 쓸 수 있어요. 설정 방법은 BlockHound 퀵 스타트 가이드를 참고하세요.
디버그 에이전트 의존성 추가
프로젝트에서 디버그 에이전트를 쓰려면 kotlinx-coroutines-debug 의존성을 추가해요.
// build.gradle.kts
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.11.0")
}
<!-- pom.xml -->
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-debug</artifactId>
<version>1.11.0</version>
<scope>test</scope>
</dependency>
디버그 에이전트로 코루틴 추적하기
디버그 에이전트로 코루틴 추적을 시작하려면 다음 중 하나를 하면 돼요.
- VM 옵션에
-javaagent:/path/to/kotlinx-coroutines-debug-1.11.0.jar를 추가해서 애플리케이션 시작 시 디버그 에이전트를 로드해요. - 추적하려는 코루틴을 시작하기 전에
DebugProbes.install()함수를 호출해요.
JDK 21부터 DebugProbes.install() 함수로 디버그 에이전트를 동적으로 로드하면 경고가 발생할 수 있어요. 이 경고를 피하려면 -javaagent VM 옵션으로 에이전트를 로드하세요.
디버그 에이전트가 활성화되면 다음 API를 사용할 수 있어요.
DebugProbes.dumpCoroutines()는 모든 활성 코루틴을 출력해요.DebugProbes.dumpCoroutinesInfo()는 활성 코루틴에 대한 정보를 반환해요.DebugProbes.printJob()은Job의 계층 구조에 대한 코루틴 덤프를 출력해요.DebugProbes.printScope()는CoroutineScope의 계층 구조에 대한 코루틴 덤프를 출력해요.
디버그 에이전트로 활성 코루틴과 특정 Job의 코루틴 계층 구조를 출력하는 예시를 볼게요.
import kotlinx.coroutines.*
import kotlinx.coroutines.debug.*
import kotlin.time.Duration.Companion.seconds
private suspend fun loadAccount() {
delay(5.seconds)
}
private suspend fun loadPreferences() {
delay(5.seconds)
}
private suspend fun loadUserProfile() = coroutineScope {
launch { loadAccount() }
launch { loadPreferences() }
}
@OptIn(ExperimentalCoroutinesApi::class)
fun main() {
// Installs the debug agent
// This is only required if you don't use the -javaagent VM option
DebugProbes.install()
runBlocking {
// Starts a coroutine with two child coroutines
val loadingJob = launch {
loadUserProfile()
}
// Gives the child coroutines time to suspend
delay(1.seconds)
// Prints all active coroutines
DebugProbes.dumpCoroutines()
println("============")
// Prints the loading job and its child coroutines
DebugProbes.printJob(loadingJob)
}
}
디버그 모드를 켜고 이 예시를 실행하면 다음과 같은 출력이 나와요.
Coroutines dump 2026/08/18 14:00:08
Coroutine "coroutine#1":BlockingCoroutine{Active}@146ba0ac, state: RUNNING
at java.base/java.lang.Thread.getStackTrace(Thread.java:2389)
at kotlinx.coroutines.debug.internal.DebugProbesImpl.enhanceStackTraceWithThreadDumpImpl(DebugProbesImpl.kt:339)
at kotlinx.coroutines.debug.internal.DebugProbesImpl.dumpCoroutinesSynchronized(DebugProbesImpl.kt:294)
at kotlinx.coroutines.debug.internal.DebugProbesImpl.dumpCoroutines(DebugProbesImpl.kt:266)
at kotlinx.coroutines.debug.DebugProbes.dumpCoroutines(DebugProbes.kt:181)
at kotlinx.coroutines.debug.DebugProbes.dumpCoroutines$default(DebugProbes.kt:181)
at DebugAgentExampleKt$main$1.invokeSuspend(DebugAgentExample.kt:34)
Coroutine "coroutine#2":StandaloneCoroutine{Active}@4dfa3a9d, state: SUSPENDED
at DebugAgentExampleKt$main$1$loadingJob$1.invokeSuspend(DebugAgentExample.kt:27)
Coroutine "coroutine#3":StandaloneCoroutine{Active}@6eebc39e, state: SUSPENDED
at DebugAgentExampleKt$loadUserProfile$2$1.invokeSuspend(DebugAgentExample.kt:14)
Coroutine "coroutine#4":StandaloneCoroutine{Active}@464bee09, state: SUSPENDED
at DebugAgentExampleKt$loadUserProfile$2$2.invokeSuspend(DebugAgentExample.kt:15)
============
"coroutine#2":StandaloneCoroutine{Active}, continuation is SUSPENDED at line DebugAgentExampleKt$main$1$loadingJob$1.invokeSuspend(DebugAgentExample.kt:27)
"coroutine#3":StandaloneCoroutine{Active}, continuation is SUSPENDED at line DebugAgentExampleKt$loadUserProfile$2$1.invokeSuspend(DebugAgentExample.kt:14)
"coroutine#4":StandaloneCoroutine{Active}, continuation is SUSPENDED at line DebugAgentExampleKt$loadUserProfile$2$2.invokeSuspend(DebugAgentExample.kt:15)
JUnit 테스트가 타임아웃될 때 활성 코루틴 출력하기
JUnit 버전에 해당하는 CoroutinesTimeout API로 JUnit 테스트에 타임아웃을 설정할 수 있어요. 이 API는 디버그 프로브를 자동으로 설치해요. 테스트가 타임아웃 전에 완료되지 않으면 모든 활성 코루틴과 그 스택 트레이스를 출력하고 테스트를 실패로 처리해요.
JUnit 4
JUnit 4 테스트에 타임아웃을 설정하고, 타임아웃을 넘기면 모든 활성 코루틴과 그 스택 트레이스를 출력하려면 CoroutinesTimeout 규칙을 사용해요.
import kotlinx.coroutines.*
import kotlinx.coroutines.debug.junit4.CoroutinesTimeout
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration
@OptIn(ExperimentalCoroutinesApi::class)
class UserProfileTest {
@get:Rule
val timeout = CoroutinesTimeout.seconds(1)
private suspend fun loadUserProfile() {
withContext(Dispatchers.IO) {
// Simulates an operation that doesn't complete
delay(Duration.INFINITE)
}
}
@Test
fun loadsUserProfile() = runBlocking {
val loadingJob = launch {
loadUserProfile()
}
// Waits for the coroutine, so the test doesn't complete
loadingJob.join()
}
}
1초 뒤에 규칙이 테스트가 타임아웃됐다고 보고하고 모든 활성 코루틴과 스택 트레이스를 출력해요. 그러면 테스트가 TestTimedOutException과 함께 실패해요.
Test loadsUserProfile timed out after 1 seconds
Coroutines dump 2026/08/18 13:48:21
Coroutine "coroutine#1":BlockingCoroutine{Active}@bf1ec20, state: SUSPENDED
at UserProfileTest$loadsUserProfile$1.invokeSuspend(UserProfileTest.kt:27)
at _COROUTINE._CREATION._(CoroutineDebugging.kt:30)
at kotlin.coroutines.intrinsics.IntrinsicsKt__IntrinsicsJvmKt.createCoroutineUnintercepted(IntrinsicsJvm.kt:161)
at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26)
at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK$default(Builders.concurrent.kt:157)
at kotlinx.coroutines.BuildersKt.runBlockingK$default(Unknown Source)
at UserProfileTest.loadsUserProfile(UserProfileTest.kt:21)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at kotlinx.coroutines.debug.junit4.CoroutinesTimeoutStatement$evaluate$$inlined$runWithTimeoutDumpingCoroutines$1.call(CoroutinesTimeoutImpl.kt:79)
at kotlinx.coroutines.debug.junit4.CoroutinesTimeoutStatement$evaluate$$inlined$runWithTimeoutDumpingCoroutines$1.call(CoroutinesTimeoutImpl.kt:79)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at java.base/java.lang.Thread.run(Thread.java:1575)
Coroutine "coroutine#2":StandaloneCoroutine{Active}@70efb718, state: SUSPENDED
at UserProfileTest$loadUserProfile$2.invokeSuspend(UserProfileTest.kt:16)
at UserProfileTest$loadsUserProfile$1$loadingJob$1.invokeSuspend(UserProfileTest.kt:23)
at _COROUTINE._CREATION._(CoroutineDebugging.kt:30)
at kotlin.coroutines.intrinsics.IntrinsicsKt__IntrinsicsJvmKt.createCoroutineUnintercepted(IntrinsicsJvm.kt:161)
at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:200)
at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source)
at UserProfileTest$loadsUserProfile$1.invokeSuspend(UserProfileTest.kt:22)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK$default(Builders.concurrent.kt:157)
at kotlinx.coroutines.BuildersKt.runBlockingK$default(Unknown Source)
at UserProfileTest.loadsUserProfile(UserProfileTest.kt:21)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at kotlinx.coroutines.debug.junit4.CoroutinesTimeoutStatement$evaluate$$inlined$runWithTimeoutDumpingCoroutines$1.call(CoroutinesTimeoutImpl.kt:79)
at kotlinx.coroutines.debug.junit4.CoroutinesTimeoutStatement$evaluate$$inlined$runWithTimeoutDumpingCoroutines$1.call(CoroutinesTimeoutImpl.kt:79)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at java.base/java.lang.Thread.run(Thread.java:1575)
test timed out after 1000 milliseconds
org.junit.runners.model.TestTimedOutException: test timed out after 1000 milliseconds
at java.base/jdk.internal.misc.Unsafe.park(Native Method)
at java.base/java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:269)
at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:57)
at kotlinx.coroutines.BuildersKt__BuildersKt.runBlockingImpl(Builders.kt:30)
at kotlinx.coroutines.BuildersKt.runBlockingImpl(Unknown Source)
at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK(Builders.concurrent.kt:172)
at kotlinx.coroutines.BuildersKt.runBlockingK(Unknown Source)
at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK$default(Builders.concurrent.kt:157)
at kotlinx.coroutines.BuildersKt.runBlockingK$default(Unknown Source)
at UserProfileTest.loadsUserProfile(UserProfileTest.kt:21)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at kotlinx.coroutines.debug.junit4.CoroutinesTimeoutStatement$evaluate$$inlined$runWithTimeoutDumpingCoroutines$1.call(CoroutinesTimeoutImpl.kt:79)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at java.base/java.lang.Thread.run(Thread.java:1575)
JUnit 5
클래스의 모든 테스트 함수에 타임아웃을 적용하려면 클래스에 @CoroutinesTimeout 애너테이션을 추가해요.
import kotlinx.coroutines.*
import kotlinx.coroutines.debug.junit5.CoroutinesTimeout
import org.junit.jupiter.api.Test
import kotlin.time.Duration
@OptIn(ExperimentalCoroutinesApi::class)
// Sets a one-second timeout for all test functions in the class
@CoroutinesTimeout(testTimeoutMs = 1_000)
class UserProfileTest {
private suspend fun loadUserProfile() {
withContext(Dispatchers.IO) {
// Simulates an operation that doesn't complete
delay(Duration.INFINITE)
}
}
@Test
fun loadsUserProfile() = runBlocking {
val loadingJob = launch {
loadUserProfile()
}
// Waits for the coroutine, so the test doesn't complete
loadingJob.join()
}
}
1초 뒤에 CoroutinesTimeout API가 타임아웃을 보고하고 모든 활성 코루틴과 스택 트레이스를 출력해요. 그러면 테스트가 CoroutinesTimeoutException과 함께 실패해요.
Test loadsUserProfile timed out after 1 seconds
Coroutines dump 2026/08/18 13:46:15
Coroutine "coroutine#1":BlockingCoroutine{Active}@5c77053b, state: SUSPENDED
at UserProfileTest$loadsUserProfile$1.invokeSuspend(UserProfileTest.kt:24)
Coroutine "coroutine#2":StandaloneCoroutine{Active}@26b894bd, state: SUSPENDED
at UserProfileTest$loadUserProfile$2.invokeSuspend(UserProfileTest.kt:13)
at UserProfileTest$loadsUserProfile$1$loadingJob$1.invokeSuspend(UserProfileTest.kt:20)
test timed out after 1000 ms
kotlinx.coroutines.debug.junit5.CoroutinesTimeoutException: test timed out after 1000 ms
at java.base/jdk.internal.misc.Unsafe.park(Native Method)
at java.base/java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:269)
at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:57)
at kotlinx.coroutines.BuildersKt__BuildersKt.runBlockingImpl(Builders.kt:30)
at kotlinx.coroutines.BuildersKt.runBlockingImpl(Unknown Source)
at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK(Builders.concurrent.kt:172)
at kotlinx.coroutines.BuildersKt.runBlockingK(Unknown Source)
at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK$default(Builders.concurrent.kt:157)
at kotlinx.coroutines.BuildersKt.runBlockingK$default(Unknown Source)
at UserProfileTest.loadsUserProfile(UserProfileTest.kt:18)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at kotlinx.coroutines.debug.junit5.CoroutinesTimeoutExtension$interceptInvocation$$inlined$runWithTimeoutDumpingCoroutines$1.call(CoroutinesTimeoutImpl.kt:79)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at java.base/java.lang.Thread.run(Thread.java:1575)
Android에서 kotlinx-coroutines-debug 리소스 충돌 해결하기
디버그 에이전트는 Android에서 지원되지 않아요.
kotlinx-coroutines-debug 모듈은 JNA, JNA Platform, Byte Buddy, Byte Buddy Agent에 대한 전이적(transitive) 의존성이 있어요. 이 중 일부 의존성은 같은 경로의 리소스를 포함해요. Android가 의존성 리소스를 병합할 때 중복 경로 때문에 DuplicateRelativeFileException이 발생해서 빌드가 실패할 수 있어요.
빌드 실패를 해결하면서 kotlinx-coroutines-debug 의존성은 유지하려면, build.gradle.kts 파일의 packaging 구성으로 충돌하는 리소스를 제외하면 돼요.
// build.gradle.kts
android {
packaging {
resources {
// Excludes license files from JNA and JNA Platform
excludes += setOf(
"META-INF/AL2.0",
"META-INF/LGPL2.1",
)
// Excludes the ASM license file from Byte Buddy
excludes += "META-INF/licenses/ASM"
// Retains one copy of each Byte Buddy Agent file
pickFirsts += setOf(
"win32-x86-64/attach_hotspot_windows.dll",
"win32-x86/attach_hotspot_windows.dll",
)
}
}
}
다음 단계
IntelliJ IDEA에서 코루틴을 디버깅하는 방법은 IntelliJ IDEA로 코루틴 디버깅하기와 IntelliJ IDEA로 Kotlin Flow 디버깅하기 문서를 참고하세요.
더 알아보기
- IntelliJ IDEA로 코루틴 디버깅하기 – 튜토리얼 — IDE에서의 디버깅 실습
- IntelliJ IDEA로 Kotlin Flow 디버깅하기 — Flow 디버깅 실습
- 코루틴 소개 — 코루틴 기본 개념