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

> CI에서만 죽는 테스트 하나를 쫓다 45분 hang의 원인을 두 번 진단했습니다. 첫 진단은 hang만 고쳤고, 진짜 원인은 전역 NotificationCenter를 구독하는 뷰와 병렬 실행이 만든 격리 결함이었습니다.

- Canonical: https://blog.jaemyeong.com/posts/parallel-test-global-notification-isolation/
- Published: 2026.08.18
- Category: 기술
- Tags: #iOS, #Swift, #Testing, #CI-CD, #Troubleshooting

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

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

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

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

## 처음 구현은 이랬습니다

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

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

```swift
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 자체의 메커니즘은 금방 나왔습니다.

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

## 이렇게 바꿨습니다

생성자 주입 한 줄입니다.

```swift
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` 브랜치, 커밋 `d44808bb`와 `b7646ee2`의 iOS 구현을 기준으로 합니다.

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

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

### 못 잰 것

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

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

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

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

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

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

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

## 출처

- [Swift Testing - Apple Developer](https://developer.apple.com/documentation/testing)
- [Parallelization - Swift Testing](https://developer.apple.com/documentation/testing/parallelization)
- [expect(_:_:sourceLocation:) - Swift Testing](https://developer.apple.com/documentation/testing/expect(_:_:sourcelocation:))
- [require(_:_:sourceLocation:) - Swift Testing](https://developer.apple.com/documentation/testing/require(_:_:sourcelocation:))
- [NotificationCenter - Apple Developer](https://developer.apple.com/documentation/foundation/notificationcenter)
- [DailySudoku - App Store](https://apps.apple.com/app/id1149229748)
- [DailySudoku - Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku)
- [DailySudoku 웹](https://dailysudoku.app/ko/)
