무엇을 만드나요
많은 앱에는 두 종류의 화폐가 있습니다. 하나는 플레이로 얻는 소프트 커런시(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의 결제 엔진과 잔액을 쓰되, 상점 UI는 여러분의 네이티브 화면으로 만듭니다(RevenueCat Paywall UI는 필요하지 않습니다). 그래야 젬 상점이 이미 디자인해 둔 소프트 커런시 상점과 시각적으로 일관됩니다.
누구를 위한 코드랩이고, 무엇이 필요한가
게임이나 소셜 앱에 소모성 하드 커런시를 더하려는 모바일 개발자를 위한 코드랩입니다. 자신이 쓰는 플랫폼의 UI와 비동기 모델에 익숙해야 하고, App Store Connect나 Google Play에 앱이 설정돼 있어야 합니다(실제 앱이거나, 로컬 테스트용 StoreKit 구성 파일 또는 라이선스 테스터라도 됩니다). 가상 화폐에는 최신 SDK가 필요합니다.
| 플랫폼 | SDK | 최소 버전 |
|---|---|---|
| iOS | purchases-ios | 5.32.0 |
| Android | purchases-android | 9.1.0 |
| Flutter | purchases_flutter | 9.1.0 |
| React Native | react-native-purchases | 9.1.0 |
| Kotlin Multiplatform | purchases-kmp | 2.1.0+16.2.0 |
가상 화폐 만들기
RevenueCat 대시보드에서 프로젝트의 Product catalog를 열어 Virtual Currencies를 선택한 뒤, + New virtual currency를 누릅니다. 중요한 필드는 두 개입니다.
- Code: SDK와 API에서 사용하는 식별자입니다(예:
GEM). 코드에서 참조하게 되니 신중히 정하세요. - Name: 표시 이름입니다(예:
Gems).
선택 사항으로 아이콘과 설명도 추가할 수 있습니다. 저장하면 모든 고객의 시작 잔액이 0인 상태로 화폐가 생깁니다.
젬 팩 상품 만들기와 지급량 설정
젬은 실제 돈으로 사는 것이므로, 젬 팩은 저마다 소모성(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_300 | 300 GEM |
gems_1200 | 1200 GEM |
gems_6500 | 6500 GEM |
이제부터는 고객이 이 상품들을 살 때마다 RevenueCat이 스토어 영수증을 검증하고, 설정한 수량을 젬 잔액에 자동으로 더합니다. 적립 코드는 한 줄도 작성하지 않습니다.
SDK 설치와 구성
플랫폼에 맞는 RevenueCat SDK를 설치하고, 앱 시작 시점에 public API 키로 한 번 구성합니다.
키 접두사는 빌드가 향하는 스토어에 따라 다릅니다. Apple 키는 appl_로,
Google 키는 goog_로, Amazon 키는 amzn_로 시작합니다.
크로스 플랫폼 SDK에서는 런타임에 플랫폼별로 알맞은 키를 고릅니다.
// 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()
}
}
}
// 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()
)
}
}
// 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);
}
// 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',
});
}
// 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를 배정하는데, 그때 산 젬은 그 익명 고객에게 남아 새 기기나 새로 설치한 앱으로 사용자를 따라오지
않습니다. 사용자가 로그인하는 즉시 자체적으로 안정적인 사용자 ID로 logIn을 호출해,
젬 잔액이 어디서나 그 사용자의 것이 되게 하세요.
// 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).
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
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;
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');
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
상점 화면 만들기
gems Offering을 가져와, 각 Package를 지역화된 가격 문자열과 함께 렌더링합니다.
RevenueCat은 사용자의 스토어프론트 화폐에 맞춰 이미 포맷된 가격을 돌려주니, 가격을 절대 하드코딩하지 마세요.
여기서 참조하는 buy와 refreshBalance 함수는 다음 두 단계에서 추가합니다.
// 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.
}
// 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 ?? "")
}
}
}
}
// 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.
}
// 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) }
)
}
}
}
// 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.
}
// 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>
);
}
// 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) } }
)
}
}
}
젬 구매하기
선택한 Package에 대해 구매 API를 호출합니다. RevenueCat이 스토어 구매를 진행하고 영수증을 검증하며, 상품이
GEM에 연결돼 있으므로 자사 서버에서 젬을 자동으로 적립합니다. 여러분이 할 일은
취소를 처리하고 그 뒤 잔액을 새로 고치는 것뿐입니다.
// 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)"
}
}
}
// 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}") }
}
}
// 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
}
}
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
}
}
// 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
}
}
buy는 한 번만 읽지 않고 폴링합니다. 앱이 포그라운드로 돌아올 때도 잔액을
새로 고쳐, 놓친 업데이트가 스스로 복구되게 하세요. 그 새로고침은 완료된 구매가 사용자 취소로 보고되는 드문 경우도
함께 처리해 줍니다.
잔액 표시하기
가상 화폐 API로 잔액을 읽고, all 맵에서 코드로 자신의 화폐를 꺼냅니다. 잔액은 SDK가
캐시하며 스스로 갱신되지 않으니, 잔액을 바꾸는 일(구매나 백엔드 차감)이 있은 뒤에는 캐시를 무효화하고
다시 가져오세요.
// 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)"
}
}
}
// 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}") }
}
}
// 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
}
}
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;
}
// 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
}
첫 화면을 빠르게 그리려면, 네트워크 응답이 오기 전에 캐시된 값을 읽을 수 있습니다.
// iOS exposes a synchronous cached property (nil before the first fetch):
let cached = Purchases.shared.cachedVirtualCurrencies?.all["GEM"]?.balance
// Android also exposes a synchronous cached property (null before the first fetch):
val cached = Purchases.sharedInstance.cachedVirtualCurrencies?.all["GEM"]?.balance
// Flutter's cached accessor is an async method (not a property):
final cached = (await Purchases.getCachedVirtualCurrencies())?.all["GEM"]?.balance;
// React Native's cached accessor is an async method (not a property):
const cached = (await Purchases.getCachedVirtualCurrencies())?.all['GEM']?.balance;
// KMP's cached accessor is a method (not a property):
val cached = Purchases.sharedInstance.getCachedVirtualCurrencies()?.all["GEM"]?.balance
getCachedVirtualCurrencies())입니다. 각
VirtualCurrency는 balance와 함께 code, name,
serverDescription도 담고 있으니, 앱에 "Gems"를 하드코딩하는 대신 대시보드에서 라벨을 끌어올 수 있습니다.
젬 차감하기 (백엔드)
젬 차감은 반드시 여러분이 통제하는 서버에서 RevenueCat
Secret API 키로 이뤄져야 합니다(이 키는 sk_로 시작합니다). 시크릿 키는 잔액을 움직일 수
있으므로 앱에 절대 포함돼서는 안 됩니다. 흐름은 이렇습니다. 앱이 백엔드에 차감을 요청하고, 백엔드가 RevenueCat을 통해
차감하며, 사용자가 감당할 수 있었는지는 그 차감 자체가 판정합니다.
차감 엔드포인트 (여러분의 백엔드)
고객의 가상 화폐 트랜잭션 엔드포인트로 adjustments 맵을 POST합니다. 음수는 차감이고 양수는 지급입니다.
맵 전체는 원자적(atomically)으로 적용됩니다. 맵에 든 화폐 중 하나라도 잔액이 부족하면 아무것도
차감되지 않고 RevenueCat이 HTTP 422를 반환합니다. 가격은 서버가 항목 이름을 보고
정하며, 클라이언트가 보낸 금액을 차감하는 일은 절대 없습니다. 이 부분은 앱 플랫폼과 무관하게 동일합니다.
// 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 });
}
GET .../virtual_currencies 읽기는
잔액을 표시할 때만 쓰세요.
앱에서 호출하고 402에 반응하기
여기서 402("Payment Required")는 백엔드가 자기 클라이언트에게 돌려주는 애플리케이션 관례입니다. RevenueCat의 422와는 별개로, "RevenueCat이 잔액 부족이라고 한다"를 앱이 이해하는 상태 코드로 바꾸는 것입니다.
// 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."
}
}
}
// 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.") }
}
}
// 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."
}
}
// 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."
}
}
// 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")
}
}
처음부터 끝까지 테스트하기
구매하고 잔액이 늘어나는지 보기
- 스토어의 테스트 설정으로 앱을 실행합니다(iOS는 Xcode의 StoreKit 구성 파일이나 샌드박스 테스터, Android는 내부 테스트의 라이선스 테스터).
- 로그인해 안정적인 App User ID를 확보한 뒤, 젬 상점을 엽니다.
gems_1200을 삽니다. 지급이 반영되면 잔액이 1200만큼 올라야 합니다. 적립은 구매가 완료된 잠깐 뒤에 적용되므로,buy()의 폴링이 이를 기다립니다.- 대시보드에서 지급을 확인합니다. Customers를 열어 자신의 App User ID를 찾고, 가상 화폐 잔액과 트랜잭션 이력을 확인하세요.
차감하고 잔액 부족 경로 밟아 보기
- 아이템에 젬을 얼마간 써 보고 잔액이 줄어드는지 지켜봅니다.
- 보유량보다 많이 써 보세요. 백엔드는 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가 클라이언트, 잔액, 백엔드 호출을 연결합니다.
- 소프트 커런시를 자체 서버에서도 운영한다면 그대로 두세요. 한 화폐를 두 시스템에 복제하지 마세요.
계속 나아가기
- 이 시리즈의 다음 편: 하드 커런시를 자체 서버에 있는 소프트 커런시로 교환하기. 멱등성 키와 보상(사가) 패턴으로, 흐름 도중 실패해도 화폐가 사라지거나 중복되지 않게 합니다.
- 플랫폼에서 RevenueCat이 처음이라면 IAP 기초부터 시작하세요. iOS, Android, Flutter, React Native, Kotlin Multiplatform.
- 사용자 식별과 CustomerInfo 가져오기: 여기서 쓴 아이덴티티 구성 요소입니다.
- RevenueCat: Virtual Currencies와 잔액 단일 진실 소스 가이드.