
테스트가 흔들리는 진짜 범인은 테스트 코드가 아니었어요. 프로덕션에 박아둔 Dispatchers.IO 한 줄, 이게 느림과 flaky를 동시에 만들고 있었습니다.
지난달에 debounce 로직 테스트를 짜다가 딱 이 벽에 부딪혔거든요. delay(5000) 하나 때문에 검증 하나가 5초씩 멈추고, 그게 다섯 개 쌓이니 스위트 전체가 30초를 넘어가더라고요. 더 짜증났던 건 로컬에선 초록불인데 CI에서만 빨간불이 뜨는 거였어요. 재실행하면 또 통과하고요. 이 코루틴 테스트 runTest 처방으로 느림과 흔들림을 한 뿌리에서 잡은 과정을 before/after로 풀어보겠습니다.
delay가 실제 시간을 기다려 느리고 flaky하던 테스트
타이머나 debounce에 흔히 쓰는 delay(1000)은 코루틴 안에서 실제로 스레드를 재웁니다. 검증 다섯 건이면 5초가 그대로 쌓여요. 유닛 테스트가 수십 초로 늘어나는 순간, 개발 흐름이 확 끊기더군요.
느림이랑 흔들림은 겉으로 별개 문제처럼 보이는데요. 파고들어 보면 둘 다 "코드가 실제 시간·실제 스레드에서 돈다"는 같은 뿌리에서 나옵니다. 로컬은 통과하고 CI만 실패하는 이유도 운이 아니에요. 프로덕션 코드에 하드코딩된 Dispatchers.IO 한 줄이 여러 실제 스레드를 비결정적으로 돌리고 있었던 겁니다.

runTest 가상 시간이 delay를 즉시 흘려보내는 원리
runTest는 kotlinx-coroutines-test 1.6.0(2021년 12월)에서 runBlockingTest를 대체한 표준으로 알려져 있어요. 내부의 TestCoroutineScheduler가 시간을 관리하는 단일 소스가 됩니다.
핵심은 이거예요. 가상 시간 안에서 delay()는 스레드를 재우지 않고 가상 시계만 앞으로 당깁니다. 그래서 delay(10_000)도 실제로는 밀리초 안에 끝나요. 10초를 쉽게 설명하자면, 원래는 커피 한 모금 마실 시간을 통째로 기다려야 했던 게 눈 깜짝할 새로 줄어드는 셈입니다.
@Test
fun debounce_는_창을_넘겨야_방출한다() = runTest {
val vm = SearchViewModel()
vm.onQueryChanged("kotlin")
advanceTimeBy(301) // 300ms debounce 창을 넘김
runCurrent()
assertEquals("kotlin", vm.query.value)
// delay(300)이 있어도 실제 대기 없이 즉시 통과
}
다만 오해 하나는 짚고 가야 해요. "runTest면 delay가 무조건 스킵된다"는 틀린 얘기입니다. Retrofit 스레드풀처럼 스케줄러에 연결 안 된 디스패처에서 도는 delay는 여전히 실시간으로 대기하거든요. 가상 시계가 손이 닿는 범위 안에서만 시간을 당길 수 있는 거죠.

Dispatchers.IO 하드코딩이 흔들림을 만드는 이유와 TestDispatcher 주입
프로덕션 코드가 withContext(Dispatchers.IO)로 넘어가는 순간, 가상 시간 통제권이 그대로 빠져나갑니다. delay 스킵이 깨지고 실제 스레드 여럿이 순서 보장 없이 돌아요. 이게 "됐다 안 됐다"의 정체였습니다.
처방은 디스패처를 하드코딩하지 말고 주입하는 겁니다. 생성자 인자로 받아두면 테스트에서 TestDispatcher로 갈아끼울 수 있어요.
class UserRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun load(): User = withContext(ioDispatcher) {
// 네트워크·DB 작업
}
}
@Test
fun load_테스트() = runTest {
// testScheduler를 공유해야 가상 시간이 통합됨
val repo = UserRepository(StandardTestDispatcher(testScheduler))
val user = repo.load()
assertEquals("도도동", user.name)
}
여기서 두 가지는 꼭 챙기셔야 되는데요.
Thread.sleep()은 가상 시간과 무관해서 절대 스킵되지 않습니다. 지연이 필요하면 반드시delay()로 바꿔야 해요.- 유닛 테스트엔 안드로이드 메인 스레드가 없어서,
Dispatchers.Main을 쓰는 ViewModel을 그냥 돌리면 "Module with the Main dispatcher had failed to initialize" 예외가 터집니다.
두 번째 문제는 setup에서 메인 디스패처를 테스트용으로 갈아끼우면 해결되는데, 이때 setMain에 익명으로 새 디스패처를 넘기면 그 스케줄러가 runTest가 만드는 스케줄러와 분리돼 가상 시간이 통합되지 않으니, 디스패처를 필드로 보관해 runTest에 같은 scheduler를 넘겨야 합니다.
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain() // 안 하면 다른 테스트로 오염 전파
}
// 테스트에서: fun x() = runTest(testDispatcher.scheduler) { ... }

StandardTestDispatcher와 UnconfinedTestDispatcher 차이
runTest의 기본값인 StandardTestDispatcher는 launch로 띄운 코루틴을 자동으로 실행하지 않아요. 예약(pending) 상태로 두고, 제가 손으로 시간을 밀어줘야 돕니다. 그래서 실행 순서가 엄격하게 보장되고, 동시성 검증에 잘 맞았어요.
반면 UnconfinedTestDispatcher는 launch를 즉시 실행해요. 다만 "시간까지 알아서 전진시킨다"고 단정하면 곤란한데, delay 이후 예약된 작업은 여전히 advanceUntilIdle 같은 걸로 밀어줘야 하는 경우가 있거든요. 손이 덜 가서 편한 대신, 순서 보장이 없어서 동시성 테스트에선 오히려 새로운 흔들림을 만들 수 있습니다. 편의랑 결정성을 맞바꾸는 거죠.
제어 함수 세 개는 역할이 다르니 헷갈리면 검증 시점이 어긋납니다.
runCurrent()— 지금 가상 시간에 예약된 것만 실행합니다.advanceTimeBy(ms)— 지정한 시간만큼만 시계를 밀어요.advanceUntilIdle()— 예약된 작업이 다 소진될 때까지 끝까지 밀어붙입니다.
runCurrent가 "지금 이 순간"이라면 advanceUntilIdle은 "다 끝날 때까지"예요. debounce 창을 넘기려는데 runCurrent만 부르면 아직 예약 시간이 안 됐으니 방출이 안 잡히는 식입니다.
개인적으로는 초심자일수록 편하다고 UnconfinedTestDispatcher로 도망치기보다, 처음부터 StandardTestDispatcher로 시간을 손으로 미는 감각을 익히는 쪽을 추천하고 싶네요. 익숙해지는 데 며칠 걸리지만, 그 뒤론 흔들림 원인을 눈으로 짚게 되더군요.

안드로이드 Flow 테스트에 Turbine 곁들여 방출 순서 검증
StateFlow나 SharedFlow가 연속으로 값을 뿜을 때, 수동 collect로는 중간 방출을 놓치기 쉽습니다. 이럴 때 터빈(Turbine)의 test { awaitItem() }을 쓰면 방출 하나하나를 결정적으로 확인할 수 있어요.
@Test
fun 검색_상태가_순서대로_방출된다() = runTest {
viewModel.uiState.test {
assertEquals(UiState.Idle, awaitItem())
viewModel.onQueryChanged("kotlin")
advanceTimeBy(301) // debounce 창 넘김
assertEquals(UiState.Loading, awaitItem())
assertEquals(UiState.Success, awaitItem())
cancelAndConsumeRemainingEvents()
}
}
가상 시간이랑 조합하면 advanceTimeBy로 창을 넘긴 뒤 awaitItem으로 방출을 확인하는 흐름이 깔끔하게 잡힙니다.
다만 한계도 정직하게 말씀드릴게요. 가상 시간은 실제 성능이나 레이스 컨디션, 사용자가 체감하는 지연 UX까지는 검증하지 못합니다. delay를 스킵해버리니까요. 진짜 타이밍 버그는 테스트를 다 통과하고 나서 프로덕션에서 터질 수 있어요. 그리고 flaky가 전역 싱글턴이나 공유 상태 때문이라면, runTest로 시간을 통제해도 해결이 안 됩니다. 만병통치약은 아니라는 얘기입니다.
시간과 스레드를 어디서 통제하는가
돌이켜보면 제 테스트가 느리고 흔들렸던 건 프레임워크 탓이 아니었어요. 시간과 스레드를 통제하는 손잡이가 프로덕션 코드 안쪽으로 숨어 있었던 겁니다. runTest로 시간을 잡고, 디스패처를 주입해서 스레드를 잡으니 흔들림 대부분이 사라졌습니다.
delay(5000) 다섯 개로 30초를 넘기던 스위트가, 다음 CI에선 밀리초 단위로 끝나 초록불이 뜨는 화면을 보시게 될 거예요. 결국 손에 쥐어야 했던 건 시간과 스레드, 그 두 손잡이가 전부였습니다.
'Android 개발 > 트러블슈팅' 카테고리의 다른 글
| Baseline Profiles로 콜드 스타트 324ms→229ms 줄인 실측 일지 (0) | 2026.10.22 |
|---|---|
| 안드로이드 StrictMode로 '가끔 뚝 끊김'을 매번 재현되는 위반으로 바꾼 일지 (0) | 2026.10.18 |
| DataStore 마이그레이션 실전기: 설정값 손실 없이 SharedPreferences 걷어낸 일지 (0) | 2026.10.13 |
| 6년차도 커서만 깜빡였다: 낯선 레거시 코드 읽는 순서 (0) | 2026.10.11 |
| 에뮬레이터 3분 부팅을 90밀리초로: Robolectric로 JVM에서 UI 테스트 돌린 일지 (0) | 2026.10.09 |