--- id: ios-storekit-2-patterns title: StoreKit 2 — IAP / Subscription category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [ios, storekit, iap, subscription, vibe-coding] tech_stack: { language: "Swift / StoreKit 2", applicable_to: ["iOS 15+"] } applied_in: [] aliases: [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 + 구매 ```swift import StoreKit final class Store: ObservableObject { @Published var products: [Product] = [] @Published var purchasedIds: Set = [] 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() 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(_ result: VerificationResult) throws -> T { switch result { case .verified(let value): return value case .unverified(_, let error): throw error } } } ``` ### Server-side validation ```swift // Transaction 의 jwsRepresentation 을 서버로 let jws = transaction.jwsRepresentation await api.verifyPurchase(jws: jws, userId: user.id) ``` 서버: ```ts // 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 ```swift // 사용자 "구매 복원" 버튼 try await AppStore.sync() // 그 후 currentEntitlements 다시 읽기 ``` ### 가격 표시 (locale) ```swift 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 검증 필수. ## 🔗 관련 문서 - [[Web_JWT_Patterns]] - [[iOS_Keychain_Storage]]