
같은 테스트 코드인데 6.2초가 0.4초로 줄어들었습니다. 화면 50개 중 8개에서만 새던 5초 지연을, 최근 androidx.compose.ui-test 라인에 새로 들어온 API 하나로 막은 일지예요.
지난주에 사내 슬랙으로 CI 알림이 또 빨갛게 떠서 로그를 까봤는데, 평소 8분 안에 끝나던 UI 테스트 묶음이 14분을 찍었습니다. 새로 추가된 테스트는 4개뿐이었는데 말이지요. 범인을 잡으려 시간 프로파일링을 돌려보니 특정 화면 8개에서만 어셔션 한 번에 5초씩 새고 있더라고요.
runWithoutImplicitWait, 상태 검사용으로 새로 깔린 API
androidx.compose.ui-test 의 ComposeTestRule 과 ComposeUiTest 에 새로 깔린 람다 블록입니다. 릴리즈 노트에는 "상태 검사 중 테스트 성능을 개선하기 위해 도입했다"고 적혀 있어요. 블록 안에서 어셔션·액션 직전마다 자동 호출되던 waitForIdle() 을 끄는 스위치라고 보시면 됩니다.
5초가 어느 정도냐면, 60프레임 UI 기준으로 300프레임 동안 메인 스레드가 idle 신호를 기다리고만 있었다는 얘기예요. 한 테스트 안에 어셔션 10개면 그것만으로 50초가 사라지는 구조입니다.
다만 이 API는 ui-test 라인에 비교적 최근 들어온 축이라, 도입 버전과 시그니처는 사용 중인 Compose 버전의 릴리즈 노트에서 한 번 확인하고 쓰시는 걸 권합니다. 저는 이 변경 묶음을 디버깅용 모듈에 격리해 끼워 넣고, CI 메인 빌드는 검증된 stable 라인을 유지하는 식으로 굴리고 있어요. 새 API를 메인 파이프라인에 바로 태우기 전에 한 단계 떼어두는 평소 습관입니다.
rememberInfiniteTransition이 5초씩 idle 신호를 막던 자리
문제의 8개 화면을 펼쳐보니 공통점이 딱 하나였어요. 스켈레톤 로딩이나 Lottie 펄스 같은 무한 애니메이션을 rememberInfiniteTransition 으로 돌리고 있었습니다.
Compose 테스트의 암묵적 동기화는 Recomposer, Layout, Draw, 애니메이션이 모두 멈춰야 idle 신호를 흘립니다. 근데 무한 애니메이션이 한 컴포저블에라도 살아 있으면 idle 신호는 영원히 오지 않거든요.
저희 프로젝트는 composeTestRule.waitUntil(timeoutMillis = 5_000) 을 어셔션 직전마다 깔아둔 헬퍼가 있었는데, 이게 매번 5초를 꽉 채우고 떨어지고 있었어요. 화면당 어셔션 다섯 번이면 25초가 그냥 증발하는 구조였습니다.
waitForIdle과 runWithoutImplicitWait, 어느 자리에서 다른가
waitForIdle() 은 모든 어셔션 직전에 자동으로 한 번씩 호출되는 암묵적 동기화입니다. 편의 기능이긴 한데, idle 이 영원히 안 오는 화면에서는 곧장 지연원으로 바뀌어요.
runWithoutImplicitWait 는 람다 블록 안에서 그 자동 호출을 통째로 끕니다. 대신 프레임 진행 책임이 개발자에게 넘어와요. mainClock.autoAdvance = false 로 프레임을 멈춰 세우고, advanceTimeByFrame() 으로 한 칸씩 굴리는 패턴이 짝꿍입니다.
@Test
fun animationTest_isFast() {
// 1. 프레임 자동 진행 중단
rule.mainClock.autoAdvance = false
rule.setContent { MyAnimatedComponent() }
// 2. 암묵적 동기화 비활성화
rule.runWithoutImplicitWait {
repeat(60) { // 60 프레임 검사
// 동기화 대기 없이 노드 속성 즉시 샘플링
rule.onNodeWithTag("myNode").assertExists()
// 3. 프레임 수동 진행
rule.mainClock.advanceTimeByFrame()
}
}
}
이 패턴을 8개 화면에 적용한 결과, 평균 6.2초가 0.4초로 떨어졌어요. CI 전체 시간은 14분에서 9분 30초로 돌아왔습니다. 이 수치는 저희 화면 구성 기준이라 환경에 따라 다릅니다.
Compose 테스트에서 밟았던 함정 3가지
1. Stale 노드 검증
동기화를 끄면 이전 프레임의 노드 트리를 그대로 검증하는 일이 생깁니다. advanceTimeByFrame() 을 어셔션 직전에 명시적으로 한 번 더 끼워 넣지 않으면, 한 프레임 늦은 상태를 검증해서 false positive 가 나오더라고요.
2. Snapshot 동시성 충돌
Snapshot.takeMutableSnapshot() 으로 별도 검증을 돌리는 자리는 동기화가 꺼진 상태에서 위험합니다. 저는 그 검증 한 토막만 runWithoutImplicitWait 바깥으로 빼서, 동기화가 살아 있는 람다로 분리해뒀어요.
3. 테스트 격리 실패
mainClock.autoAdvance = false 를 켜둔 채 테스트가 끝나면 다음 테스트까지 시계가 멈춘 상태로 흘러갑니다. @After 에 mainClock.autoAdvance = true 를 박아 복원하지 않으면 후속 테스트가 줄줄이 깨져요.
ComposeTestRule 성능 디버깅 체크리스트
느린 UI 테스트를 마주했을 때 제가 돌리는 절차예요.
- 느린 테스트만 분리해
--profile로 어셔션별 소요 시간 찍기 rememberInfiniteTransition또는 Lottie 사용 화면 후보 마킹- 의심 테스트에서
mainClock.autoAdvance = false부터 적용 runWithoutImplicitWait블록 +advanceTimeByFrame()으로 수동 제어
개인적으로 무한 애니메이션을 테스트 환경에서 아예 꺼버리는 방식보다, 위 순서대로 가져가는 쪽을 더 추천합니다. 애니메이션을 끄려면 컴포저블에 테스트용 분기를 박아야 하는데, 한두 군데가 아니면 프로덕션 코드가 지저분해지거든요.
새 API 의존은 디버깅용 sourceSet 으로 격리하고, CI 메인은 검증된 라인을 유지하는 편이 안전해요. 저는 androidTestDebug 쪽에만 이 의존성을 떼어 끼워 두고 있습니다.

느린 UI 테스트의 진짜 범인은 idle 대기 자리
이번 사건의 진짜 원인은 무한 애니메이션이 아니라 idle 을 기다리는 테스트 로직 쪽이었어요. 무한 애니메이션 + 암묵적 waitForIdle + 5초 waitUntil 의 3중 조합이 화면당 25초씩을 갉아먹고 있었습니다. runWithoutImplicitWait 는 동기화를 끄는 스위치이고, 그 대가로 프레임 제어 책임이 개발자에게 넘어와요.
결국 느린 UI 테스트의 범인은 UI 가 아니라 idle 대기 지점에 숨어 있는 셈이지요.
'Android 개발 > Jetpack Compose' 카테고리의 다른 글
| @PreviewWrapper 하나로 디자인 시스템 프리뷰 일관성 잡기 (0) | 2026.06.26 |
|---|---|
| LazyColumn 41번 재구성, Quail Layout Inspector로 진범 잡은 일주일 (0) | 2026.06.23 |
| 안드로이드 위젯 통합 테스트, remote-testing으로 뚫린 사각지대 (0) | 2026.06.03 |
| Jetpack Compose 2026.05.00 BOM 살펴보기 — MeshGradient·customizable shadows·defaultFontFamily (0) | 2026.05.31 |
| MeshGradient Modifier, 한 줄로 끝난다는 말의 진짜 의미 (0) | 2026.05.30 |