무엇을 만드나요

많은 앱에는 두 종류의 화폐가 있습니다. 하나는 플레이로 얻는 소프트 커런시(soft currency)이고(코인, 에너지, XP), 다른 하나는 실제 돈으로 사는 하드 커런시(hard currency)입니다(젬, 크레딧, 토큰). 이 코드랩에서는 RevenueCat 가상 화폐(Virtual Currencies)하드 커런시 쪽, 즉 "젬" 상점을 만듭니다. 다루는 플랫폼은 iOS, Android, Flutter, React Native, Kotlin Multiplatform입니다.

코드랩 하나로 모든 플랫폼을. 클라이언트 코드 블록마다 플랫폼 전환기가 붙어 있습니다. 아무 탭에서든 플랫폼을 고르면 코드랩의 모든 클라이언트 스니펫이 그 플랫폼으로 함께 바뀌므로, 처음부터 끝까지 한 언어로 읽을 수 있습니다. 백엔드와 대시보드 단계는 누구에게나 동일합니다.

다 마치면 다음을 갖게 됩니다.

  • 소모성 인앱 구매로 채워지는 RevenueCat의 GEM 가상 화폐.
  • 젬 팩을 각 지역의 스토어 가격으로 나열하는 네이티브 상점 화면.
  • RevenueCat이 영수증을 검증한 뒤 젬 잔액을 자동으로 적립하는 구매 흐름.
  • SDK에서 읽어 앱에 표시되는 실시간 젬 잔액.
  • Secret API 키로 젬을 차감하고, 잔액이 부족한 경우를 깔끔하게 처리하는 백엔드 "차감" 엔드포인트.

첫 번째 결정: 각 화폐를 어디에 둘까

코드를 쓰기 전에, 잔액의 단일 진실 소스(source of truth)부터 정해야 합니다. 여기까지 온 팀은 대부분 이미 자체 서버에서 소프트 커런시를 운영하고 있고, 두 화폐를 한곳에 모아도 될지 궁금해합니다. 크게 두 가지 형태가 있습니다.

방식 소프트 커런시 하드 커런시
A. 분리 (이 경우에 권장) 자체 서버 RevenueCat
B. 통합 RevenueCat RevenueCat

이미 동작하는 소프트 커런시 서버가 있다면 A 방식이 위험이 더 낮습니다. 이미 잘 돌아가는 자리에 고빈도의 게임플레이 소프트 잔액을 그대로 두고, 하드 커런시는 RevenueCat이 맡게 하는 것입니다. 하드 커런시야말로 RevenueCat이 가장 큰 가치를 더하는 영역입니다. 영수증 검증, 자동 적립, 환불 시 자동 회수, 원자적 차감, 감사 로그를 제공합니다. 기존 소프트 잔액을 마이그레이션할 필요도 없습니다.

B 방식(RevenueCat을 두 화폐 모두의 단일 진실 소스로 삼기)은 기존 잔액 저장소가 없는 신규(greenfield) 앱에 RevenueCat이 권장하는 방식입니다. 모든 잔액이 한 시스템에 있으니, 처음부터 새로 시작할 때 가장 단순한 모델입니다.

화폐마다 단일 진실 소스를 하나만 두고, 한 화폐를 여러 시스템에 나눠 두지 마세요. 위험한 경우는 화폐의 "진짜" 잔액을 자체 서버에 두면서 그것을 RevenueCat에도 그대로 복제하는 상황입니다(반대 방향도 마찬가지). 하나의 잔액에 기록하는 주체가 둘이면 값이 어긋나고 대사(reconciliation)로 골머리를 앓게 됩니다. 이 코드랩은 A 방식을 따릅니다. 즉 젬 잔액은 처음부터 끝까지 RevenueCat이 소유합니다.

여기서는 RevenueCat의 결제 엔진과 잔액을 쓰되, 상점 UI는 여러분의 네이티브 화면으로 만듭니다(RevenueCat Paywall UI는 필요하지 않습니다). 그래야 젬 상점이 이미 디자인해 둔 소프트 커런시 상점과 시각적으로 일관됩니다.

누구를 위한 코드랩이고, 무엇이 필요한가

게임이나 소셜 앱에 소모성 하드 커런시를 더하려는 모바일 개발자를 위한 코드랩입니다. 자신이 쓰는 플랫폼의 UI와 비동기 모델에 익숙해야 하고, App Store Connect나 Google Play에 앱이 설정돼 있어야 합니다(실제 앱이거나, 로컬 테스트용 StoreKit 구성 파일 또는 라이선스 테스터라도 됩니다). 가상 화폐에는 최신 SDK가 필요합니다.

플랫폼 SDK 최소 버전
iOSpurchases-ios5.32.0
Androidpurchases-android9.1.0
Flutterpurchases_flutter9.1.0
React Nativereact-native-purchases9.1.0
Kotlin Multiplatformpurchases-kmp2.1.0+16.2.0

가상 화폐 만들기

RevenueCat 대시보드에서 프로젝트의 Product catalog를 열어 Virtual Currencies를 선택한 뒤, + New virtual currency를 누릅니다. 중요한 필드는 두 개입니다.

  • Code: SDK와 API에서 사용하는 식별자입니다(예: GEM). 코드에서 참조하게 되니 신중히 정하세요.
  • Name: 표시 이름입니다(예: Gems).

선택 사항으로 아이콘과 설명도 추가할 수 있습니다. 저장하면 모든 고객의 시작 잔액이 0인 상태로 화폐가 생깁니다.

알아 두면 좋은 점. 한 프로젝트는 최대 100개의 가상 화폐를 지원하고, 단일 잔액은 최대 2,000,000,000까지 올라가며, 잔액은 절대 음수가 될 수 없습니다(0을 넘어가는 차감은 거부됩니다. 차감 단계에서 직접 확인하게 됩니다). 가상 화폐는 RevenueCat Pro 플랜에 포함됩니다.

젬 팩 상품 만들기와 지급량 설정

젬은 실제 돈으로 사는 것이므로, 젬 팩은 저마다 소모성(consumable) 인앱 구매입니다. 여기까지는 전부 대시보드와 스토어 구성이고, 코드는 아직 없습니다.

1. 스토어에 소모성 상품 만들기

App Store Connect나 Google Play Console에서 팩마다 소모성 상품을 하나씩 만듭니다(예: gems_300, gems_1200, gems_6500). 각각 자체 가격 등급을 가집니다. 소모성 상품은 반복해서 살 수 있는데, 화폐 충전에 딱 필요한 성질입니다.

2. 상품을 가져와 Offering에 넣기

RevenueCat에서 그 상품들을 앱 아래에 추가하고, Offering을 만든 뒤(예: gems) 각 상품을 Package로 넣습니다. Offering은 앱이 런타임에 구매 가능한 팩을 가져오는 통로이고, 앱을 새로 배포하지 않고도 나중에 팩 순서를 바꾸거나 교체할 수 있게 해 줍니다.

3. 각 상품을 GEM 화폐에 연결하기

GEM 화폐를 열어 Add associated product를 누르고, 젬 팩 상품을 고른 뒤 지급할 수량을 입력합니다. 예를 들면 다음과 같습니다.

상품 지급량
gems_300300 GEM
gems_12001200 GEM
gems_65006500 GEM

이제부터는 고객이 이 상품들을 살 때마다 RevenueCat이 스토어 영수증을 검증하고, 설정한 수량을 젬 잔액에 자동으로 더합니다. 적립 코드는 한 줄도 작성하지 않습니다.

환불은 자동으로 회수됩니다. 소모성 구매가 환불되면(RevenueCat이 환불을 감지하도록 인앱 구매 키를 설정해 두세요) RevenueCat이 지급한 화폐를 비례해서 회수하고, 잔액이 음수가 되지 않도록 0에서 멈춥니다. 구매 콜백에서 젬을 직접 적립하기보다 RevenueCat에 하드 커런시를 맡기는 주된 이유 중 하나가 바로 이 환불 처리입니다.

SDK 설치와 구성

플랫폼에 맞는 RevenueCat SDK를 설치하고, 앱 시작 시점에 public API 키로 한 번 구성합니다. 키 접두사는 빌드가 향하는 스토어에 따라 다릅니다. Apple 키는 appl_로, Google 키는 goog_로, Amazon 키는 amzn_로 시작합니다. 크로스 플랫폼 SDK에서는 런타임에 플랫폼별로 알맞은 키를 고릅니다.

아래 플랫폼 전환기를 쓰세요. 아무 코드 블록에서든 플랫폼을 고르면 이 코드랩의 나머지 클라이언트 스니펫이 모두 따라옵니다. (플랫폼별 최소 SDK 버전은 1단계를 참고하세요.)
swift
// GemStoreApp.swift
import SwiftUI
import RevenueCat

@main
struct GemStoreApp: App {
    init() {
        Purchases.logLevel = .debug
        Purchases.configure(withAPIKey: "appl_YOUR_PUBLIC_SDK_KEY")
    }

    var body: some Scene {
        WindowGroup {
            GemStoreView()
        }
    }
}
kotlin
// App.kt
import android.app.Application
import com.revenuecat.purchases.LogLevel
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Purchases.logLevel = LogLevel.DEBUG
        Purchases.configure(
            PurchasesConfiguration.Builder(this, "goog_YOUR_PUBLIC_SDK_KEY").build()
        )
    }
}
dart
// main.dart
import 'dart:io' show Platform;
import 'package:purchases_flutter/purchases_flutter.dart';

Future<void> configureRevenueCat() async {
  await Purchases.setLogLevel(LogLevel.debug);
  final config = Platform.isIOS
      ? PurchasesConfiguration("appl_YOUR_PUBLIC_SDK_KEY")
      : PurchasesConfiguration("goog_YOUR_PUBLIC_SDK_KEY");
  await Purchases.configure(config);
}
typescript
// revenuecat.ts
import { Platform } from 'react-native';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';

export function configureRevenueCat() {
  Purchases.setLogLevel(LOG_LEVEL.DEBUG);
  Purchases.configure({
    apiKey: Platform.OS === 'ios' ? 'appl_YOUR_PUBLIC_SDK_KEY' : 'goog_YOUR_PUBLIC_SDK_KEY',
  });
}
kotlin
// commonMain
import com.revenuecat.purchases.kmp.LogLevel
import com.revenuecat.purchases.kmp.Purchases

// Each target supplies the key (appl_ on iOS, goog_ on Android).
expect val revenueCatApiKey: String

fun configureRevenueCat() {
    Purchases.logLevel = LogLevel.DEBUG
    Purchases.configure(apiKey = revenueCatApiKey)
}

사용자 식별하기 (잔액이 여기에 붙습니다)

가상 화폐 잔액은 App User ID로 식별되는 고객(customer)에게 속합니다. 아무것도 하지 않으면 RevenueCat이 익명 ID를 배정하는데, 그때 산 젬은 그 익명 고객에게 남아 새 기기나 새로 설치한 앱으로 사용자를 따라오지 않습니다. 사용자가 로그인하는 즉시 자체적으로 안정적인 사용자 IDlogIn을 호출해, 젬 잔액이 어디서나 그 사용자의 것이 되게 하세요.

swift
// After your own auth resolves a user id:
let (_, created) = try await Purchases.shared.logIn("your-stable-user-id")
print("RevenueCat customer ready (new customer: \(created))")
// Use this SAME id on your backend when you spend gems (see step 8).
kotlin
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.awaitLogIn

// Use this SAME id on your backend when you spend gems (see step 8).
// LogInResult is a Poko class (no destructuring); read the property.
val result = Purchases.sharedInstance.awaitLogIn("your-stable-user-id")
val created = result.created
dart
import 'package:purchases_flutter/purchases_flutter.dart';

// Use this SAME id on your backend when you spend gems (see step 8).
final LogInResult result = await Purchases.logIn("your-stable-user-id");
final bool created = result.created;
typescript
import Purchases from 'react-native-purchases';

// Use this SAME id on your backend when you spend gems (see step 8).
const { created } = await Purchases.logIn('your-stable-user-id');
kotlin
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitLogIn

// Use this SAME id on your backend when you spend gems (see step 8).
val login = Purchases.sharedInstance.awaitLogIn("your-stable-user-id")
val created = login.created
App User ID가 연결 키입니다. 같은 ID가 클라이언트, RevenueCat의 젬 잔액, 백엔드 차감 호출을 하나로 묶어 줍니다. 이메일이 아니라 백엔드 사용자 ID를 쓰세요(이메일은 바뀝니다). 로그인과 로그아웃에 관한 자세한 내용은 사용자 식별 가이드를 참고하세요.

상점 화면 만들기

gems Offering을 가져와, 각 Package를 지역화된 가격 문자열과 함께 렌더링합니다. RevenueCat은 사용자의 스토어프론트 화폐에 맞춰 이미 포맷된 가격을 돌려주니, 가격을 절대 하드코딩하지 마세요. 여기서 참조하는 buyrefreshBalance 함수는 다음 두 단계에서 추가합니다.

swift
// GemStoreModel.swift
import SwiftUI
import RevenueCat

@MainActor
final class GemStoreModel: ObservableObject {
    @Published var packages: [Package] = []
    @Published var gemBalance: Int = 0
    @Published var errorMessage: String?

    /// Load the purchasable gem packs.
    func loadStore() async {
        do {
            let offerings = try await Purchases.shared.offerings()
            // Named "gems" offering, or fall back to the current one.
            let offering = offerings.all["gems"] ?? offerings.current
            packages = offering?.availablePackages ?? []
        } catch {
            errorMessage = "Could not load the store: \(error.localizedDescription)"
        }
    }
    // buy(_:) and refreshBalance() are added in the next steps.
}
swift
// GemStoreView.swift
import SwiftUI
import RevenueCat

struct GemStoreView: View {
    @StateObject private var model = GemStoreModel()

    // A real two-way binding, so any dismissal clears the error state.
    private var showError: Binding<Bool> {
        Binding(get: { model.errorMessage != nil },
                set: { if !$0 { model.errorMessage = nil } })
    }

    var body: some View {
        NavigationStack {
            List {
                Section("Your balance") {
                    Label("\(model.gemBalance) gems", systemImage: "diamond.fill")
                        .font(.headline)
                }
                Section("Buy gems") {
                    ForEach(model.packages, id: \.identifier) { pkg in
                        Button {
                            Task { await model.buy(pkg) }
                        } label: {
                            HStack {
                                Text(pkg.storeProduct.localizedTitle)
                                Spacer()
                                Text(pkg.storeProduct.localizedPriceString)
                                    .foregroundStyle(.secondary)
                            }
                        }
                    }
                }
            }
            .navigationTitle("Gem Store")
            .task {
                await model.loadStore()
                await model.refreshBalance()
            }
            .alert("Something went wrong", isPresented: showError) {
                Button("OK", role: .cancel) { }
            } message: {
                Text(model.errorMessage ?? "")
            }
        }
    }
}
kotlin
// GemStoreViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.awaitOfferings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

class GemStoreViewModel : ViewModel() {
    data class UiState(
        val packages: List<Package> = emptyList(),
        val gemBalance: Int = 0,
        val error: String? = null,
    )
    private val _state = MutableStateFlow(UiState())
    val state = _state.asStateFlow()

    init {
        viewModelScope.launch {
            val offerings = Purchases.sharedInstance.awaitOfferings()
            val offering = offerings.all["gems"] ?: offerings.current
            _state.update { it.copy(packages = offering?.availablePackages ?: emptyList()) }
            refreshBalance()
        }
    }
    // buy(...) and refreshBalance() are added in the next steps.
}
kotlin
// GemStoreScreen.kt
import android.app.Activity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun GemStoreScreen(vm: GemStoreViewModel, activity: Activity) {
    val ui by vm.state.collectAsState()
    LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
        item {
            Text("${ui.gemBalance} gems", style = MaterialTheme.typography.headlineSmall)
            Spacer(Modifier.height(16.dp))
        }
        items(ui.packages, key = { it.identifier }) { pkg ->
            ListItem(
                headlineContent = { Text(pkg.product.title) },
                trailingContent = { Text(pkg.product.price.formatted) },
                modifier = Modifier.clickable { vm.buy(pkg, activity) }
            )
        }
    }
}
dart
// gem_store_page.dart
import 'package:flutter/material.dart';
import 'package:purchases_flutter/purchases_flutter.dart';

class GemStorePage extends StatefulWidget {
  const GemStorePage({super.key});
  @override
  State<GemStorePage> createState() => _GemStorePageState();
}

class _GemStorePageState extends State<GemStorePage> {
  List<Package> _packages = [];
  int _gemBalance = 0;

  @override
  void initState() {
    super.initState();
    _init();
  }

  Future<void> _init() async {
    final offerings = await Purchases.getOfferings();
    final offering = offerings.all["gems"] ?? offerings.current;
    if (mounted) setState(() => _packages = offering?.availablePackages ?? []);
    await refreshBalance();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Gem Store')),
      body: ListView(
        children: [
          ListTile(
            title: Text('$_gemBalance gems',
                style: Theme.of(context).textTheme.headlineSmall),
          ),
          const Divider(),
          for (final pkg in _packages)
            ListTile(
              title: Text(pkg.storeProduct.title),
              trailing: Text(pkg.storeProduct.priceString),
              onTap: () => buy(pkg),
            ),
        ],
      ),
    );
  }
  // buy() and refreshBalance() are added in the next steps.
}
tsx
// GemStoreScreen.tsx
import React, { useEffect, useState } from 'react';
import { FlatList, Text, TouchableOpacity, View } from 'react-native';
import Purchases, { PurchasesPackage } from 'react-native-purchases';

export function GemStoreScreen() {
  const [packages, setPackages] = useState<PurchasesPackage[]>([]);
  const [gemBalance, setGemBalance] = useState(0);

  useEffect(() => {
    (async () => {
      const offerings = await Purchases.getOfferings();
      const offering = offerings.all['gems'] ?? offerings.current;
      setPackages(offering?.availablePackages ?? []);
      await refreshBalance(setGemBalance);
    })();
  }, []);

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Text style={{ fontSize: 22, fontWeight: '600' }}>{gemBalance} gems</Text>
      <FlatList
        data={packages}
        keyExtractor={(p) => p.identifier}
        renderItem={({ item: pkg }) => (
          <TouchableOpacity
            onPress={() => buy(pkg, gemBalance, setGemBalance)}
            style={{ flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 12 }}
          >
            <Text>{pkg.product.title}</Text>
            <Text style={{ color: '#888' }}>{pkg.product.priceString}</Text>
          </TouchableOpacity>
        )}
      />
    </View>
  );
}
kotlin
// GemStoreScreen.kt (commonMain, Compose Multiplatform)
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitOfferings
import com.revenuecat.purchases.kmp.models.Package
import kotlinx.coroutines.launch

@Composable
fun GemStoreScreen() {
    val scope = rememberCoroutineScope()
    var packages by remember { mutableStateOf<List<Package>>(emptyList()) }
    var gemBalance by remember { mutableStateOf(0) }

    LaunchedEffect(Unit) {
        val offerings = Purchases.sharedInstance.awaitOfferings()
        val offering = offerings.all["gems"] ?: offerings.current
        packages = offering?.availablePackages ?: emptyList()
        gemBalance = refreshBalance()
    }

    LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
        item {
            Text("$gemBalance gems", style = MaterialTheme.typography.headlineSmall)
            Spacer(Modifier.height(16.dp))
        }
        items(packages, key = { it.identifier }) { pkg ->
            ListItem(
                headlineContent = { Text(pkg.storeProduct.title) },
                trailingContent = { Text(pkg.storeProduct.price.formatted) },
                modifier = Modifier.clickable { scope.launch { gemBalance = buy(pkg, gemBalance) } }
            )
        }
    }
}
UI는 여러분이, 엔진은 RevenueCat이. 이것이 "엔진만" 쓰는 방식입니다. offerings API로 상품을 가져와 원하는 대로 렌더링하므로, 젬 상점이 이미 만들어 둔 소프트 커런시 상점과 어울립니다. RevenueCat Paywalls UI SDK는 선택 사항이고 여기서는 쓰지 않습니다.

젬 구매하기

선택한 Package에 대해 구매 API를 호출합니다. RevenueCat이 스토어 구매를 진행하고 영수증을 검증하며, 상품이 GEM에 연결돼 있으므로 자사 서버에서 젬을 자동으로 적립합니다. 여러분이 할 일은 취소를 처리하고 그 뒤 잔액을 새로 고치는 것뿐입니다.

swift
// GemStoreModel.swift (continued)
extension GemStoreModel {
    func buy(_ package: Package) async {
        do {
            let result = try await Purchases.shared.purchase(package: package)
            guard !result.userCancelled else { return } // user closed the sheet

            // Gems are credited on RevenueCat's servers a beat after purchase()
            // returns, so poll briefly until the new balance lands.
            let before = gemBalance
            for _ in 0..<5 {
                await refreshBalance()
                if gemBalance > before { break }
                try? await Task.sleep(for: .milliseconds(500))
            }
        } catch {
            errorMessage = "Purchase failed: \(error.localizedDescription)"
        }
    }
}
kotlin
// Add to GemStoreViewModel
import android.app.Activity
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.PurchasesException
import com.revenuecat.purchases.awaitPurchase
import kotlinx.coroutines.delay

fun buy(pkg: Package, activity: Activity) = viewModelScope.launch {
    try {
        Purchases.sharedInstance.awaitPurchase(
            PurchaseParams.Builder(activity, pkg).build()
        )
        // Poll briefly until the credited balance lands.
        val before = state.value.gemBalance
        repeat(5) {
            refreshBalance()
            if (state.value.gemBalance > before) return@launch
            delay(500)
        }
    } catch (e: PurchasesException) {
        if (e.code == PurchasesErrorCode.PurchaseCancelledError) return@launch
        _state.update { it.copy(error = "Purchase failed: ${e.message}") }
    }
}
dart
// Add to _GemStorePageState
import 'package:flutter/services.dart' show PlatformException;

Future<void> buy(Package package) async {
  try {
    await Purchases.purchasePackage(package);
    // Poll briefly until the credited balance lands.
    final before = _gemBalance;
    for (var i = 0; i < 5; i++) {
      await refreshBalance();
      if (_gemBalance > before) break;
      await Future.delayed(const Duration(milliseconds: 500));
    }
  } on PlatformException catch (e) {
    final code = PurchasesErrorHelper.getErrorCode(e);
    if (code == PurchasesErrorCode.purchaseCancelledError) return; // closed the sheet
    // surface "Purchase failed" as you prefer
  }
}
typescript
import Purchases, { PurchasesPackage, PURCHASES_ERROR_CODE } from 'react-native-purchases';

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function buy(
  pkg: PurchasesPackage,
  before: number,
  setGemBalance: (n: number) => void,
) {
  try {
    await Purchases.purchasePackage(pkg);
    // Poll briefly until the credited balance lands.
    for (let i = 0; i < 5; i++) {
      const balance = await refreshBalance(setGemBalance);
      if (balance > before) break;
      await sleep(500);
    }
  } catch (e: any) {
    if (e.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) return; // closed the sheet
    // surface "Purchase failed" as you prefer
  }
}
kotlin
// commonMain
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.PurchasesException
import com.revenuecat.purchases.kmp.ktx.awaitPurchase
import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PurchasesErrorCode
import kotlinx.coroutines.delay

suspend fun buy(pkg: Package, before: Int): Int {
    return try {
        Purchases.sharedInstance.awaitPurchase(packageToPurchase = pkg)
        // Poll briefly until the credited balance lands.
        var balance = before
        repeat(5) {
            balance = refreshBalance()
            if (balance > before) return balance
            delay(500)
        }
        balance
    } catch (e: PurchasesException) {
        if (e.error.code == PurchasesErrorCode.PurchaseCancelledError) before // closed the sheet
        else before // surface "Purchase failed" as you prefer
    }
}
이 콜백에서 젬을 직접 적립하지 마세요. RevenueCat이 검증된 영수증을 바탕으로 이미 지급했습니다. 여기서 젬을 또 더하면 이중 적립이 되고, 더 나쁜 것은 환불 시 회수되지 않는다는 점입니다. SDK의 잔액을 진실로 여기고 다시 읽기만 하세요.
잔액은 최종적으로 일관됩니다(eventually consistent). 지급은 구매 호출이 반환된 잠깐 뒤에 RevenueCat 서버에 반영되므로, buy는 한 번만 읽지 않고 폴링합니다. 앱이 포그라운드로 돌아올 때도 잔액을 새로 고쳐, 놓친 업데이트가 스스로 복구되게 하세요. 그 새로고침은 완료된 구매가 사용자 취소로 보고되는 드문 경우도 함께 처리해 줍니다.

잔액 표시하기

가상 화폐 API로 잔액을 읽고, all 맵에서 코드로 자신의 화폐를 꺼냅니다. 잔액은 SDK가 캐시하며 스스로 갱신되지 않으니, 잔액을 바꾸는 일(구매나 백엔드 차감)이 있은 뒤에는 캐시를 무효화하고 다시 가져오세요.

swift
// GemStoreModel.swift (continued)
extension GemStoreModel {
    /// Invalidate first so we never show a stale value after a purchase or spend.
    func refreshBalance() async {
        do {
            Purchases.shared.invalidateVirtualCurrenciesCache()
            let currencies = try await Purchases.shared.virtualCurrencies()
            gemBalance = currencies.all["GEM"]?.balance ?? 0
        } catch {
            errorMessage = "Could not load your balance: \(error.localizedDescription)"
        }
    }
}
kotlin
// Add to GemStoreViewModel
import com.revenuecat.purchases.awaitGetVirtualCurrencies

suspend fun refreshBalance() {
    try {
        Purchases.sharedInstance.invalidateVirtualCurrenciesCache()
        val currencies = Purchases.sharedInstance.awaitGetVirtualCurrencies()
        _state.update { it.copy(gemBalance = currencies.all["GEM"]?.balance ?: 0) }
    } catch (e: Exception) {
        _state.update { it.copy(error = "Could not load your balance: ${e.message}") }
    }
}
dart
// Add to _GemStorePageState
Future<void> refreshBalance() async {
  try {
    await Purchases.invalidateVirtualCurrenciesCache();
    final currencies = await Purchases.getVirtualCurrencies();
    if (mounted) setState(() => _gemBalance = currencies.all["GEM"]?.balance ?? 0);
  } catch (e) {
    // surface error as you prefer
  }
}
typescript
import Purchases from 'react-native-purchases';

export async function refreshBalance(
  setGemBalance: (n: number) => void,
): Promise<number> {
  await Purchases.invalidateVirtualCurrenciesCache();
  const currencies = await Purchases.getVirtualCurrencies();
  const balance = currencies.all['GEM']?.balance ?? 0;
  setGemBalance(balance);
  return balance;
}
kotlin
// commonMain
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitVirtualCurrencies

suspend fun refreshBalance(): Int {
    Purchases.sharedInstance.invalidateVirtualCurrenciesCache()
    val currencies = Purchases.sharedInstance.awaitVirtualCurrencies()
    return currencies.all["GEM"]?.balance ?: 0
}

첫 화면을 빠르게 그리려면, 네트워크 응답이 오기 전에 캐시된 값을 읽을 수 있습니다.

swift
// iOS exposes a synchronous cached property (nil before the first fetch):
let cached = Purchases.shared.cachedVirtualCurrencies?.all["GEM"]?.balance
kotlin
// Android also exposes a synchronous cached property (null before the first fetch):
val cached = Purchases.sharedInstance.cachedVirtualCurrencies?.all["GEM"]?.balance
dart
// Flutter's cached accessor is an async method (not a property):
final cached = (await Purchases.getCachedVirtualCurrencies())?.all["GEM"]?.balance;
typescript
// React Native's cached accessor is an async method (not a property):
const cached = (await Purchases.getCachedVirtualCurrencies())?.all['GEM']?.balance;
kotlin
// KMP's cached accessor is a method (not a property):
val cached = Purchases.sharedInstance.getCachedVirtualCurrencies()?.all["GEM"]?.balance
캐시 접근 방식은 플랫폼마다 다릅니다. iOSAndroid에서는 동기 프로퍼티지만, Flutter, React Native, KMP에서는 비동기 메서드(getCachedVirtualCurrencies())입니다. 각 VirtualCurrencybalance와 함께 code, name, serverDescription도 담고 있으니, 앱에 "Gems"를 하드코딩하는 대신 대시보드에서 라벨을 끌어올 수 있습니다.

젬 차감하기 (백엔드)

젬 차감은 반드시 여러분이 통제하는 서버에서 RevenueCat Secret API 키로 이뤄져야 합니다(이 키는 sk_로 시작합니다). 시크릿 키는 잔액을 움직일 수 있으므로 앱에 절대 포함돼서는 안 됩니다. 흐름은 이렇습니다. 앱이 백엔드에 차감을 요청하고, 백엔드가 RevenueCat을 통해 차감하며, 사용자가 감당할 수 있었는지는 그 차감 자체가 판정합니다.

차감 엔드포인트 (여러분의 백엔드)

고객의 가상 화폐 트랜잭션 엔드포인트로 adjustments 맵을 POST합니다. 음수는 차감이고 양수는 지급입니다. 맵 전체는 원자적(atomically)으로 적용됩니다. 맵에 든 화폐 중 하나라도 잔액이 부족하면 아무것도 차감되지 않고 RevenueCat이 HTTP 422를 반환합니다. 가격은 서버가 항목 이름을 보고 정하며, 클라이언트가 보낸 금액을 차감하는 일은 절대 없습니다. 이 부분은 앱 플랫폼과 무관하게 동일합니다.

javascript
// POST /spend  (Node serverless handler)
// Env: RC_SECRET_KEY (sk_...), RC_PROJECT_ID. Never expose these to the client.

// The server owns the price list. Never deduct an amount the client sends:
// a tampered client could buy a 5000-gem item for 1 gem.
const GEM_PRICES = { extra_life: 50, legendary_skin: 5000 };

export async function POST(req) {
  const { item, idempotencyKey } = await req.json();

  // Derive the customer from the authenticated session, NOT from the request body.
  // The client must not be able to spend another user's gems by sending their id.
  const appUserId = await getUserIdFromSession(req); // your auth

  const cost = GEM_PRICES[item];
  if (!Number.isInteger(cost) || cost <= 0) {
    return Response.json({ error: "Unknown item" }, { status: 400 });
  }

  const url =
    `https://api.revenuecat.com/v2/projects/${process.env.RC_PROJECT_ID}` +
    `/customers/${encodeURIComponent(appUserId)}/virtual_currencies/transactions`;

  const headers = {
    Authorization: `Bearer ${process.env.RC_SECRET_KEY}`,
    "Content-Type": "application/json",
    // The SAME key on a retry is applied once, so a network retry can't double-spend.
    "Idempotency-Key": idempotencyKey,
  };

  const rcRes = await fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify({ adjustments: { GEM: -cost } }),
  });

  // 422 = balance too low. The deduction is atomic, so nothing was taken.
  if (rcRes.status === 422) {
    return Response.json({ error: "Not enough gems" }, { status: 402 });
  }
  if (!rcRes.ok) {
    return Response.json({ error: "Spend failed" }, { status: 502 });
  }

  // The gems are gone, atomically. Grant the item, and refund if that fails.
  try {
    await grantItemToUser(appUserId, item);
  } catch (e) {
    // Compensate so we never charge for nothing.
    await fetch(url, {
      method: "POST",
      headers,
      body: JSON.stringify({ adjustments: { GEM: cost } }),
    });
    return Response.json({ error: "Could not grant item, gems refunded" }, { status: 500 });
  }
  return Response.json({ ok: true });
}
422를 사전 확인이 아니라 방어선으로 삼으세요. 잔액을 먼저 읽어 비교하고 싶을 수 있습니다. 그렇게 하지 마세요. 읽기와 쓰기 사이에 잔액이 바뀔 수 있습니다(전형적인 확인 시점 대 사용 시점 경쟁, TOCTOU). 차감은 원자적이고 권위가 있으니, 그냥 시도한 뒤 422를 "잔액 부족"으로 처리하면 됩니다. GET .../virtual_currencies 읽기는 잔액을 표시할 때만 쓰세요.

앱에서 호출하고 402에 반응하기

여기서 402("Payment Required")는 백엔드가 자기 클라이언트에게 돌려주는 애플리케이션 관례입니다. RevenueCat의 422와는 별개로, "RevenueCat이 잔액 부족이라고 한다"를 앱이 이해하는 상태 코드로 바꾸는 것입니다.

swift
// GemStoreModel.swift (continued)
struct SpendRequest: Encodable {
    let item: String
    let idempotencyKey: String
}

enum SpendError: Error { case insufficientGems, failed }

extension GemStoreModel {
    // Create `idempotencyKey` once when the user taps buy, and reuse the SAME key on
    // every retry of this spend. A fresh UUID per attempt gives no double-spend safety.
    func spend(on item: String, idempotencyKey: String) async {
        do {
            var req = URLRequest(url: URL(string: "https://your-api.example.com/spend")!)
            req.httpMethod = "POST"
            req.setValue("application/json", forHTTPHeaderField: "Content-Type")
            req.setValue("Bearer \(yourSessionToken)", forHTTPHeaderField: "Authorization")
            // The server looks up the price from the item; the client never sends a cost.
            req.httpBody = try JSONEncoder().encode(
                SpendRequest(item: item, idempotencyKey: idempotencyKey)
            )

            let (_, response) = try await URLSession.shared.data(for: req)
            let status = (response as? HTTPURLResponse)?.statusCode ?? 500

            if status == 402 { throw SpendError.insufficientGems }
            guard (200..<300).contains(status) else { throw SpendError.failed }

            await refreshBalance()
        } catch SpendError.insufficientGems {
            errorMessage = "You need more gems for that."
        } catch {
            errorMessage = "Could not complete that purchase."
        }
    }
}
kotlin
// Add to GemStoreViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject

private val http = OkHttpClient()

// Create idempotencyKey once per spend intent and reuse it on every retry.
fun spend(item: String, idempotencyKey: String) = viewModelScope.launch {
    try {
        val ok = withContext(Dispatchers.IO) {
            val body = JSONObject()
                .put("item", item) // server looks up the price; client never sends a cost
                .put("idempotencyKey", idempotencyKey)
                .toString()
                .toRequestBody("application/json".toMediaType())
            val req = Request.Builder()
                .url("https://your-api.example.com/spend")
                .header("Authorization", "Bearer $yourSessionToken") // app token, NOT the RC key
                .post(body)
                .build()
            http.newCall(req).execute().use { res ->
                if (res.code == 402) return@withContext false
                if (!res.isSuccessful) throw IllegalStateException("Spend failed")
                true
            }
        }
        if (!ok) {
            _state.update { it.copy(error = "You need more gems for that.") }
            return@launch
        }
        refreshBalance()
    } catch (e: Exception) {
        _state.update { it.copy(error = "Could not complete that purchase.") }
    }
}
dart
// Add to _GemStorePageState
import 'dart:convert';
import 'package:http/http.dart' as http;

// Create idempotencyKey once per spend intent and reuse it on every retry.
Future<void> spend(String item, String idempotencyKey) async {
  try {
    final res = await http.post(
      Uri.parse('https://your-api.example.com/spend'),
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer $yourSessionToken', // app token, NOT the RC key
      },
      body: jsonEncode({'item': item, 'idempotencyKey': idempotencyKey}),
    );
    if (res.statusCode == 402) {
      // show "You need more gems for that."
      return;
    }
    if (res.statusCode ~/ 100 != 2) throw Exception('Spend failed');
    await refreshBalance();
  } catch (e) {
    // show "Could not complete that purchase."
  }
}
typescript
// Create idempotencyKey once per spend intent and reuse it on every retry.
export async function spend(
  item: string,
  idempotencyKey: string,
  yourSessionToken: string,
  setGemBalance: (n: number) => void,
) {
  try {
    const res = await fetch('https://your-api.example.com/spend', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${yourSessionToken}`, // app token, NOT the RC key
      },
      body: JSON.stringify({ item, idempotencyKey }),
    });
    if (res.status === 402) {
      // show "You need more gems for that."
      return;
    }
    if (!res.ok) throw new Error('Spend failed');
    await refreshBalance(setGemBalance);
  } catch {
    // show "Could not complete that purchase."
  }
}
kotlin
// commonMain (Ktor client)
import io.ktor.client.HttpClient
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class SpendRequest(val item: String, val idempotencyKey: String)

// Create idempotencyKey once per spend intent and reuse it on every retry.
suspend fun spend(
    client: HttpClient,
    item: String,
    idempotencyKey: String,
    yourSessionToken: String,
): Boolean {
    val res: HttpResponse = client.post("https://your-api.example.com/spend") {
        header(HttpHeaders.Authorization, "Bearer $yourSessionToken") // app token, NOT the RC key
        contentType(ContentType.Application.Json)
        setBody(Json.encodeToString(SpendRequest(item, idempotencyKey)))
    }
    return when {
        res.status.value == 402 -> false
        res.status.isSuccess() -> { refreshBalance(); true }
        else -> throw IllegalStateException("Spend failed")
    }
}
차감은 성공했는데 아이템 지급이 실패하면 어떻게 될까요? 위 백엔드는 지급이 예외를 던지면 젬을 환불하는데, 아이템이 여러분의 데이터베이스에 있을 때는 이걸로 충분합니다. 하지만 차감과 보상이 서로 다른 시스템에 있으면 (전형적인 "자체 서버의 소프트 커런시로 젬을 교환" 사례) 그 한 번의 환불로는 부족합니다. 멱등성 키와 대사 작업을 갖춘 보상(compensation) 또는 사가(saga) 패턴이 필요합니다. 그것이 바로 이 시리즈의 다음 코드랩 주제입니다.

처음부터 끝까지 테스트하기

구매하고 잔액이 늘어나는지 보기

  1. 스토어의 테스트 설정으로 앱을 실행합니다(iOS는 Xcode의 StoreKit 구성 파일이나 샌드박스 테스터, Android는 내부 테스트의 라이선스 테스터).
  2. 로그인해 안정적인 App User ID를 확보한 뒤, 젬 상점을 엽니다.
  3. gems_1200을 삽니다. 지급이 반영되면 잔액이 1200만큼 올라야 합니다. 적립은 구매가 완료된 잠깐 뒤에 적용되므로, buy()의 폴링이 이를 기다립니다.
  4. 대시보드에서 지급을 확인합니다. Customers를 열어 자신의 App User ID를 찾고, 가상 화폐 잔액과 트랜잭션 이력을 확인하세요.

차감하고 잔액 부족 경로 밟아 보기

  1. 아이템에 젬을 얼마간 써 보고 잔액이 줄어드는지 지켜봅니다.
  2. 보유량보다 많이 써 보세요. 백엔드는 RevenueCat에서 422를 받아 앱에 402를 반환하고, 앱은 잔액이 그대로인 채 "You need more gems"를 표시해야 합니다.
원한다면 지급을 애널리틱스에 반영하세요. 구매로 발생한 지급은 VIRTUAL_CURRENCY_TRANSACTION 웹훅을 내보냅니다(source: in_app_purchase 포함). 잔액의 읽기 전용 사본을 데이터 웨어하우스에 동기화할 때 유용합니다. 단일 진실 소스는 RevenueCat으로 유지하세요. 웹훅은 보고(reporting)용이지, 여러분이 기록하는 두 번째 잔액을 위한 것이 아닙니다.

정리와 다음 단계

무엇을 만들었나

GEM 가상 화폐를 만들고, 검증된 구매 시 잔액을 자동으로 적립하는 소모성 젬 팩을 판매하고, 플랫폼에서 SDK로 실시간 잔액을 표시하고, Secret API 키로 백엔드에서 젬을 안전하게 차감했습니다. 잔액 부족 판정은 RevenueCat의 원자적 422를 권위 있는 방어선으로 삼았습니다. 여러분의 UI가 RevenueCat의 결제 엔진과 잔액 위에 올라간 구조입니다.

단일 진실 소스 체크리스트

  • 화폐마다 단일 진실 소스가 정확히 하나입니다. 여기서는 젬을 처음부터 끝까지 RevenueCat이 소유합니다.
  • 앱은 SDK에서 잔액을 읽기만 하고, 직접 쓰지 않습니다.
  • 지급은 구매 시 자동으로 일어나고, 차감은 시크릿 키로 백엔드를 거칩니다.
  • 같은 App User ID가 클라이언트, 잔액, 백엔드 호출을 연결합니다.
  • 소프트 커런시를 자체 서버에서도 운영한다면 그대로 두세요. 한 화폐를 두 시스템에 복제하지 마세요.

계속 나아가기