병렬 테스트가 전역 알림으로 서로를 건드린 사례

사이드 프로젝트로 만드는 스도쿠 앱 DailySudoku는 매일 한 판을 무료로 내고, 배너 광고를 붙여요. 받을 수 있는 곳은 App Store, Google Play, 웹이에요. 이 배너 광고의 iOS 테스트에서 장애가 나왔어요.

holdsImpressionUntilTheSlotIsLiveAgain이라는 테스트가 로컬에서는 약 0.02초에 통과하는데, CI 작업은 45분 제한에 닿아서 끝났어요. 첫 조치를 한 뒤에는 무한 대기가 없어졌지만 테스트는 계속 실패했어요. 코드의 기준은 2026년 8월 14일 develop 브랜치의 커밋 d44808bb와 b7646ee2의 iOS 구현이에요.

배너가 듣고 있던 알림

AdBannerView는 광고 제거 구매, 동의 변경, 포그라운드 전환에 반응해야 해요. 그래서 NotificationCenter.default에서 세 가지 알림을 관찰하고 있었어요.

배너가 관찰자를 등록하던 코드예요.

for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
    NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
}

마지막의 활성 전환 알림에는 이유가 있어요. 슬롯이 비활성인 동안 미뤄 둔 로드와 노출 처리를 다시 시작할 계기가 필요해요. 이미 떠 있는 화면으로 돌아오는 경우에는 viewWillAppear가 호출된다고 기대할 수 없어요. 이 계기가 없으면 광고 슬롯도 노출 기록도 비어 있을 수 있어요.

처음에는 러너의 부하를 의심했어요

CI 러너를 여러 작업이 함께 쓰는 구성이어서 처음에는 부하를 의심했어요. 그러다 테스트 코드에서 다른 문제를 찾았어요. #expect가 실패해도 실행은 계속되고, 그 뒤에 shownKind를 기다리는 상한 없는 while로 들어가는 흐름이었어요.

문제가 된 흐름을 줄여서 적으면 이래요. 인자와 루프 본문은 생략했어요.

#expect(await waitForChain(               // 실패해도 실행이 멈추지 않는다
    // …
))
while banner.shownKind == nil {           // 그래서 여기서 영원히 대기한다
    // …
}

시간도 재 봤어요. 이 테스트만 따로 실행하면 로컬에서 0.024초였어요. 경합이 있는 상황에서는 체인을 구성하는 MainActor의 비동기 작업이 늦어져서 4초의 관측 범위를 벗어난 사례가 있었어요.

다른 변경이 원인일 가능성도 배제했어요. 광고 라우팅을 바꾼 PR의 releaseForcedProvider는 DEBUG에서 항상 nil이에요. 리뷰를 요청한 PR에는 광고 디렉터리의 변경이 없어요. 두 PR의 ancestry와 diff로 확인했어요.

먼저 방어 조치를 넣었어요. provider의 네트워크 제한 시간에 맞춰 대기를 4초에서 8초로 늘리고, 기존의 폴링 방식으로 waitForShown을 만들었어요. #require로 실패한 뒤의 진행을 막고, 상한 없는 루프를 시간 상한이 있는 대기로 바꿨어요. 결과는 45분 대기가 8초 실패로 바뀐 것이에요. 테스트가 서로 간섭하는 문제는 그대로였어요.

다른 테스트가 보낸 알림이 배너를 초기화했어요

원인 가설은 Swift Testing의 병렬 실행과 프로세스 전체가 공유하는 알림의 결합이에요. SudokuStore.didChange를 발행하는 테스트 파일은 정적으로 검색했을 때 7개였어요. 관련 없는 테스트가 보낸 알림이, 관찰 중인 배너들의 refresh()를 동기로 호출해요. 광고가 비활성인 상태라면 !enabled 분기에서 chain, shownKind, pendingImpression을 지우고 세대를 올려요.

이 테스트만 취약했던 조건은 둘이에요.

  • watcher는 shownKind를 입력으로 광고 제거 상태를 판정해요. 값이 생긴 뒤부터 리셋될 수 있어요. 값이 생기기 전의 알림에서는 setNeedsLayout()만 해요.
  • 다른 테스트와 달리 이 테스트는 첫 대기 전에 layoutIfNeeded()를 호출하지 않아요. 그래서 리셋된 뒤에 로드를 다시 시작할 레이아웃 단계가 없어요.

두 조건이 함께 있는 테스트는 이것 하나뿐이라고 판단했어요.

로컬과 CI가 달랐던 이유도 이 가설로 설명했어요. 로컬에서 통과했을 때에는 함께 실행된 스위트가 위험한 구간에 알림을 보내지 않았다고 봤어요. 경합으로 시간이 늦어진다는 관측 자체는 맞지만, 이번 실패의 원인으로는 맞지 않았어요.

가설은 실험으로 확인했어요. 알림을 발행하는 Task를 더한 로컬 실험에서 실패를 유도할 수 있었어요. 병렬로 실행하던 기존 테스트도 같은 코드 위치에서 실패했어요.

NotificationCenter를 주입했어요

AdBannerView의 생성자에 notificationCenter: NotificationCenter = .default를 더하고, 관찰자를 주입받은 센터에 등록하도록 바꿨어요.

바꾼 생성자의 일부예요. 관찰자 등록 앞의 초기화 코드는 생략했어요.

init(
    colors: SudokuColors,
    adsProvider: AdsProvider,
    consentProvider: ConsentProvider,
    notificationCenter: NotificationCenter = .default   // 추가
) {
    // …
    for name in [SudokuStore.didChange, AdsNotifications.didChange, UIApplication.didBecomeActiveNotification] {
        notificationCenter.addObserver(self, selector: #selector(refresh), name: name, object: nil)
    }
}

테스트 파일 3개가 각자 별도의 인스턴스를 넘겨요. 기본값이 있어서 앱은 원래 쓰던 NotificationCenter를 그대로 써요. adsProvider, consentProvider, makeAd, logger를 이미 주입하고 있었으므로 같은 방식이에요. refresh()와 didMoveToWindow()의 초기화 동작은 크리에이티브를 지운다는 뜻이 있어서 그대로 뒀어요.

다른 방법도 검토했어요. @Suite(.serialized)는 그 스위트 안의 동시 실행만 제한하므로, 다른 스위트가 보내는 알림에는 효과가 없어요. 레이아웃이 다시 돌지 않는 상태에서는 8초를 80초로 늘리거나 무한히 기다려도 회복되지 않아요.

고친 것을 확인한 방법

xcodebuild test의 결과는 299 tests, 60 suites 성공이었어요. 회귀 테스트를 하나 더해서 298개에서 299개가 됐어요. 문제의 테스트만 실행하면 0.023초, 전체는 8.58초였어요.

새 테스트가 결함을 잡는지도 확인했어요. 센터 격리를 .default로 되돌리는 뮤테이션을 넣으면 새 테스트와 기존 테스트가 모두 실패했어요. 검증 코드에 일부러 실패 조건을 넣어 보는 방법은 검증 스크립트에 대조군을 적용한 글에서 다뤘어요.

이번 일에서 남긴 기준은 넷이에요. CI에서만 실패한다는 이유로 성능 문제라고 단정하지 않아요. 코드 분석으로 세운 가설은 재현으로 확인해요. 회귀 테스트는 결함을 다시 넣어서 실패하는지 봐요. 시간 상한과 실패 뒤의 진행 차단은 격리 수정과 별개로 가치가 있으므로 함께 남겨요.

직접 관측한 것은 인위적으로 만든 알림 조건에서의 실패예요. 실패한 CI 실행에서 실제로 어느 스위트가 알림을 보냈는지는 찾지 못했어요. 파일이 7개라는 검색 결과로는 그때의 발신자를 확정할 수 없어요. 각 테스트 사례에 별도의 센터를 만드는지에 관한 설명은 이 글에 없어요.