StoreKit 거래 리스너는 Sheet보다 오래 살아야 합니다

팁 항아리 화면을 닫았습니다.

결제 승인은 그 뒤에 도착할 수 있습니다.

이 두 문장만 나란히 놓아도 거래 리스너를 어디에 두어야 하는지는 거의 정해집니다.

화면에 두면 안 됩니다.

정확히는, 화면의 생명주기에 거래 수신과 정리의 책임을 묶으면 안 됩니다.

SwiftUI의 Sheet는 열렸다가 닫힙니다.

Transaction.updates는 그 사정을 봐주지 않습니다.

Ask to Buy 승인이나 중단됐다가 재개된 구매 결과는 원래 구매 버튼을 눌렀던 화면이 사라진 뒤에도 올 수 있습니다.

결제 결과가 화면보다 오래 사는 셈입니다.

TODO Slayer 저장소의 Tip Jar를 고치면서 이 수명을 둘로 나눴습니다.

  • 앱은 StoreKit 거래 리스너를 소유합니다.
  • Sheet는 화면이 열려 있는 동안 결과 스트림만 구독합니다.

핵심은 객체를 오래 살게 만드는 것이 아닙니다.

오래 살아야 할 책임과 짧게 살아야 할 책임을 다른 객체에 두는 것입니다.

처음에는 Store가 두 개였습니다

앱 루트에는 이미 StoreKitTipJarStore가 있었습니다.

이 인스턴스는 앱이 살아 있는 동안 Transaction.updates를 순회했습니다.

@State private var tipJarStore = StoreKitTipJarStore()

var body: some Scene {
    WindowGroup {
        ContentView()
            .task { await tipJarStore.listenForTransactions() }
    }
}

여기까지만 보면 수명은 맞습니다.

거래 리스너가 Sheet에 매달려 있지 않으니까요.

그런데 About Sheet를 열 때는 다른 Store를 새로 만들고 있었습니다.

AboutSheet(store: StoreKitTipJarStore())

두 인스턴스의 역할은 이렇게 갈렸습니다.

앱의 Store
  -> Transaction.updates를 수신함
  -> 거래를 검증하고 finish 함

Sheet의 Store
  -> 상품을 불러옴
  -> 사용자가 누른 직접 구매 결과를 모델에 돌려줌

직접 구매는 잘 보였습니다.

버튼을 누른 함수가 반환한 결과는 Sheet의 Store와 모델이 바로 처리했기 때문입니다.

문제는 나중에 도착한 결과였습니다.

그 결과를 수신한 객체는 앱의 Store였습니다.

화면이 보고 있는 객체는 Sheet의 Store였습니다.

둘은 같은 타입이지만 같은 인스턴스가 아닙니다.

타입이 같다고 사건을 공유하는 것은 아닙니다.

쌍둥이 우체통을 세워 놓고 한쪽에 온 편지가 다른 쪽에도 들어 있기를 기대한 셈입니다.

거래를 정리하는 것과 화면에 알리는 것은 다른 책임입니다

앱의 Store는 늦게 도착한 거래를 놓치지 않았습니다.

검증 결과를 신호로 바꾸고, 필요한 거래에 finish()도 호출했습니다.

즉 StoreKit 쪽 정리는 끝났습니다.

하지만 정리된 결과를 버렸습니다.

for await verification in Transaction.updates {
    _ = await settle(verification)
}

화면 입장에서는 결론이 사라졌습니다.

승인 대기 중이던 결제가 성공했는지, 검증에 실패했는지, 다시 실패 상태로 돌아갔는지를 알 수 없었습니다.

여기서 거래 리스너를 About Sheet 안으로 옮기면 눈앞의 연결은 단순해집니다.

리스너와 모델이 같은 화면 수명 안에 있으니까요.

대신 더 큰 문제가 생깁니다.

Sheet를 닫는 순간 거래 리스너도 취소될 수 있습니다.

외부 이벤트의 수명을 UI 표시 여부에 맡기게 됩니다.

그래서 생산자는 앱에 남겨 두고 전달 통로만 추가했습니다.

앱이 소유한 Store 하나를 화면까지 공유했습니다

수정된 앱 루트는 자신이 가진 Store를 환경에 넣습니다.

@State private var tipJarStore = StoreKitTipJarStore()

ContentView()
    .environment(\.tipJarStore, tipJarStore)
    .task { await tipJarStore.listenForTransactions() }

HomeDungeonBoardView는 같은 인스턴스를 환경에서 읽어 About Sheet에 전달합니다.

@Environment(\.tipJarStore) private var tipJarStore

if let tipJarStore {
    AboutSheet(store: tipJarStore)
}

이제 인스턴스 그래프는 하나로 이어집니다.

QuestKeeperApp
  -> StoreKitTipJarStore 한 개
      -> 앱 수명의 Transaction.updates 리스너
      -> 현재 열린 모델들의 결과 구독

AboutSheet
  -> TipJarModel
      -> 앱이 가진 같은 Store를 구독

앱의 리스너가 늦은 거래를 받으면 같은 Store 안의 구독자들에게 결과를 발행할 수 있습니다.

Sheet가 열려 있다면 그 Sheet의 모델이 결과를 받습니다.

Sheet가 닫히면 짧은 구독은 끝나지만, StoreKit 거래 리스너는 계속 살아 있습니다.

외부 이벤트 수신과 현재 화면 표시가 드디어 각자의 수명을 갖습니다.

공유 인스턴스는 전역 싱글턴이 아닙니다

여기서 “Store 하나를 공유한다”를 곧바로 전역 싱글턴으로 번역할 필요는 없습니다.

이 구현에서 소유자는 명확합니다.

QuestKeeperApp이 Store를 생성합니다.

SwiftUI Environment는 그 소유권을 없애지 않고 아래 뷰에 같은 참조를 전달합니다.

화면이 임의로 전역 객체를 찾아오지 않습니다.

Preview나 테스트는 다른 TipJarStore를 주입할 수 있습니다.

이 차이가 중요합니다.

전역 접근
  -> 어디서 누가 만들었는지 흐려짐
  -> 교체 지점이 불분명함

앱 소유 + 의존성 전달
  -> 생성 지점이 하나임
  -> 같은 인스턴스라는 계약이 보임
  -> 테스트 seam을 유지함

필요한 것은 “어디서나 접근 가능한 Store”가 아닙니다.

“앱이 소유한 바로 그 Store가 거래 리스너와 Sheet 양쪽에 전달된다”는 인스턴스 동일성입니다.

Sheet는 구독을 소유합니다

Store가 앱 수명이라고 해서 모든 객체가 앱 수명일 필요는 없습니다.

About Sheet가 만드는 TipJarModel은 자신만의 결과 스트림을 등록합니다.

final class TipJarModel {
    private let outcomeStream: AsyncStream<TipJarOutcome>

    init(store: TipJarStore) {
        outcomeStream = store.outcomes
    }

    func listenForOutcomes() async {
        for await outcome in outcomeStream {
            apply(outcome)
        }
    }
}

Sheet는 .task로 이 스트림을 소비합니다.

.task { await model.listenForOutcomes() }

구독은 화면 수명과 맞습니다.

열린 화면에만 UI 결과를 보여 주면 되기 때문입니다.

Store는 구독마다 별도 AsyncStream과 continuation을 만들고, 종료되면 UUID로 등록을 제거합니다.

Sheet를 다시 열면 새 모델이 새 구독을 얻습니다.

오래 사는 Store 안에 닫힌 화면들의 continuation이 영원히 쌓이지 않습니다.

수명 분리는 생산자만 오래 살게 하고 소비자는 필요한 만큼만 살게 합니다.

직접 결과와 지연 결과는 같은 화면 정책을 씁니다

Store 인스턴스를 공유해도 결과 처리가 두 갈래라면 시간이 지나면서 의미가 달라질 수 있습니다.

직접 구매 성공은 감사 화면을 띄우는데 지연 승인 성공은 다른 상태를 띄우는 식입니다.

모델은 두 경로를 하나의 apply(_:)로 모았습니다.

func tip(_ tier: TipJarTier) async {
    apply(TipJarPolicy.outcome(for: await store.purchase(tier)))
}

func listenForOutcomes() async {
    for await outcome in outcomeStream {
        apply(outcome)
    }
}

도착 경로는 달라도 사용자에게 뜻하는 바는 같습니다.

  • 검증된 완료는 감사 상태입니다.
  • 실패나 폐기는 실패 상태입니다.
  • 보류는 승인 대기 상태입니다.
  • 취소는 안내를 남기지 않습니다.

StoreKit 이벤트를 어디서 받았는지가 화면 정책을 갈라놓지 않습니다.

테스트가 읽은 것과 읽지 않은 것

정확한 구현 커밋에서 TipJarModelTests 11개를 iOS Simulator로 다시 실행했고 모두 통과했습니다.

이 스위트는 직접 결과와 지연 결과가 같은 모델 상태로 매핑되는지, 생산자가 소비 루프보다 먼저 발행해도 등록된 스트림이 결과를 보존하는지를 읽습니다.

반면 이번 실행은 SwiftUI Environment 연결을 렌더링하거나 뷰 계층의 인스턴스 동일성을 검사하지 않습니다.

앱 루트가 Store를 환경에 넣고, HomeDungeonBoardView가 그 값을 About Sheet에 넘기는 사실은 현재 공개 소스를 직접 읽어 확인했습니다.

또한 실제 Ask to Buy 승인이나 App Store 거래를 만들지 않았습니다.

따라서 근거의 범위는 여기까지입니다.

모델의 지연 결과 전달과 매핑
  -> Simulator 단위 테스트로 확인

앱 소유 Store의 주입 경로
  -> 현재 공개 소스로 확인

실제 StoreKit 지연 승인
  -> 이번 글에서는 실행하지 않음

테스트가 화면을 읽지 않았는데 “UI 연결까지 검증했다”고 말하면, 수명은 잘 나눠 놓고 증거는 다시 한 바구니에 담는 셈입니다.

실전 규칙

외부 이벤트가 화면보다 오래 살 수 있다면 생산자를 화면에 두지 않습니다.

앱이나 그에 준하는 장수명 소유자가 리스너를 시작하고 유지하게 합니다.

현재 화면에는 같은 생산자 인스턴스를 주입합니다.

화면 모델은 짧은 구독만 소유하고, 종료될 때 continuation 등록도 정리합니다.

직접 호출의 결과와 지연 이벤트의 결과는 같은 상태 전이 함수로 모읍니다.

마지막으로 다음 두 질문을 따로 테스트합니다.

화면이 열려 있을 때 늦은 결과가 모델에 도착하는가?
화면이 닫혀 있어도 외부 거래 리스너는 계속 살아 있는가?

첫 번째는 구독의 책임입니다.

두 번째는 생산자의 책임입니다.

StoreKit 거래 리스너는 Sheet보다 오래 살아야 합니다.

그렇다고 Sheet의 모든 상태까지 오래 살게 만들 필요는 없습니다.