이미 만료된 Task.sleep은 throw하지 않는다: 버전당 1회 리뷰 요청 지키기
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이기 때문입니다.
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의 활성 상태까지 봐야 했습니다.
guard let presenter, let scene = presenter.view.window?.windowScene,
scene.activationState == .foregroundActive else { return }
이미 만료된 sleep은 throw하지 않습니다
두 번째가 이 글의 핵심입니다.
예약은 Task 안의 Task.sleep으로 구현했습니다. 취소하면 sleep이 CancellationException을 던지고, catch에서 조용히 빠져나가는 구조입니다.
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 여부와 무관하게 취소 플래그를 세웁니다. 그래서 플래그를 직접 봐야 합니다.
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과 아무 관계가 없는 판단입니다. 그래서 순수 함수로 빼뒀습니다.
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 리더보드 글에 있습니다.
판정 로직은 두 플랫폼이 같아야 하므로, 공유 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 통과. AndroidtestDebugUnitTest— 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
- requestReview(in:) - Apple Developer
- Ratings and Reviews - Apple Human Interface Guidelines
- Task.sleep(for:tolerance:clock:) - Apple Developer
- Cancellation - Kotlin coroutines
- In-app reviews (Android) - Google Play
- DailySudoku - App Store
- DailySudoku - Google Play
- DailySudoku 웹
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.