리뷰 요청 예약에서 취소된 Task가 끝까지 실행된 경로

매일 스도쿠 한 판을 내는 개인 프로젝트 DailySudoku에, 이긴 뒤에 스토어 평가를 요청하는 기능을 넣었어요. 스토어와 웹의 주소는 App Store, Google Play, 웹이에요.

iOS의 AppStore.requestReview(in:)는 창이 실제로 떴는지 알려 주지 않고, 끝났다는 통지도 없어요. 시스템이 표시 빈도를 제한하고, 앱도 버전마다 한 번만 요청하도록 제한해요. 화면이 요청하기에 알맞지 않은 순간에 시도 이력을 남기면 그 릴리스의 요청 기회를 써 버리게 돼요. 코드 리뷰 두 번이 그런 경로를 하나씩 찾았어요.

이 글의 이전 판은 Swift의 취소 오류 이름과 Task.sleep의 동작을 틀리게 적었어요. 2026년 10월 4일에 Swift 6.4에서 독립된 프로그램으로 취소 동작을 다시 실행해 보고 고쳤어요. 앱의 테스트와 실제 경합은 이번에 다시 실행하지 않았어요.

요청을 예약하는 흐름

승리로 게임이 끝나면 markPending()이 자격을 확인하고 pending만 표시해요. 이때 StoreKit은 부르지 않아요. 완료 화면이 보이면 screenDidShowCompletion()이 pending일 때만 그 시점부터 2초 뒤의 실행을 예약하고, 화면을 벗어나면 screenWillDisappear()가 예약을 취소해요. 지연을 두는 방식은 Apple의 리뷰 요청 안내의 샘플과 같은 기준이에요.

실행하는 쪽인 fire는 예약할 때의 scene을 저장해 두지 않고, 실행 직전에 presenter.view.window?.windowScene을 조회해요.

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

시도 이력은 여기에서만 남겨요. pending을 표시할 때 저장하지 않는 것은, 취소된 예약에 쿨다운이 걸리지 않게 하기 위해서예요. 화면을 벗어나면 window가 nil일 것이라고 가정했는데, 전환 중이거나 백그라운드에 있을 때에는 이 가정이 맞지 않을 수 있어요.

첫 번째 지적: 백그라운드에서도 window는 남아요

첫 리뷰가 찾은 것은 2초를 기다리는 동안 앱이 백그라운드로 가는 경우예요. 백그라운드에서도 UIKit의 window 연결은 남을 수 있어서, window가 있다는 것만으로는 요청해도 되는 상태인지 판단할 수 없어요.

scene이 .foregroundActive인지 확인하는 조건을 더했어요.

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

화면이 전환되는 도중에는 이 조건도 요청하기 알맞은 상태를 보장하지 않아요.

두 번째 지적: 취소했는데 fire까지 간 경로

이틀 뒤의 리뷰가 찾은 것은 화면을 벗어나 예약을 취소했는데도 fire가 실행될 수 있는 경로예요. 예약은 이렇게 돼 있었어요.

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

취소되면 Task.sleep이 오류를 던지고 catch에서 끝난다고 생각했어요. 이 생각은 절반만 맞아요. 이전 판에서는 이 오류를 CancellationException이라고 적었는데, 그것은 Kotlin의 이름이에요. Swift에서는 CancellationError예요.

이번에 실행해서 확인한 동작은 이래요(Swift 6.4, 독립 프로그램).

  • 이미 취소된 Task 안에서 try await Task.sleep을 부르면 CancellationError가 나와요.
  • 30초를 기다리는 도중에 취소해도 CancellationError가 나와요.
  • Task.sleep이 정상으로 반환한 뒤에 취소하면, 다음 문장은 그대로 실행돼요. 이때 Task.isCancelled는 true예요.
  • try?로 감싸면 오류가 nil로 바뀔 뿐이고, 다음 문장이 실행돼요. 취소 상태는 그대로 남아요.
  • 취소된 상태에서 Task.checkCancellation()을 부르면 CancellationError가 나와요.

세 가지 경우를 실행한 출력이에요. before는 취소한 뒤에 sleep을 부른 경우, during은 기다리는 도중에 취소한 경우, after는 sleep이 반환한 뒤에 취소한 경우예요.

before: isCancelled=true
before: error=CancellationError, CancellationError=true
try?: nil=true, continued=true, isCancelled=true
check: CancellationError=true
during: CancellationError=true
after: sleep returned
after: continued=true, isCancelled=true
after: check CancellationError=true

after의 결과가 문제의 경로예요. sleep이 이미 반환했다면 뒤늦은 취소는 그 호출을 실패로 되돌리지 않아요. catch를 거치지 않으므로 fire까지 가요. 이전 판은 이것을 «만료된 sleep은 오류를 던지지 않는다»고 넓게 적었는데, 확인된 것은 «정상 반환한 뒤의 취소»까지예요. 타이머가 끝난 시점과 Task가 다시 실행되는 시점 사이의 정확한 경합은 이 실험으로 판정하지 못했어요.

수정은 한 줄이에요. sleep의 오류를 처리한 뒤에 지금 취소 상태인지 한 번 더 봐요.

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

catch는 기다리는 도중의 취소를, guard는 반환한 뒤의 취소를 막아요. Task.checkCancellation()으로 오류를 받아 처리하는 방법도 있어요. 이 guard가 보는 것은 그 순간의 상태예요. 확인한 뒤에 다른 실행 문맥에서 취소가 일어날 가능성까지 없애 주지는 않아요. 화면 이벤트와 이 실행이 같은 문맥에서 차례로 처리되는지는 발췌한 코드만으로 확인할 수 없어요.

Android와 비교한 것

당시에는 같은 문제가 Android 쪽에는 없다고 판단했어요. 근거는 둘이었어요. Kotlin의 취소 문서에 따르면 delay 같은 중단 함수는 중단될 때 취소를 확인해요. delay의 API 문서는 중단된 동안 취소되면 결과를 돌려줄 준비가 됐더라도 CancellationException을 던진다고 적어요. 그리고 recordPrompt가 suspendCancellableCoroutine 뒤에 놓여 있어요. 다만 이 보장은 중단된 동안 들어온 취소에 대한 것이에요. suspendCancellableCoroutine이 정상으로 돌아온 뒤 recordPrompt를 부르기 전에 취소가 들어오면, 위 실측의 after 경우처럼 다음 문장이 실행될 수 있어요. Android 코드가 그 사이에 ensureActive() 같은 확인을 하는지와 취소를 재현한 결과는 이 글에 없어서, Android 쪽이 안전하다고 단정할 수는 없어요. iOS의 recordAttempt는 동기 호출이라 취소를 확인하는 지점이 아예 없어요. Google Play의 앱 내 리뷰 쪽 흐름은 이 비교의 대상이 아니에요.

이전 판은 여기에 «Swift의 sleep은 기다릴 때에만 취소를 확인한다»고 덧붙였는데, 위 출력의 before처럼 이미 취소된 Task에서도 오류가 나오므로 그 설명은 맞지 않아요. Kotlin 쪽은 코드를 실행해서 확인한 것이 아니에요. API가 보장하는 것과 기능 전체가 안전하다는 것은 다른 문제이고, 플랫폼마다 따로 확인해야 해요.

자격 판정은 순수 함수로 뺐어요

요청해도 되는지를 계산하는 부분은 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
}

forceOverride가 켜져 있으면 먼저 허용해요. 그다음 이긴 횟수를 기준과 비교하고, 마지막 시도로부터 지난 날을 쿨다운과 비교하고, 현재 버전이 마지막으로 요청한 버전과 같으면 거부해요. StoreKit의 AppStore를 부르지 않으므로 진리표로 테스트할 수 있어요.

같은 JSON에 담긴 12개 입력을 iOS와 Android가 각자 읽어서 테스트해요. 케이스 수를 보는 Android의 검사는 >= 10에서 정확히 12로 바꿨어요. >= 10으로는 공유 픽스처가 달라지거나 두 쪽이 어긋나는 것을 잡지 못했기 때문이에요. 날짜 경계를 다루는 방식은 리더보드의 날짜 경계를 다룬 글과 이어져요.

승리 기준과 쿨다운 값은 제가 골랐어요. 사용자가 의견을 갖기 전에 요청하지 말라는 Apple의 평점과 리뷰 지침의 취지보다 이른 요청을 허용하는 값이라고 스스로 평가해요. 시스템의 상한을 추가 보호로 보고, 고른 이유를 상수 옆 주석에 적어 뒀어요. 시스템 상한이 지침의 권고를 대신해 준다는 보장은 없어요.

확인한 범위

원래의 검증은 2026년 8월 15일 develop 브랜치의 커밋 eeb4d90d, 9884d36f, fa69105c를 기준으로 했어요. iOS의 xcodebuild test는 287 tests, 60 suites가 통과했고, Android의 testDebugUnitTest는 297 tests, 42 classes가 통과했어요. 자격 판정은 두 플랫폼 모두 12케이스예요. 이 숫자는 그때의 기록이고, 이번에 다시 실행한 것이 아니에요.

자동 테스트가 덮지 못하는 것이 많아요. 이 API는 창을 표시했는지를 알려 주지 않으므로, 자동 테스트로는 시스템 창이 실제로 떴는지를 확인하지 못해요. 취소 뒤의 실행을 자동으로 검사하는 테스트는 두 플랫폼 모두 없어요. iOS에는 해당 테스트 파일이 없고, pending을 표시하는 부분만 유닛 테스트의 범위예요. 테스트 파일이 있었더라도 xctestplan의 킬스위치가 실제 호출을 막았을 거예요. 취소 경로는 코드 추적과 정적 call-graph로 검토했고, 두 결함은 모두 코드 리뷰에서 나왔어요.

기기에서는 Xcode의 디버그 빌드에 자격 판정을 건너뛰는 런치 인자를 줘서 확인해요. requestReview(in:) 문서에 따르면 TestFlight로 배포한 앱에서는 이 메서드가 효과를 내지 않아요.

이번에 실행으로 확인한 것은 독립된 Swift 프로그램에서의 취소 동작까지예요. UIKit, StoreKit, MainActor의 큐가 얽힌 실제 경합과, 수정한 뒤의 앱에서 그 경로가 막혔는지는 재현하지 못했어요.