[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
---
|
||||
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]]
|
||||
Reference in New Issue
Block a user