---
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<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
```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]]
