---
id: wiki-2026-0508-iap-in-app-purchase
title: IAP (In-App Purchase)
category: 10_Wiki/Topics
status: verified
canonical_id: self
aliases: [In-App Purchase, 인앱 구매, 인앱 결제]
duplicate_of: none
source_trust_level: A
confidence_score: 0.9
verification_status: applied
tags: [monetization, purchase, ios, android, store]
raw_sources: []
last_reinforced: 2026-05-10
github_commit: pending
tech_stack:
  language: Swift / Kotlin / Server (Node)
  framework: StoreKit 2 / Google Play Billing v7
---

# IAP (In-App Purchase)

## 매 한 줄
> **"매 in-app 의 commerce 의 primitive"**. 매 Apple 30%, 매 Google 30% (small biz 15%) 의 take rate, 매 receipt 의 server validation 의 mandatory. 매 2026 의 StoreKit 2 + Play Billing v7 의 unified async API 의 mainstream.

## 매 핵심

### 매 product type
- **Consumable**: 매 gem, 매 coin pack — 매 multiple purchase.
- **Non-consumable**: 매 ad-removal, 매 unlock — 매 once 의 own.
- **Auto-renewable subscription**: 매 monthly / yearly.
- **Non-renewing subscription**: 매 fixed-term, 매 manual renew.

### 매 핵심 의 server validation
- **Receipt forwarding**: 매 client → server → Apple/Google verify endpoint.
- **JWS / signed transaction**: 매 StoreKit 2 의 JSON Web Signature.
- **Ledger**: 매 idempotent transaction id 의 dedup.
- **Webhook**: 매 App Store Server Notifications V2, 매 Real-time Developer Notifications.

### 매 KPI
- **ARPPU**: 매 average revenue per paying user.
- **Conversion rate**: 매 payer / DAU.
- **Whale concentration**: 매 top 1% 의 revenue 의 share (매 typical 50%).

### 매 응용
1. Game IAP (gem, energy, BP).
2. SaaS subscription (Spotify, Notion).
3. Content unlock (Kindle, comic).

## 💻 패턴

### StoreKit 2 (Swift) — purchase flow
```swift
import StoreKit

func buy(_ productID: String) async throws {
    guard let product = try await Product.products(for: [productID]).first
        else { throw IAPError.notFound }
    let result = try await product.purchase()
    switch result {
    case .success(let verification):
        let txn = try checkVerified(verification)
        await deliver(txn)
        await txn.finish()
    case .userCancelled:
        return
    case .pending:
        return
    @unknown default:
        return
    }
}

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    switch result {
    case .unverified: throw IAPError.failedVerification
    case .verified(let safe): return safe
    }
}
```

### Server-side receipt verify (Node)
```typescript
import { verifyAppleReceipt } from "./apple";

app.post("/iap/verify", async (req, res) => {
  const { jwsRepresentation, userId } = req.body;
  const txn = await verifyAppleReceipt(jwsRepresentation);
  if (await db.iapLedger.exists(txn.transactionId))
    return res.json({ ok: true });  // 매 idempotent
  await db.iapLedger.insert({
    txnId: txn.transactionId,
    userId,
    productId: txn.productId,
    purchaseDate: txn.purchaseDate,
  });
  await wallet.credit(userId, gemsForProduct(txn.productId));
  res.json({ ok: true });
});
```

### Google Play Billing v7 (Kotlin)
```kotlin
val billingClient = BillingClient.newBuilder(context)
    .setListener { result, purchases ->
        if (result.responseCode == BillingResponseCode.OK) {
            purchases?.forEach { handlePurchase(it) }
        }
    }
    .enablePendingPurchases()
    .build()

suspend fun launchPurchase(activity: Activity, productId: String) {
    val products = billingClient.queryProductDetails(...)
    val flow = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(...)
        .build()
    billingClient.launchBillingFlow(activity, flow)
}
```

### Subscription state machine
```python
class SubState(Enum):
    ACTIVE = "active"
    GRACE = "grace"        # 매 billing retry
    HOLD = "hold"
    CANCELED = "canceled"
    EXPIRED = "expired"

def transition(current: SubState, event: str) -> SubState:
    return {
        (SubState.ACTIVE, "renewal_failed"): SubState.GRACE,
        (SubState.GRACE, "renewed"): SubState.ACTIVE,
        (SubState.GRACE, "exhausted"): SubState.HOLD,
        (SubState.HOLD, "recovered"): SubState.ACTIVE,
        (SubState.HOLD, "expired"): SubState.EXPIRED,
        (SubState.ACTIVE, "user_cancel"): SubState.CANCELED,
    }[(current, event)]
```

### Price ladder + LTV-aware offer
```python
LADDER = [0.99, 4.99, 9.99, 19.99, 49.99, 99.99]

def recommend_pack(user) -> float:
    if user.iap_total_usd < 5: return 0.99
    if user.iap_total_usd < 20: return 4.99
    if user.iap_total_usd < 100: return 19.99
    return 99.99
```

### Refund webhook handler
```typescript
app.post("/iap/webhook/apple", async (req) => {
  const note = await decodeAppleNotification(req.body);
  if (note.notificationType === "REFUND") {
    await wallet.debit(note.userId, gemsForProduct(note.productId));
    await ledger.markRefunded(note.transactionId);
  }
});
```

## 매 결정 기준
| 상황 | Approach |
|---|---|
| 매 native iOS/Android | StoreKit 2 / Play Billing v7 |
| 매 web | Stripe + 매 Apple/Google 의 외부 payment 의 EU DMA exception |
| 매 cross-platform | Receipt 의 backend 의 unified ledger |

**기본값**: 매 server-side verify mandatory — 매 client trust 절대 X.

## 🔗 Graph
- 부모: [[게임 수익화 모델]] · [[Subscription]]
- 응용: [[F2P]] · [[Live-ops]]
- Adjacent: [[IAA]]

## 🤖 LLM 활용
**언제**: 매 receipt parse 의 schema understanding, 매 subscription state machine 의 review.
**언제 X**: 매 cryptographic verify 의 LLM 의 implementation — 매 vendor SDK 의 사용.

## ❌ 안티패턴
- **Client-trust delivery**: 매 receipt validation 없이 reward — 매 piracy.
- **Non-idempotent ledger**: 매 retry 의 double credit.
- **Refund 의 ignore**: 매 customer refund 의 ledger 의 reflect 없이 → 매 negative balance.
- **Apple 30% 의 의 fight**: 매 3rd-party payment 의 hide → 매 ban risk (Epic v Apple 사례).

## 🧪 검증 / 중복
- Verified (StoreKit 2 docs 2025; Google Play Billing v7 release notes).
- 신뢰도 A.

## 🕓 Changelog
| 날짜 | 변경 |
|---|---|
| 2026-05-08 | Phase 1 |
| 2026-05-10 | Manual cleanup — IAP full reference with StoreKit 2 + Play Billing v7 |
