구독 결제를 붙이려면 안드로이드는 Google Play Billing Library, iOS는 StoreKit을 각각 붙여야 합니다. 두 SDK는 상품 조회, 구매 흐름, 영수증 형식이 전부 다르고, 여기에 서버 쪽 영수증 검증과 구독 갱신·해지·환불 웹훅까지 직접 구현하면 결제 로직만으로 프로젝트 하나 분량이 나옵니다. RevenueCat은 이 스토어별 차이와 서버 인프라를 대신 떠맡아주는 결제 백엔드이고, purchases-kmp SDK로 Kotlin Multiplatform의 commonMain에서 안드로이드·iOS 결제를 하나의 API로 다룰 수 있습니다.
왜 필요한가
Play Billing과 StoreKit을 직접 붙이면 최소한 아래 네 가지를 스토어별로 각각 구현해야 합니다.
- 상품/구독 목록 조회 API
- 구매 요청과 결과 콜백 처리
- 영수증(구매 토큰 / receipt)을 서버에서 검증
- 구독 갱신·해지·환불이 발생했을 때 서버 측 상태 동기화 (RTDN, App Store Server Notification 등 웹훅 수신)
이 중 서버 검증과 웹훅 처리는 결제 로직 자체보다 인프라 성격이 강해서, 앱 하나 붙이자고 두 스토어의 서버-투-서버 알림 포맷을 각각 파싱하는 백엔드를 새로 만드는 경우가 많습니다. RevenueCat은 이 백엔드를 대신 운영하면서 두 스토어의 구매 데이터를 자체 서버로 수집하고, 그 결과를 SDK 하나로 노출합니다. 클라이언트 입장에서는 "구매하기"와 "지금 구독 상태 확인"만 호출하면 되고, 검증·갱신 추적은 RevenueCat 대시보드 뒤에서 처리됩니다.
핵심 개념
RevenueCat은 스토어의 상품 개념과 앱의 비즈니스 로직 사이에 한 겹을 더 둡니다.
- Product: 스토어 콘솔(Play Console, App Store Connect)에 등록한 실제 SKU. 플랫폼마다 ID 체계가 다릅니다.
- Offering / Package: RevenueCat 대시보드에서 안드로이드·iOS의 Product를 하나로 묶은 그룹. 예를 들어 "월간 구독"이라는 Package 하나가 안드로이드 SKU와 iOS SKU를 동시에 가리킵니다. 앱 코드는 플랫폼별 Product ID를 몰라도 되고, 가격 실험을 위해 Offering을 바꿔도 앱을 다시 배포할 필요가 없습니다.
- Entitlement: "프리미엄 기능에 접근 가능한가"처럼 앱이 실제로 확인하고 싶은 권한 단위. 여러 Package가 같은 Entitlement로 연결될 수 있어서, 구독 상품 구성을 바꿔도 앱 코드의 권한 체크 로직(
entitlements["pro"].isActive)은 그대로 둘 수 있습니다. - CustomerInfo: 현재 로그인한 사용자의 구독 상태 스냅샷. 로컬에 캐시되고, 구매·복원·갱신이 일어날 때마다 RevenueCat이 자동으로 갱신해줍니다.
purchases-kmp는 안드로이드·iOS 네이티브 SDK가 원래 갖고 있던 이 개념을 그대로 commonMain에 노출하고, 콜백 대신 suspend 함수(awaitOfferings, awaitPurchase 등)로 감싸서 코루틴과 자연스럽게 붙습니다.
실전 예시
의존성을 추가합니다.
// composeApp/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.revenuecat.purchases:purchases-kmp-core:2.2.1")
}
}
}
초기화는 플랫폼별로 요구 인자가 다릅니다. 안드로이드는 Context가 필요하고 iOS는 API 키만 있으면 되므로, Room 예시와 마찬가지로 expect/actual로 분리합니다.
// commonMain/payment/PurchasesInitializer.kt
expect fun configurePurchases(apiKey: String, appUserId: String?)
// androidMain/payment/PurchasesInitializer.kt
actual fun configurePurchases(apiKey: String, appUserId: String?) {
Purchases.configure(
PurchasesConfiguration.Builder(appContext, apiKey)
.appUserId(appUserId)
.build()
)
}
// iosMain/payment/PurchasesInitializer.kt
actual fun configurePurchases(apiKey: String, appUserId: String?) {
Purchases.configure(
PurchasesConfiguration.Builder(apiKey)
.appUserId(appUserId)
.build()
)
}
Offering을 가져와서 화면에 그리고, 사용자가 선택한 Package로 구매를 진행하는 흐름은 commonMain에서 한 번만 작성하면 됩니다.
// commonMain/payment/SubscriptionRepository.kt
class SubscriptionRepository {
suspend fun loadMonthlyPackage(): Package? {
val offerings = Purchases.sharedInstance.awaitOfferings()
return offerings.current?.monthly
}
suspend fun purchase(packageToBuy: Package): CustomerInfo {
val params = PurchaseParams.Builder(packageToBuy).build()
val result = Purchases.sharedInstance.awaitPurchase(params)
return result.customerInfo
}
fun isProUnlocked(customerInfo: CustomerInfo): Boolean =
customerInfo.entitlements["pro"]?.isActive == true
}
구매 취소나 네트워크 오류는 예외로 던져지므로, 화면 쪽에서는 이렇게 처리합니다.
// commonMain/payment/SubscriptionViewModel.kt
fun onPurchaseClick(packageToBuy: Package) {
viewModelScope.launch {
try {
val customerInfo = repository.purchase(packageToBuy)
_isPro.value = repository.isProUnlocked(customerInfo)
} catch (e: PurchasesException) {
if (!e.userCancelled) {
_errorMessage.value = e.message
}
}
}
}
기기를 바꾸거나 앱을 재설치한 사용자를 위한 구매 복원도 한 줄입니다.
suspend fun restore(): CustomerInfo = Purchases.sharedInstance.awaitRestore()
비슷한 서비스와 비교
인앱결제 백엔드를 대신 맡아주는 서비스는 RevenueCat 외에도 Adapty, Qonversion이 있고, 이런 서비스 없이 두 스토어 SDK를 직접 붙이는 선택지도 있습니다.
| 항목 | RevenueCat | Adapty | Qonversion | 직접 구현 |
|---|---|---|---|---|
| Kotlin Multiplatform 지원 | 공식 SDK(purchases-kmp) 제공 |
없음 — iOS/Android/Flutter/RN 개별 SDK만 제공 | 없음 — iOS/Android/Flutter/RN 개별 SDK만 제공 | expect/actual로 두 스토어 SDK를 직접 연동 |
| 서버 측 영수증 검증 | 제공 | 제공 | 제공 | 직접 구현 필요 |
| 페이월 UI 컴포넌트 | 제공(purchases-ui-kmp) |
제공 | 제공 | 직접 구현 필요 |
| 구독 분석 대시보드 | 제공 | 제공 | 제공 | 별도 분석 도구 연동 필요 |
| 무료 티어 | 있음 (아래 비용 항목 참고) | 있음, 기준·요율은 RevenueCat과 다름 | 있음, 기준·요율은 RevenueCat과 다름 | 수수료 없음 — 대신 검증·동기화 인프라를 직접 구축·운영해야 함 |
Adapty와 Qonversion도 영수증 검증, 페이월 A/B 테스트 등 기능 구성은 RevenueCat과 크게 다르지 않습니다. 다만 이 셋 중 commonMain에서 바로 쓸 수 있는 공식 KMP SDK를 제공하는 곳은 RevenueCat뿐이라, 이미 KMP로 앱을 짜고 있다면 나머지 둘은 안드로이드·iOS용 SDK를 각각 받아 직접 expect/actual 계층을 얹어야 합니다. Adapty·Qonversion의 정확한 무료 구간 기준액과 수수료율은 RevenueCat보다 변경 빈도가 있어 보이므로, 실제 도입 전에는 각 서비스 공식 요금 페이지에서 최신 수치를 확인하는 게 안전합니다.
비용
RevenueCat은 월 추적 매출(MTR, Monthly Tracked Revenue) 2,500달러까지는 수수료가 없습니다. 이 구간을 넘어서면 초과분에 대해 매출의 1%를 수수료로 부과하는 구조입니다. 스토어(Google/Apple)가 떼어가는 결제 수수료와는 별개로 붙는 금액이라, 실제 앱에 도입할 때는 예상 구독 매출을 기준으로 이 1%가 자체 서버·검증 인프라를 구축·운영하는 비용보다 저렴한지 따져보는 게 좋습니다. 초기 스타트업이나 사이드 프로젝트처럼 매출이 월 2,500달러 이하인 단계에서는 사실상 무료로 결제 인프라 전체를 쓸 수 있다는 뜻이라, 도입 장벽이 낮은 편입니다.
겪을 수 있는 문제
configurePurchases를 호출하기 전에 Purchases.sharedInstance에 접근하면 초기화 예외가 발생합니다. 안드로이드는 Application.onCreate()에서, iOS는 앱 진입 시점에서 가장 먼저 호출되도록 해야 하는데, KMP 프로젝트에서는 이 진입점이 플랫폼마다 다른 파일에 있다 보니 한쪽만 초기화를 넣고 빠뜨리는 실수가 흔합니다. 두 플랫폼의 진입점에 초기화 호출이 모두 있는지는 코드 리뷰에서 따로 확인해야 합니다.
또한 테스트 결제는 실제 결제 흐름과 별개로 스토어 콘솔에 테스트 트랙(Play Console 내부 테스트, App Store Connect 샌드박스 계정)을 등록해야 동작합니다. RevenueCat SDK 자체는 문제가 없는데 스토어 콘솔 설정이 안 돼 있어서 구매가 실패하는 경우가 많으므로, 결제 코드를 디버깅하기 전에 콘솔 쪽 테스트 설정부터 확인하는 편이 시간을 아낍니다.
장단점 정리
장점
- Play Billing과 StoreKit의 API 차이를 몰라도
commonMain코드 하나로 두 스토어 결제를 다룰 수 있음 - 영수증 검증, 구독 갱신·해지 추적용 서버를 직접 운영하지 않아도 됨
- Offering 단위로 가격·상품 구성을 대시보드에서 바꿀 수 있어서, 가격 실험 때마다 앱을 재배포할 필요가 없음
- 구독 지표(전환율, 이탈률, MRR) 대시보드가 기본 제공됨
단점
- 매출이 커질수록 수수료(매출의 1%)도 함께 커짐 (자세한 내용은 위 비용 항목 참고)
- 결제 데이터의 진실 공급원(source of truth)이 RevenueCat 서버가 되므로, 자체 백엔드와 구독 상태를 동기화하려면 RevenueCat의 웹훅을 다시 받아 처리하는 연동 작업이 필요함
- 스토어 콘솔에는 없는 RevenueCat만의 개념(Offering, Entitlement)이 하나 더 늘어나므로, 상품 구성을 바꿀 때 스토어 콘솔과 RevenueCat 대시보드 두 곳을 함께 관리해야 함
직접 서버를 붙여 영수증 검증과 웹훅을 관리할 여력이 없는 팀이라면 RevenueCat이 결제 인프라를 통째로 대체해줍니다. 반대로 이미 자체 결제 서버가 있고 매출 규모가 커서 수수료 부담이 크다면, RevenueCat 없이 두 스토어 SDK를 직접 붙이는 쪽이 장기적으로 더 저렴할 수 있습니다.