Android 개발/트러블슈팅

로컬 초록불, CI 빨간불 — Flaky 테스트 격리로 5주 만에 18.3%→2.1% 잡은 기록

stackD 2026. 7. 3. 18:00
반응형

 

금요일 저녁 6시, PR 머지만 하면 퇴근이었습니다. 근데 CI가 또 빨간불이더라고요. 로컬에선 100번을 돌려도 초록불인데 말이죠.

 

재시도 버튼을 세 번 누르고서야 겨우 통과했습니다. 다음 주에 또 같은 테스트가 빨간불을 띄웠을 때, 이제는 격리 외에 답이 없겠다 싶었어요.

 

Flaky 테스트가 PR 머지 시간을 1.7배로 늘린 대목

수치를 한 달 모아봤더니 멘붕이 오더라고요. PR 평균 머지 시간이 38분에서 65분으로 늘었고, 수동 재시도가 월 142회까지 갔어요. 1.7배라는 수치보다, PR 하나가 다음 PR을 잡고 늘어진다는 점이 더 큰 그림이었습니다.

 

더 무서운 건 따로 있었어요. "재시도 세 번에 통과했으니 됐다"고 넘긴 결제 로직 테스트가, 알고 보니 0.3% 확률의 경쟁 상태(race condition) 버그였거든요. 프로덕션에서 같은 식으로 터지면 환불 대응 회의가 한 판은 무조건 잡혀요.

 

Google의 'Taming Google-Scale Continuous Testing'(Memon et al., ICSE-SEIP 2017)을 비롯한 자료를 모아보면, 대형 CI에서 flaky가 전체 실패에서 차지하는 비중이 최대 30% 수준까지 잡힙니다. CI 시간의 15~30%가 재실행에 쓰인다는 벤치마크도 거기 같이 보이고요. 저희 팀 수치는 거의 그 상단이었어요.

 

 

CI(UTC) vs 로컬(KST), 시간대 차이부터 잡기

원인을 하나씩 까보니 첫 번째가 시간이었습니다. CI 컨테이너는 UTC로 돌고 제 맥북은 KST로 도는데, LocalDate.now()를 그냥 박아둔 테스트가 자정 경계에서 깨지더라고요. 한국 시간 새벽 1시에 돌리면 UTC로는 전날이고, 9시 이후엔 같은 날이고요.

 

해결은 Clock을 주입하는 방향으로 갔습니다.

 

class OrderService(private val clock: Clock = Clock.systemDefaultZone()) {
    fun isToday(date: LocalDate): Boolean =
        date == LocalDate.now(clock)
}

// 테스트
@Test
fun `자정 직전 주문은 당일로 잡힌다`() {
    val fixed = Clock.fixed(
        Instant.parse("2026-06-13T14:59:59Z"),
        ZoneOffset.UTC,
    )
    val service = OrderService(fixed)
    // ...
}

 

LocalDate.now()를 직접 쓰는 자리는 전부 Clock을 한 번 거치게 바꿨습니다. 처음 깔 땐 귀찮아 보이는데, 한 번 잡혀 있으면 시간대 깨짐이 다시는 안 올라오더라고요.

 

 

JUnit 5 공유 상태 격리 — @BeforeEach와 Koin 재시작

두 번째가 공유 상태였습니다. @TestInstance(PER_CLASS)로 잡아둔 클래스에서 싱글톤 DB 인스턴스가 테스트 간에 그대로 살아 있었거든요. A 테스트가 남긴 user_id 3번을 B 테스트가 자기가 만든 것처럼 굴면서 실패가 났습니다.

 

로컬에서는 단일 파일만 돌리니까 안 보였습니다. CI는 전체 스위트를 병렬로 굴리니 순서가 매번 섞이고, 한 번씩 빨간불이 떴어요.

 

@JvmField
@RegisterExtension
val koinTestExtension = KoinTestExtension.create {
    modules(testModule)
}

@BeforeEach
fun setUp() {
    database.clearAllTables()
}

 

DB를 매 테스트마다 비우고, Koin 모듈 라이프사이클은 KoinTestExtension에 위임해 전역 상태 재구성으로 인한 충돌을 없앴습니다. 실행 시간이 살짝 늘어나긴 했는데, 빨간불 디버깅에 쓰던 30분 대비 1초도 안 되는 비용이라 무조건 남는 장사였네요.

 

코루틴 디스패처 경합과 MainDispatcherExtension

세 번째가 진짜 골칫거리였어요. 안드로이드 CI 실패의 절반이 Dispatchers.Main 타임아웃과 Dispatchers.IO 코루틴 경합이었습니다.

 

ViewModel 테스트에서 흐름 제어가 필요할 땐 StandardTestDispatcher, 즉시 emit 결과를 검증하고 싶을 땐 UnconfinedTestDispatcher로 갈랐습니다. Dispatchers.setMain/resetMain 보일러플레이트는 MainDispatcherExtension 하나로 묶어 모든 ViewModel 테스트에 붙였어요.

 

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherExtension(
    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : BeforeEachCallback, AfterEachCallback {
    override fun beforeEach(context: ExtensionContext) {
        Dispatchers.setMain(dispatcher)
    }
    override fun afterEach(context: ExtensionContext) {
        Dispatchers.resetMain()
    }
}

 

적용 후 ViewModel 테스트의 flaky 비율이 8.2%에서 0.4%로 떨어졌습니다. delay()가 들어간 디바운스 로직은 testScheduler.advanceTimeBy()로 가상 시간만 진행시켰는데, 47초 걸리던 테스트가 6초로 줄었어요. 거의 8분의 1입니다.

 

@Tag("flaky") 격리 절차 — 재시도 대신 분리

여기까지 잡고도 원인이 안 잡히는 테스트가 남았습니다. 외부 결제 API의 5초 타임아웃처럼 저희가 못 건드는 영역이요.

 

이건 @Tag("flaky")를 붙여서 메인 CI에서는 빠지게 하고, continue-on-error: true로 따로 띄운 quarantineTest 잡에서 굴렸습니다. PR 머지를 막지 않으면서도 실패율은 계속 쌓이게요.

 

jobs:
  test:
    steps:
      - run: ./gradlew test -PexcludeTags=flaky
  quarantineTest:
    continue-on-error: true
    steps:
      - run: ./gradlew test -PincludeTags=flaky

 

격리할 땐 무덤이 되지 않게 주석으로 담당자와 기한을 박았습니다.

 

// owner: @donghoon, until: 2026-07-15
@Tag("flaky")
@Test
fun `결제 승인 후 콜백 도착`() { ... }

 

@Disabled로 그냥 꺼두면 실패율 추적이 끊깁니다. 격리는 실행은 하되 결과만 분리하는 방식이라, "50회 연속 성공하면 태그 자동 제거" 같은 자동화 기반이 거기서 나오더라고요. 14일 넘게 안 풀린 테스트는 자동으로 @Disabled 처리하는 정책도 그 위에 얹었습니다.

 

 

5주 만에 18.3%→2.1%, 회복된 건 CI 신뢰

JUnit 리포트를 파싱해서 Grafana로 실패율을 추적했습니다. 5주차에 평균 flaky 비율이 18.3%에서 2.1%로 안정됐어요.

 

숫자보다 더 큰 변화는 빨간불을 보는 팀의 반응이었습니다. 예전엔 "또 그거지" 하고 재시도 버튼부터 눌렀는데, 이제는 빨간불이 뜨면 실제 버그라고 보고 GitHub PR 코멘트에서 누가 먼저 잡습니다. CI에 대한 신뢰가 돌아온 게 5주짜리 작업의 진짜 결과였어요.

 

격리에 대한 반대 의견도 있습니다. "어차피 은폐다, 즉시 비활성화하고 티켓 발행이 낫다"는 입장이요. 저는 격리 쪽을 골랐는데, 실패율을 계속 보고 있어야 자동 재합류 같은 다음 단계로 갈 수 있다고 봐서요. 소규모 팀이라면 재시도 비용이 격리 시스템보다 낮을 수도 있고, 이건 팀 성숙도가 결정하는 문제라고 봅니다.

 

이번 글에선 격리 절차와 시간·공유 상태·디스패처 세 가지 원인까지만 다뤘는데요, 격리된 테스트를 자동으로 분류해주는 pytest-xflaky나 Trunk 같은 도구를 붙여본 기록은 다음 글에서 더 풀어볼 생각입니다. 자동 격리 PR이 떠주면 손이 한 번 더 빠지는 자리지요.

 

 

반응형
LIST