비자발적 이탈이라는 문제
모든 취소가 떠나고 싶어서 떠난 고객은 아닙니다. 상당수는 그저 결제 실패입니다. 만료된 카드, 잔액 부족, 은행 거절이 원인이죠. 사용자는 취소를 선택한 적이 없는데, 회복 장치가 없으면 조용히 접근 권한을 잃고 여러분은 매출을 잃습니다.
RevenueCat의 State of Subscription Apps 2026에 따르면 Google Play 구독 취소의 약 3분의 1이 비자발적 청구 실패였습니다(App Store의 비율보다 대략 두 배 높습니다). 보고서는 이렇게 표현합니다. 청구 문제를 고치는 것이야말로 가장 확실한 성장 지렛대 중 하나라고요. 이 코드랩에서 그 방법을 보여 드립니다.
이번에 할 일은 다음과 같습니다.
- App Store Connect와 Google Play에서 청구 유예 기간을 켜서, 스토어가 카드를 재시도하는 동안 접근 권한이 이어지게 합니다.
- RevenueCat으로 청구 재시도 중인 활성 구독을 감지합니다(iOS, Android, React Native).
- Customer Center 또는 스토어의
managementURL로 사용자가 결제 수단을 고치게 합니다. - BILLING_ISSUE 웹훅에 대응해 앱 밖에서 사용자의 재참여를 유도합니다.
자발적 이탈과 비자발적 이탈
전혀 다른 두 사건이 똑같이 "구독이 끝났다"로 나타납니다. 둘을 다르게 다뤄야 합니다.
| 유형 | 원인 | 회복 방법 |
|---|---|---|
| 자발적 | 사용자가 취소를 누름 | 취소 흐름 제안, 윈백, 가치 리마인더 |
| 비자발적 | 갱신 시점에 결제 실패 | 유예 기간, 청구 재시도, 결제 수정 유도 |
이 코드랩이 겨냥하는 것은 비자발적 이탈입니다. 회복 사슬은 이렇습니다. 스토어가 일정 기간 동안 카드를 계속 재시도하고(이 기간이 유예 기간입니다), 사용자는 그동안 접근 권한을 유지하며, 여러분의 앱은 기간이 끝나기 전에 결제 수단을 업데이트하도록 사용자를 살짝 밀어 줍니다.
unsubscribeDetectedAt을 설정하고, 비자발적 실패는 billingIssueDetectedAt을
설정합니다. 후자는 6단계에서 사용합니다. (자발적 이탈은 별도의 Customer Center 코드랩에서 다룹니다.)
App Store Connect에서 유예 기간 켜기
청구 유예 기간을 켜면 Apple이 실패한 갱신을 재시도하는 동안 구독자가 접근 권한을 유지합니다. 그래서 Apple이 제때 결제를 회복하면 서비스나 매출에 끊김이 없습니다.
- App Store Connect에서 앱을 열고 사이드바의 Subscriptions를 클릭하세요.
- Billing Grace Period 섹션에서 Set Up Billing Grace Period를 클릭하세요.
- 기간을 고르세요. 3일, 16일, 28일 중 하나이며(앱의 모든 구독에 일괄 적용됩니다).
- 갱신 유형(전체 갱신, 또는 유료에서 유료로 이어지는 갱신만)과 환경(샌드박스만, 또는 프로덕션과 샌드박스)을 고르세요.
- Confirm을 클릭하세요.
Google Play에서 유예 기간과 계정 보류 켜기
Google Play의 회복 단계는 두 가지입니다. 유예 기간 동안에는 Google이 카드를 재시도하는 사이에도 사용자가 접근 권한을 유지합니다. 이마저 실패하면 구독은 계정 보류로 넘어가는데, 이때 사용자는 접근 권한을 잃는 반면 Google은 계속 재시도합니다. 두 단계 중 어느 쪽에서든 회복하면 구독은 끊김 없이 이어집니다.
- Google Play Console에서 Monetize with Play → Products → Subscriptions로 이동하세요.
- 구독을 열고 자동 갱신되는 기본 요금제(base plan)를 선택하세요. 유예 기간과 계정 보류는 기본 요금제 단위로 설정합니다.
- 유예 기간을 설정하세요(갱신 결제가 해결되지 않는 동안 사용자가 접근 권한을 유지하는 기간).
- 계정 보류 기간을 설정하세요(접근이 중단된 뒤에도 Google이 재시도를 이어 가는 기간).
RevenueCat이 청구 문제를 드러내는 방식
이것이 감지를 단순하게 만드는 핵심 통찰입니다. 구독이 유예 기간에 있는 동안 RevenueCat은 entitlement를
isActive: true로 유지하고(사용자는 여전히 접근 권한이 있습니다), 실패가
감지된 시각으로 billingIssueDetectedAt을 설정합니다.
그래서 "결제 중인 고객인데 방금 카드가 실패했다"라는 신호는 다음과 같습니다.
entitlement.isActive == true AND entitlement.billingIssueDetectedAt != null
=> active subscriber, in billing retry, nudge them to fix payment
entitlement의 관련 필드는 다음과 같습니다(이름은 플랫폼 전반에서 동일합니다).
isActive: 고객이 지금 접근 권한이 있는지. 유예 기간 동안true로 유지됩니다.billingIssueDetectedAt: 날짜/시각 값. 결제 실패가 감지되면 설정되고, 결제가 회복되면 지워집니다. 문제가 없으면 null입니다.willRenew: 구독이 갱신되도록 설정되어 있는지.unsubscribeDetectedAt: 자발적 취소일 때 설정됩니다(청구 문제와 혼동하지 마세요).
billingIssueDetectedAt이 설정됩니다. Google이 계정 보류로
넘어가면 entitlement는 더 이상 활성이 아니라서, 평소의 entitlement 확인만으로도 접근이 이미 막힙니다.
앱 안에서 청구 재시도 감지하기
현재 CustomerInfo를 읽고 pro entitlement에서 청구 문제 신호를 확인하세요.
iOS (Swift)
let info = try await Purchases.shared.customerInfo()
if let pro = info.entitlements["pro"], pro.isActive {
if pro.billingIssueDetectedAt != nil {
// Active subscriber whose payment is failing: show a "fix payment" banner.
showBillingIssueBanner()
}
}
Android (Kotlin)
val info = Purchases.sharedInstance.awaitCustomerInfo()
val pro = info.entitlements["pro"]
if (pro?.isActive == true && pro.billingIssueDetectedAt != null) {
// Active subscriber in billing retry.
showBillingIssueBanner()
}
React Native (TypeScript)
const info = await Purchases.getCustomerInfo();
const pro = info.entitlements.active['pro'];
if (pro && pro.billingIssueDetectedAt != null) {
// Active subscriber in billing retry.
showBillingIssueBanner();
}
CustomerInfo 업데이트도 구독해서 결제가 회복되는 순간 배너가 사라지게 하세요. (React Native
패턴은 CustomerInfo 리스너 가이드를
참고하세요.)
Customer Center로 사용자가 결제 고치게 하기
문제를 감지했다면, 사용자가 한 번의 탭으로 고칠 수 있게 해 주세요. 방법은 두 가지입니다.
방법 A: Customer Center (권장)
Customer Center는 RevenueCat이 제공하는, 바로 붙여 쓰는 셀프서비스 구독 관리 UI입니다. 사용자를 구독 관리와 결제 업데이트로 안내하고, 복원과 취소, 리텐션 제안까지 처리합니다.
// iOS (SwiftUI) - RevenueCatUI
import RevenueCatUI
struct SettingsView: View {
@State private var showCustomerCenter = false
var body: some View {
Button("Manage Subscription") { showCustomerCenter = true }
.sheet(isPresented: $showCustomerCenter) {
CustomerCenterView()
}
}
}
// React Native - react-native-purchases-ui
import RevenueCatUI from 'react-native-purchases-ui';
await RevenueCatUI.presentCustomerCenter();
Android에서는 purchases-ui의 CustomerCenter Composable을 렌더링하세요.
방법 B: 스토어의 관리 페이지를 바로 열기
Customer Center를 쓰지 않는다면, customerInfo.managementURL로 사용자를 스토어의 구독 관리
페이지로 곧장 보내세요. RevenueCat이 올바른 스토어에 맞게 이 값을 자동으로 채워 줍니다.
// React Native
import { Linking } from 'react-native';
const info = await Purchases.getCustomerInfo();
if (info.managementURL) {
await Linking.openURL(info.managementURL);
}
iOS에서는 customerInfo.managementURL을 UIApplication.shared.open(_:)로 열고,
Android에서는 ACTION_VIEW 인텐트로 엽니다.
Manage Subscriptions 가이드를 참고하세요.
청구 문제 웹훅에 대응하기
앱 안 배너는 앱을 여는 사용자에게만 닿습니다. 나머지에게 닿으려면, 서버에서 RevenueCat 웹훅을 활용해 이메일이나 푸시 알림을 보내세요.
BILLING_ISSUE: 결제 실패가 감지되는 즉시 발생합니다. RevenueCat은 문제당 하나씩 보냅니다. 유예 기간이 설정되어 있으면, 회복 마감 시각인grace_period_expiration_at_ms가 포함됩니다.CANCELLATION에cancel_reason: BILLING_ERROR가 붙은 경우: 이 역시 실패가 감지될 때 발생합니다.EXPIRATION에expiration_reason: BILLING_ERROR가 붙은 경우: 유예 기간이 회복 없이 끝날 때만 발생합니다. 이때 비로소 접근 권한을 제거합니다.RENEWAL: 결제가 회복될 때 발생하며,billingIssueDetectedAt이 지워집니다.
// Your webhook endpoint (Express-style)
app.post('/revenuecat/webhook', (req, res) => {
const event = req.body.event;
switch (event.type) {
case 'BILLING_ISSUE':
// Card failed but access continues. Email "update your payment method"
// with the deadline from event.grace_period_expiration_at_ms.
sendFixPaymentEmail(event.app_user_id, event.grace_period_expiration_at_ms);
break;
case 'RENEWAL':
// Payment recovered. Stop the dunning sequence.
clearDunning(event.app_user_id);
break;
}
res.sendStatus(200);
});
EXPIRATION
(BILLING_ERROR)이 유예가 끝날 때까지 미뤄져서, 독촉(dunning) 시퀀스가 작동할 시간을 벌어 줍니다. 유예 기간이
없으면 BILLING_ISSUE와 동시에 즉시 발생해서 접근 권한이 곧바로 사라집니다. 3단계와 4단계에서
설정한 유예 기간이 중요한 이유가 바로 이것입니다.
샌드박스에서 테스트하기
출시하기 전에 전체 루프를 검증하세요.
- App Store Connect에서 Sandbox(또는 Production과 Sandbox)에 유예 기간을 켜서, 샌드박스 테스터로 직접 돌려 볼 수 있게 하세요.
- Google Play에서는 closed testing 트랙의 라이선스 테스터로 구독을 유예와 보류 상태로 몰아넣으세요.
- 갱신 실패를 발생시킨 뒤, 앱에서
proentitlement가 여전히isActive이고billingIssueDetectedAt이 설정되어 배너가 뜨는지 확인하세요. - 서버가
grace_period_expiration_at_ms가 담긴BILLING_ISSUE웹훅을 받았는지 확인하세요. - 결제를 회복시킨 뒤 배너가 사라지고
RENEWAL웹훅이 도착하는지 확인하세요.
정리
비자발적 이탈을 되찾는 완전한 회복 흐름을 만들었습니다.
- 두 스토어의 유예 기간이 카드를 재시도하는 동안 결제 중인 사용자의 접근 권한을 유지합니다.
- RevenueCat이 그 상태를 활성 entitlement +
billingIssueDetectedAt으로 드러내고, 여러분은 이를 앱 안에서 감지합니다. - Customer Center(또는
managementURL)가 사용자에게 한 번의 탭으로 결제를 업데이트할 길을 줍니다. - 웹훅이 앱 밖 독촉(dunning)을 이끌고, 유예 기간 덕분에 타이밍에 여유가 생깁니다.
이 작업은 만들 수 있는 변화 중 지렛대 효과가 가장 큰 축에 듭니다. 떠날 마음이 전혀 없던 고객에게서 매출을 되찾는 일이니까요.
더 나아가기
- Get CustomerInfo와 CustomerInfo 업데이트 리스너: 감지의 기본 구성 요소입니다.
- Manage Subscriptions: managementURL 패턴을 깊이 다룹니다.
- RevenueCat: Billing issues & grace periods와 Customer Center.