4.1 KiB
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 |
|
|
|
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 검증 필수.