# 이미 만료된 Task.sleep은 throw하지 않는다: 버전당 1회 리뷰 요청 지키기

> AppStore.requestReview(in:)는 실패를 알려주지 않는데 게이트는 버전당 1회입니다. 같은 설계를 iOS와 Android에 옮겼더니 Swift Concurrency와 Kotlin의 취소 의미론 차이로 iOS에만 결함이 남았습니다.

- Canonical: https://blog.jaemyeong.com/posts/task-sleep-cancellation-review-prompt-gate/
- Published: 2026.08.18
- Category: 기술
- Tags: #iOS, #Swift, #StoreKit, #Concurrency, #Android

DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 최근에 게임을 이긴 뒤 스토어 리뷰를 요청하는 기능을 넣었습니다.

iOS는 `AppStore.requestReview(in:)`을 씁니다. 이 API에는 성질이 두 개 있는데, 둘이 겹치면 다루기가 까다로워집니다.

**첫째, 실패를 알려주지 않습니다.** 반환값이 없고 완료 콜백도 없습니다. 다이얼로그가 떴는지 안 떴는지 앱은 알 수 없습니다.

**둘째, 시스템이 표시 횟수를 제한합니다.** 여기에 더해 제 앱은 자체적으로 「버전당 1회」 게이트를 뒀습니다. 한 버전에서 한 번 요청하면 다음 릴리스까지 다시 묻지 않습니다.

두 성질을 합치면 이렇게 됩니다. **아무도 보지 않는 화면에 대고 호출해도 앱은 성공한 줄 압니다.** 그리고 그 조용한 실패가 이번 버전의 유일한 기회를 태웁니다.

이 글은 그 조용한 실패를 막으려고 짠 설계와, 그 설계에 두 번 남아 있던 구멍, 그리고 같은 설계를 Android에 옮겼을 때 **iOS에만 결함이 생긴 이유**를 정리한 기록입니다.

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

「이겼을 때 요청한다」를 그대로 코드로 옮기면 완료 이벤트에서 `requestReview`를 부르게 됩니다. 그런데 완료 이벤트 시점에는 결과 화면이 실제로 표시될지, 표시된다면 얼마나 오래 떠 있을지 알 수 없습니다.

그래서 처음부터 세 단계로 나눴습니다.

| 단계 | 시점 | 하는 일 |
| --- | --- | --- |
| `markPending()` | 완료 이벤트 | 자격 판정이 참이면 **신호만** 세운다. StoreKit을 전혀 건드리지 않는다 |
| `screenDidShowCompletion()` | 결과 화면이 실제로 표시될 때 | pending이면 **그 시점부터** 2초 뒤 호출을 예약한다 |
| `screenWillDisappear()` | 결과 화면이 사라지기 직전 | 예약이 있으면 취소한다 |

2초 지연을 완료 **이벤트**가 아니라 화면 **표시** 시점부터 재는 것은 Apple 공식 샘플과 같습니다. 그리고 예약 시점에 `windowScene`을 캡처해두지 않고, 2초 뒤 발화 시점에 `presenter.view.window`를 **새로 다시** 역참조합니다. 그 사이 사용자가 화면을 떠났으면 `presenter`가 살아 있어도 `.view.window`는 이미 `nil`이기 때문입니다.

```swift
private func fire(presenter: UIViewController?, repository: ReviewPromptRepository) {
    scheduledTask = nil
    guard let presenter, let scene = presenter.view.window?.windowScene else { return }
    let version = ReviewPromptConfig.currentAppVersion
    let epochDay = EpochDays.today(millis: Int64(Date().timeIntervalSince1970 * 1000))
    // 시도 기록은 여기에서만 합니다.
    repository.recordAttempt(epochDay: epochDay, version: version)
    AppStore.requestReview(in: scene)
}
```

`recordAttempt`를 `markPending()`이 아니라 여기서 부르는 것도 의도입니다. 승리 직후 곧장 홈으로 나가 취소된 pending까지 「시도」로 세면, 쿨다운을 헛되이 소모하게 됩니다.

여기까지가 최초 구현입니다. 설계는 옳았습니다. 구멍은 **그 설계를 실행하는 방식**에 있었습니다.

## 어디서 어긋났을까요

코드 리뷰가 이 파일을 두 번에 걸쳐 잡았습니다. 이틀 간격이었고, 둘 다 같은 문장으로 요약됩니다. **가드가 참인데 화면은 이미 없었습니다.**

첫 번째는 백그라운드 전환이었습니다. 2초를 기다리는 동안 사용자가 홈 버튼을 누르면, 앱은 백그라운드로 갑니다. 그런데 `presenter.view.window`는 여전히 값이 있습니다.

두 번째가 더 미묘했습니다. `screenWillDisappear()`가 `cancel()`을 부르는데도 `fire`가 실행되는 경로가 있었습니다.

## 원인과 수정

두 결함이 서로 독립적이라, 각각 원인과 고친 코드를 함께 봅니다.

### 백그라운드에서도 window는 살아 있습니다

UIKit은 앱이 백그라운드로 가도 화면을 window에서 제거하지 않습니다. 뷰 계층은 그대로 있고, `presenter.view.window`도 그대로 값을 돌려줍니다.

그러니 「window가 있는가」만 확인하는 가드는 백그라운드를 걸러내지 못합니다. 아무것도 표시할 수 없는 상태에서 `recordAttempt`가 실행되고 `requestReview`가 나갑니다. 그리고 이 API는 실패를 알려주지 않으니, 게이트만 조용히 사라집니다.

scene의 활성 상태까지 봐야 했습니다.

```swift
guard let presenter, let scene = presenter.view.window?.windowScene,
      scene.activationState == .foregroundActive else { return }
```

### 이미 만료된 sleep은 throw하지 않습니다

두 번째가 이 글의 핵심입니다.

예약은 `Task` 안의 `Task.sleep`으로 구현했습니다. 취소하면 `sleep`이 `CancellationException`을 던지고, `catch`에서 조용히 빠져나가는 구조입니다.

```swift
scheduledTask = Task { [weak presenter] in
    do { try await Task.sleep(for: .seconds(2)) }
    catch { return }   // 취소됨 — 여기서 끝난다고 생각했습니다
    fire(presenter: presenter, repository: repository)
}
```

문제는 **경계에 있는 순간**입니다.

`sleep`이 이미 만료돼 continuation이 MainActor 큐에 올라간 상태를 생각해 봅시다. 아직 실행되지는 않았고, 큐에서 차례를 기다리고 있습니다. 이때 `screenWillDisappear`가 `cancel()`을 부릅니다.

**이미 완료된 `sleep`은 throw하지 않습니다.** 취소는 「앞으로 기다릴 시간」에 작용하지, 이미 끝난 대기를 소급해서 실패시키지 않습니다. continuation은 그대로 재개되고, `catch`를 건너뛰고, `fire`로 진행합니다.

그리고 그 순간 화면은 사라지는 **전환 중**입니다. window는 아직 있고 scene도 아직 `foregroundActive`라, 방금 추가한 가드까지 통과합니다. 아무도 보지 않는 화면에 대고 `recordAttempt`와 StoreKit 호출이 나가 버전당 1회 게이트를 태웁니다.

`cancel()`은 `sleep`의 throw 여부와 무관하게 취소 플래그를 세웁니다. 그래서 플래그를 직접 봐야 합니다.

```swift
do { try await Task.sleep(for: .seconds(2)) }
catch { return }
guard !Task.isCancelled else { return }   // 이 줄이 있어야 합니다
fire(presenter: presenter, repository: repository)
```

`catch`와 `guard`가 둘 다 필요합니다. `catch`는 「아직 기다리는 중에 취소된 경우」를, `guard`는 「기다림이 끝난 뒤 취소된 경우」를 잡습니다.

### Android에는 같은 결함이 없었습니다

같은 설계를 Android에도 옮겼는데, 이 결함은 iOS에만 있었습니다. 이유가 두 겹입니다.

**첫째, Kotlin의 `delay`는 재개 시점에 취소를 확인합니다.** `CancellationException`을 던지므로 「이미 완료된 sleep」 문제 자체가 생기지 않습니다. Swift의 `Task.sleep`은 그 확인을 대기 구간에서만 합니다.

**둘째, 게이트를 태우는 호출이 취소 지점 뒤에 있습니다.** Android에서 시도를 기록하는 `recordPrompt`는 `suspendCancellableCoroutine` **뒤**에 있어서, 그 취소 지점을 반드시 통과해야 도달합니다. iOS의 `recordAttempt`는 **동기 함수**라 그런 관문이 없었습니다.

정리하면 이렇습니다.

| | iOS | Android |
| --- | --- | --- |
| 대기 API | `Task.sleep` — 만료된 뒤 `cancel()`은 throw하지 않음 | `delay` — 재개 시점에 취소 확인 |
| 게이트 소모 지점 | `recordAttempt` (동기) | `recordPrompt` (취소 지점 뒤) |
| 결과 | 취소 뒤에도 도달 가능 | 구조적으로 도달 불가 |

크로스플랫폼으로 같은 설계를 옮길 때 저는 「대칭이어야 한다」를 기본값으로 삼습니다. 여기서는 그 대칭이 성립하지 않았고, 성립하지 않는 이유가 런타임의 취소 의미론 차이였습니다. **한쪽에서 안전한 패턴이 다른 쪽에서도 안전하리라는 보장은 없습니다.**

## 판정과 실행을 갈랐습니다

「언제 요청할 것인가」는 StoreKit과 아무 관계가 없는 판단입니다. 그래서 순수 함수로 빼뒀습니다.

```swift
static func shouldRequestReview(
    wonCompletionCount: Int,
    lastAttemptEpochDay: Int64,
    todayEpochDay: Int64,
    lastPromptedVersion: String?,
    currentVersion: String,
    forceOverride: Bool
) -> Bool {
    if forceOverride { return true }
    guard wonCompletionCount >= ReviewPromptConfig.wonCompletionThreshold else { return false }
    guard todayEpochDay - lastAttemptEpochDay >= ReviewPromptConfig.cooldownDays else { return false }
    if let lastPromptedVersion, lastPromptedVersion == currentVersion { return false }
    return true
}
```

값만 다루고 StoreKit을 건드리지 않으니, 유닛 테스트가 `AppStore` 접촉을 하나도 켜지 않고 진리표 전체를 잠글 수 있습니다. 리더보드 제출에서 같은 이유로 판정과 실행을 갈랐던 것과 같은 구조입니다. 그때 정리한 내용은 [Game Center·Play Games 리더보드 글](/posts/daily-leaderboard-date-boundary-submission-reliability/)에 있습니다.

판정 로직은 두 플랫폼이 같아야 하므로, 공유 JSON 픽스처 12케이스를 양쪽이 각자 읽어 parity 테스트를 돌립니다. 여기서도 비대칭이 하나 나왔습니다. **Android 쪽 케이스 수 검증이 `>= 10`이라 픽스처가 드리프트해도 잡지 못했습니다.** iOS와 같이 정확히 12건을 요구하도록 맞췄습니다. 「최소 몇 개 이상」은 「같은 것을 보고 있는가」를 검사하지 못합니다.

승리 횟수 임계값과 쿨다운 일수는 제가 직접 정했습니다. Apple HIG의 *"사용자가 의견을 형성할 시간을 갖기 전에 요청하지 말라"*는 권고와 **긴장 관계에 있다는 걸 알면서** 고른 값입니다. 플랫폼 자체 상한이 최종 백스톱 역할을 한다고 보고 내린 결정이지, 권고를 그대로 따른 값은 아닙니다. 그래서 상수 정의 옆에 그 사실을 주석으로 남겼습니다.

## 확인한 방법

이 글은 2026년 8월 15일 DailySudoku `develop` 브랜치, 커밋 `eeb4d90d`·`9884d36f`·`fa69105c`의 iOS 구현을 기준으로 합니다.

- iOS `xcodebuild test` — **287 tests / 60 suites 통과**. Android `testDebugUnitTest` — 297 tests / 42 classes 통과.
- 자격 판정 진리표는 공유 픽스처 12케이스로 양 플랫폼이 각자 검증.

### 못 잰 것

이 기능은 자동화 검증이 유난히 어렵습니다. 세 가지를 못 쟀습니다.

**시스템 다이얼로그가 실제로 떴는지 확인할 수 없습니다.** `AppStore.requestReview(in:)`이 결과를 앱에 알려주지 않기 때문입니다. 이 글의 전제이자 검증의 한계입니다.

**취소 경로를 실행하는 자동화 테스트가 양 플랫폼 다 없습니다.** 코드 추적과 정적 call-graph로 검증했습니다. iOS는 이런 테스트 파일 자체가 없고, 있었더라도 xctestplan의 킬스위치가 실제 호출 지점을 막았을 것입니다. pending 신호가 세팅되는지 여부까지는 유닛 테스트로 덮여 있습니다. **이 글이 설명한 두 결함은 테스트가 아니라 코드 리뷰가 잡았습니다.**

**TestFlight로는 확인할 수 없습니다.** Apple 문서에 따르면 TestFlight 빌드에서는 `AppStore.requestReview`가 애초에 효과가 없습니다. 그래서 실기기 확인은 Xcode 디버그 빌드에서 자격 조건을 전부 무시하는 강제 트리거 런치 인자로 합니다.

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

**조용히 실패하는 API는 되돌릴 수 없는 자원과 만날 때만 위험합니다.** `requestReview`가 실패를 안 알려주는 것 자체는 문제가 아닙니다. 그 호출이 버전당 1회 게이트를 소모하기 때문에 문제가 됩니다. 코드를 볼 때 「이 호출이 실패하면 무엇을 잃는가」를 먼저 묻는 편이, 「이 호출이 성공하는가」를 묻는 것보다 결함을 잘 찾습니다.

**대칭을 기본값으로 두되, 런타임이 다르면 검증해야 합니다.** iOS와 Android에 같은 설계를 옮겨놓고 「양쪽 다 됐다」고 넘어갔다면 이 결함은 남았을 겁니다. 두 플랫폼의 취소 의미론이 다르다는 것을 확인하고 나서야, 왜 한쪽에만 결함이 생겼는지 설명할 수 있었습니다. 대칭이 깨지는 곳에는 근거를 남겨두는 편이 낫습니다.

**테스트가 못 잡는 영역이 있다는 걸 인정하는 것도 검증입니다.** 이 기능의 핵심 결함 두 개는 전부 코드 리뷰가 잡았습니다. 자동화로 덮을 수 없는 영역이라면, 덮은 척하는 것보다 어디까지 덮였는지 적어두는 편이 다음 사람에게 도움이 됩니다. 혼자 만드는 앱에서는 그 다음 사람이 대체로 몇 달 뒤의 저입니다.

**수치를 권고와 다르게 정했다면 그 사실을 적어둡니다.** 자격 임계값과 쿨다운은 HIG 권고보다 공격적인 쪽으로 골랐습니다. 상수 옆에 「권고와 긴장 관계임을 알고 고른 값」이라고 적어두지 않으면, 나중에 읽는 사람은 그것을 검토된 기본값으로 오해합니다.

## 출처

- [Requesting App Store reviews - Apple Developer](https://developer.apple.com/documentation/storekit/requesting-app-store-reviews)
- [requestReview(in:) - Apple Developer](https://developer.apple.com/documentation/storekit/appstore/requestreview(in:))
- [Ratings and Reviews - Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/ratings-and-reviews)
- [Task.sleep(for:tolerance:clock:) - Apple Developer](https://developer.apple.com/documentation/swift/task/sleep(for:tolerance:clock:))
- [Cancellation - Kotlin coroutines](https://kotlinlang.org/docs/cancellation-and-timeouts.html)
- [In-app reviews (Android) - Google Play](https://developer.android.com/guide/playcore/in-app-review)
- [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/)
