J.
BLOG
글 목록소개구독
기술

@Suite(.serialized)로는 못 막는다: Swift Testing 병렬 실행과 전역 NotificationCenter

2026.08.18·읽기 8분

DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 무료 앱이라 배너 광고를 붙였고, 그 배너를 검증하는 테스트가 몇 개 있습니다.

그중 하나가 CI에서만 죽었습니다. 로컬에서는 0.02초에 통과하는데, CI에서는 잡 전체가 45분 타임아웃까지 매달렸습니다.

원인을 두 번 진단했습니다. 첫 번째 진단으로 hang은 사라졌지만 테스트는 여전히 실패했고, 두 번째에 가서야 진짜 원인이 나왔습니다. 테스트 격리 결함이었습니다.

이 글은 그 두 번의 진단과, 흔히 처방되는 두 가지 해법이 왜 이 경우에 통하지 않는지를 정리한 기록입니다.

처음 구현은 이랬습니다

AdBannerView는 광고 관련 상태가 비동기로 바뀔 때 자기를 갱신해야 합니다. 광고 제거 구매가 반영되거나, 동의 상태가 바뀌거나, 앱이 포그라운드로 돌아오는 경우입니다.

그래서 프로세스 전역 NotificationCenter.default를 구독합니다.

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

didBecomeActiveNotification이 목록에 있는 것도 이유가 있습니다. 슬롯 활성 여부를 판정에 넣은 뒤로 「비활성 중에 멈춘 것들」이 생겼습니다. 게이트에 막혀 되돌아간 로드 시작, 그리고 미뤄진 노출 기록입니다. 그것들을 깨울 트리거가 없으면 슬롯이 빈 채로, 노출이 미기록인 채로 남습니다. viewWillAppear로는 부족합니다 — 이미 떠 있는 화면으로 복귀할 때는 그게 불리지 않기 때문입니다.

앱 코드로서는 타당한 구조입니다. 문제는 테스트 프로세스 안에서 드러났습니다.

어디서 어긋났을까요

holdsImpressionUntilTheSlotIsLiveAgain이라는 테스트가 CI에서만 실패했습니다. 그리고 실패에서 끝나지 않고 잡이 45분 타임아웃까지 매달렸습니다.

CI 러너를 여러 작업이 함께 쓰는 구성이라, 처음에는 부하 문제로 보였습니다. 실제로 그럴듯한 정황도 있었습니다.

첫 번째 진단은 hang만 고쳤습니다

hang 자체의 메커니즘은 금방 나왔습니다.

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

Swift Testing의 #expect비치명적입니다. 실패해도 그 자리에서 테스트를 끝내지 않고 다음 줄로 넘어갑니다. 그리고 다음 줄에 타임아웃 없는 while 루프가 있었습니다. 앞의 대기가 실패하면 이 루프는 영원히 돕니다.

환경 쪽 정황도 실측했습니다. 체인 형성의 MainActor 비동기 단계가 다른 작업과 경합하면 몇 초 밀립니다. 로컬 격리 실행 0.024초 대 경합 중 4초 관측창을 넘겨 놓친 사례를 확인했습니다.

무관한 원인들도 하나씩 배제했습니다. 광고 강제 라우팅 PR은 releaseForcedProvider가 DEBUG 빌드에서 무조건 nil이라 테스트 환경에 영향을 줄 수 없었고, 리뷰 요청 PR은 광고 디렉터리를 전혀 건드리지 않았습니다. git ancestry와 diff로 각각 확인했습니다.

그래서 이렇게 고쳤습니다.

  • 대기 타임아웃을 4초에서 8초로 늘려 광고 provider의 실 네트워크 타임아웃과 정렬
  • 같은 폴링 패턴의 waitForShown 헬퍼 추가
  • #expect#require로 전환 — 실패하면 그 자리에서 멈춘다
  • 타임아웃 없는 while 루프를 상한 있는 대기로 대체

이 수정으로 hang은 물리적으로 불가능해졌습니다. 체인이 안 만들어지면 그 자리에서 멈추고, 로드가 오래 걸려도 8초에서 fail-fast합니다. 이건 그 자체로 옳은 수정이었습니다.

그런데 테스트는 여전히 실패했습니다. 45분 매달리던 것이 8초에 실패하게 됐을 뿐입니다. 4초를 8초로 늘린 것은 원인을 짚지 못한 조치였습니다.

원인 — 전역 알림과 병렬 실행

환경 문제가 아니었습니다. 테스트 격리 결함이었습니다.

Swift Testing은 스위트를 병렬로 돌립니다. 그리고 AdBannerView는 프로세스 전역 NotificationCenter.default를 구독합니다. 이 둘이 만나면 이렇게 됩니다.

무관한 테스트가 SudokuStore.didChange를 발생시키면 — 그 알림을 내는 테스트 파일이 7개입니다 — 그 프로세스에 살아 있는 모든 배너의 refresh()가 동기로 불립니다. 그때 광고가 꺼진 상태면 !enabled 분기가 chain·shownKind·pendingImpression을 통째로 리셋하고 세대를 올립니다.

무관한 테스트가 서로의 뷰 상태를 지웁니다.

왜 그 테스트만 죽었을까요

같은 파일의 다른 테스트는 멀쩡했습니다. 이유가 두 겹입니다.

첫째, 리셋 분기가 열리는 시점이 다릅니다. 이 테스트의 watcher는 광고 제거 여부를 shownKind의 함수로 정의합니다. 그래서 shownKind에 값이 대입되는 순간부터 리셋 분기가 열립니다. 그 전에 오는 알림은 무해합니다 — refresh()setNeedsLayout()만 하고 끝나기 때문입니다.

둘째, 이 테스트만 회복 경로가 없었습니다. 다른 테스트는 첫 대기 앞에 layoutIfNeeded()가 있는데 이 테스트에는 없었습니다. 리셋된 배너가 재로드를 열려면 레이아웃 패스가 필요한데, 그게 오지 않으니 영영 회복하지 못합니다. 그래서 8초 타임아웃까지 갑니다.

두 조건이 겹친 테스트가 하나뿐이었던 것입니다. 로컬에서 안 죽은 이유는 단순합니다. 병렬로 함께 돌던 다른 스위트가 하필 그 타이밍에 알림을 쏘지 않았기 때문입니다.

재현으로 확정했습니다

여기까지는 가설입니다. 그래서 재현했습니다.

알림을 쏘는 Task를 하나 넣자 로컬에서 결정적으로 재현됐습니다. 그리고 그때 관측한 것이 결정적이었습니다. 병렬로 돌던 원래 테스트가 같은 줄에서 함께 죽었습니다. 한 테스트의 전역 알림이 무관한 테스트를 죽인다는 것을 직접 본 것입니다.

이렇게 바꿨습니다

생성자 주입 한 줄입니다.

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)
    }
}

기본값이 .default프로덕션 동작은 바뀌지 않습니다. 테스트 세 파일이 각자 새 인스턴스를 넘겨 전역 방송에서 격리됩니다.

이게 「테스트를 위해 캡슐화를 낮추는 것」과 다르다는 점은 짚어둘 만합니다. 이 저장소는 adsProvider·consentProvider·makeAd·logger에 이미 같은 의존성 주입을 쓰고 있습니다. 새 구멍을 뚫은 게 아니라 기존 패턴에 하나를 더한 것입니다.

프로덕션의 리셋 동작 자체는 건드리지 않았습니다. refresh()didMoveToWindow()의 리셋은 의도된 의미입니다 — 크리에이티브를 방금 떼어냈다는 뜻이니까요. 테스트가 불편하다고 프로덕션 의미를 바꾸면 안 됩니다.

기각한 대안 두 가지

이 증상에 가장 흔히 처방되는 두 가지가 여기서는 통하지 않습니다.

@Suite(.serialized) — 스위트 안만 직렬화합니다. 이 속성은 한 스위트에 속한 테스트들의 동시 실행을 막습니다. 그런데 여기서 알림을 쏘는 쪽은 대개 다른 스위트입니다. 같은 스위트를 직렬화해도 바깥에서 날아오는 방송은 그대로입니다.

타임아웃 증가 — 회복 경로가 없으면 무의미합니다. 리셋된 배너는 재로드를 여는 레이아웃 패스가 없어 영영 회복하지 않습니다. 8초를 80초로 늘려도, 무한대로 늘려도 실패합니다. 앞서 4초를 8초로 늘렸던 것이 정확히 이 함정이었습니다.

두 처방의 공통점은 증상의 형태만 보고 고른 해법이라는 것입니다. 「병렬이라 깨진다」에는 직렬화를, 「오래 기다리다 죽는다」에는 더 긴 대기를 붙이는 식입니다. 어느 쪽도 「누가 무엇을 지우는가」를 묻지 않습니다.

확인한 방법

이 글은 2026년 8월 14일 DailySudoku develop 브랜치, 커밋 d44808bbb7646ee2의 iOS 구현을 기준으로 합니다.

  • iOS xcodebuild test299 tests / 60 suites 통과. 회귀 테스트 1개를 추가해 298에서 299가 됐습니다.
  • 문제 테스트 단독 실행 0.023초. 전체 스위트 8.58초.

뮤테이션으로 회귀 테스트 자체를 검증했습니다. 격리를 .default로 되돌리면 새 회귀 테스트와 원래 테스트가 함께 실패합니다. CI에서 보던 증상 그대로입니다. 즉 이 테스트는 실제로 이 회귀를 잡습니다. 검증 코드가 진짜 결함을 탐지하는지 확인하는 방법은 검증 스크립트의 자기검증 글에서 따로 다뤘습니다.

못 잰 것

재현은 알림을 쏘는 Task를 인위적으로 넣어 만든 것입니다. 그 조건에서 원래 테스트가 함께 죽는 것까지가 직접 관측한 범위이고, CI의 특정 실행에서 어느 스위트가 알림을 쐈는지까지 좁힌 근거는 아닙니다.

SudokuStore.didChange를 내는 테스트 파일이 7개라는 것도 정적 검색 결과입니다. 그중 어느 것이 문제 시점에 실제로 돌고 있었는지는 이 근거로 알 수 없습니다.

사이드 프로젝트라서 배운 것

「CI에서만 실패한다」는 환경 문제라는 뜻이 아닙니다. 그렇게 읽는 순간 타임아웃을 늘리고 재시도를 붙이게 됩니다. 로컬과 CI의 진짜 차이는 머신 성능이 아니라 무엇이 동시에 돌고 있는가였습니다. 첫 진단에서 실측한 「경합 중 4초 초과」는 사실이었지만 원인이 아니었습니다. 맞는 데이터가 틀린 결론을 지지할 수 있습니다.

가설을 재현으로 승격시켜야 합니다. 「전역 알림 때문일 것이다」까지는 코드를 읽어서 도달했지만, 그건 그럴듯한 이야기일 뿐입니다. 알림 쏘는 Task를 넣어 원래 테스트가 함께 죽는 것을 본 순간에야 확정됐습니다. 재현 없이 고쳤다면 증상이 사라져도 그게 이 수정 때문인지 알 수 없었을 겁니다.

회귀 테스트도 검증받아야 합니다. 격리를 되돌렸을 때 실패하지 않는 회귀 테스트는 아무것도 지키지 않습니다. 테스트를 추가한 뒤에 한 번 깨뜨려 보는 데는 1분이 걸립니다.

첫 수정이 틀렸다고 버릴 필요는 없습니다. 타임아웃과 #require 수정은 원인을 짚지 못했지만, hang을 물리적으로 불가능하게 만든 것은 그 자체로 가치가 있습니다. 다음에 다른 이유로 대기가 실패해도 이제 45분이 아니라 8초에 알게 됩니다. 원인 수정과 방어 수정은 둘 다 필요하고, 다만 방어를 원인 수정으로 착각하지 않으면 됩니다.

출처

광고Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

#iOS#Swift#Testing#CI-CD#Troubleshooting
이전 글이미 만료된 Task.sleep은 throw하지 않는다: 버전당 1회 리뷰 요청 지키기다음 글경고 주석은 있는데 대조는 없었다: ITSAppUsesNonExemptEncryption 두 원천 검증
© 2026 진재명 · blog.jaemyeong.com
iOS 소프트웨어 엔지니어 · 부산