Files
2nd/10_Wiki/Topics/Coding/iOS_StoreKit_2_Patterns.md
T
2026-05-09 21:08:02 +09:00

4.1 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
ios-storekit-2-patterns StoreKit 2 — IAP / Subscription Coding draft B conceptual 2026-05-09 2026-05-09
ios
storekit
iap
subscription
vibe-coding
language applicable_to
Swift / StoreKit 2
iOS 15+
in-app purchase
Transaction
JWS
server-side validation

StoreKit 2

iOS 15+ 의 새 IAP API. async/await + 타입 안전. Transaction 자체가 JWS 서명 — server-side validation 가능. 옛 receipt validation 시대 끝.

📖 핵심 개념

  • Product: App Store 등록 상품.
  • Purchase → Transaction → finish.
  • Transaction.currentEntitlements: 현재 active 구독 / 영구 구매.
  • Transaction.updates: 백그라운드 갱신 / 환불.

💻 코드 패턴

Product fetch + 구매

import StoreKit

final class Store: ObservableObject {
    @Published var products: [Product] = []
    @Published var purchasedIds: Set<String> = []

    init() {
        Task { await loadProducts(); await listenForUpdates() }
    }

    func loadProducts() async {
        do {
            products = try await Product.products(for: ["pro_yearly", "pro_monthly"])
        } catch { print("load failed: \(error)") }
    }

    func purchase(_ product: Product) async throws -> Transaction? {
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updateEntitlements()
            await transaction.finish()
            return transaction
        case .userCancelled, .pending:
            return nil
        @unknown default:
            return nil
        }
    }

    func updateEntitlements() async {
        var ids = Set<String>()
        for await result in Transaction.currentEntitlements {
            if case .verified(let t) = result {
                ids.insert(t.productID)
            }
        }
        purchasedIds = ids
    }

    func listenForUpdates() async {
        for await result in Transaction.updates {
            if case .verified(let t) = result {
                await updateEntitlements()
                await t.finish()
            }
        }
    }

    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let value): return value
        case .unverified(_, let error): throw error
        }
    }
}

Server-side validation

// Transaction 의 jwsRepresentation 을 서버로
let jws = transaction.jwsRepresentation
await api.verifyPurchase(jws: jws, userId: user.id)

서버:

// JWS 검증 — Apple 의 root cert chain 으로
import { decodeRevoked } from '@apple/app-store-server-library';
const decoded = await verifyJWS(jws, { rootCertificates });
if (decoded.appAccountToken === userId) await grantEntitlement(...);

Restore

// 사용자 "구매 복원" 버튼
try await AppStore.sync()
// 그 후 currentEntitlements 다시 읽기

가격 표시 (locale)

Text(product.displayPrice) // $9.99 / ₩12,000

🤔 의사결정 기준

상품 종류
1회 구매 non-consumable
사용 후 소진 (코인) consumable
월/년 구독 auto-renewable subscription
정기 (1년 단발) non-renewing subscription

안티패턴

  • client-side validation 만: 우회 가능. 서버 JWS 검증 필수.
  • Transaction.finish 안 함: queue 누적. OS 가 다시 trigger.
  • listenForUpdates 안 함: 백그라운드 갱신 / 환불 놓침.
  • userId 매핑 없음: 어떤 사용자 구매인지 모름. appAccountToken.
  • Sandbox 와 Production 혼동: TestFlight 는 sandbox.
  • 에러 메시지 generic: 결제 실패 종류별 (cancel / decline / parental) 다른 UX.
  • price hardcode: 통화 변경 / 지역 미지원.

🤖 LLM 활용 힌트

  • StoreKit 2 (iOS 15+). 옛 코드 마이그레이션 시 RestoreCompletedTransactions → AppStore.sync.
  • 서버 JWS 검증 필수.

🔗 관련 문서