Flutter 구독에 무료체험을 붙였더니 "체험 눌렀는데 즉시 결제"가 터졌다 — Android offer 선택 함정
Android는 구독 productId 하나에 offer(base plan·무료체험)마다 별도의 ProductDetails를 돌려주고, 어느 인스턴스를 PurchaseParam에 넘기느냐가 어떤 offer로 결제될지 결정한다. in_app_purchase에서 id만 맞는 첫 항목을 집으면 체험 버튼이 base plan을 결제하거나 표시 가격이 ₩0으로 뜬다. 링고루프에서 잡은 offer 선택·표시 가격 버그와 수정 코드.
TL;DR
- Flutter(
in_app_purchase) 구독 앱에 7일 무료체험 offer를 붙이자마자 두 가지가 터졌다.- 체험 버튼을 눌렀는데 base plan으로 즉시 결제
- 화면의 구독 가격이 ₩0/월로 표시
- 원인은 결제 라이브러리 버전이 아니라 Android의 offer 모델이다. Android는 하나의 구독 productId에 offer 개수만큼
ProductDetails를 돌려주고(id는 전부 동일), 어느 인스턴스를PurchaseParam에 넘기느냐가 어떤 offer로 결제되는지를 정한다(내부적으로offerToken). id만 맞는 첫 항목을 집던 코드가 Google이 순서를 보장하지 않는 offer 목록에서 아무거나 골라버린 것. 수정은 ① 무료(0원) phase를 가진 offer를 우선 선택, ② 표시 가격은infiniteRecurringphase에서 뽑기.- iOS(StoreKit)는 offer 개념이 없어
price가 이미 정가라 Android 경로에서만 처리한다. - 참고: 이건 Play Billing 8 / target API 36 대응 작업 중 전체 점검하다 잡혔지만, 그 정책 변경과는 인과가 없는 기존 버그다. 무료체험/인트로 offer를 붙이는 순간 어느 버전에서든 나온다.
문제 상황
링고루프(Flutter, 구독)에 스토어 연동 7일 무료체험을 붙였다. Play Console에서 구독 상품(productId)에 base plan 외에 무료체험 offer를 하나 추가하고, 앱 paywall에 “7일 무료체험” 문구와 카운트다운을 넣는 흐름이다.
붙이자마자 두 증상이 나왔다.
- 체험 버튼을 눌렀는데 base plan으로 바로 결제됐다. 무료 기간 없이 첫 달이 즉시 청구.
- 구독 화면 가격이 ₩0으로 떴다. “₩0/월”처럼 보여서, 정가가 얼마인지 화면에서 알 수 없었다.
결제 라이브러리(in_app_purchase)는 정상이고, 코드도 그대로였다. 바뀐 건 “Play에 offer를 하나 더 등록한 것”뿐이었다.
원인: Android는 offer 개수만큼 ProductDetails를 준다
핵심은 Android의 구독 상품 모델이다.
- 하나의 구독
productId에 base plan + 무료체험 offer를 등록하면,queryProductDetails는 그 productId에 대해 offer 개수만큼ProductDetails를 돌려준다. 위 경우 2개이고,id는 둘 다 동일하다. - 그리고 어느
ProductDetails인스턴스를PurchaseParam에 넘기느냐가 곧 어떤 offer로 결제되는지를 결정한다. 내부적으로 각 인스턴스는 서로 다른offerToken을 들고 있다.
기존 코드는 이랬다 — id만 맞는 첫 항목을 집는다.
// 기존: id만 맞으면 첫 항목. offer가 1개일 땐 우연히 맞았을 뿐이다.
ProductDetails? get premiumProduct => products
.cast<ProductDetails?>()
.firstWhere((product) => product?.id == productId, orElse: () => null);
offer가 base plan 하나뿐일 때는 이게 우연히 맞았다. 그런데 무료체험 offer를 등록해 목록이 2개가 되는 순간, Google이 offer 순서를 보장하지 않기 때문에 firstWhere가 base plan을 집을 수도, 체험 offer를 집을 수도 있게 됐다. 그래서 “체험 눌렀는데 base plan 결제”가 나온 것이다.
고치기 ① — 결제에 넘길 offer를 명시적으로 고른다
“첫 항목”이 아니라 의도한 offer를 골라 그 인스턴스를 넘겨야 한다. 다행히 규칙이 단순하다 — Play는 사용자가 자격을 갖춘 offer만 내려준다. 체험을 이미 소진했으면 애초에 목록에 체험 offer가 없다. 그러니 목록에 무료(0원) phase를 가진 offer가 있으면 그걸 고르는 게 언제나 사용자에게 유리하다.
/// 결제에 사용할 상품. Android는 하나의 구독 productId에 대해 offer
/// 개수만큼 ProductDetails 를 돌려주고(id는 모두 동일), 어느 인스턴스를
/// PurchaseParam 에 넘기느냐가 어떤 offer 로 결제되는지를 결정한다.
/// 무료 phase 를 가진 offer 가 목록에 있으면 그게 항상 사용자에게 유리하다
/// (체험을 이미 썼다면 Play 가 애초에 안 내려준다).
ProductDetails? get premiumProduct {
final matching = products.where((p) => p.id == productId).toList();
if (matching.isEmpty) return null;
for (final product in matching) {
if (_freeTrialPhase(product) != null) return product; // 0원 phase 우선
}
return matching.first;
}
offer와 무료 phase는 in_app_purchase_android의 플랫폼 타입을 꺼내 판별한다. ProductDetails를 GooglePlayProductDetails로 캐스팅하면 offer 상세(SubscriptionOfferDetailsWrapper)에 접근할 수 있다.
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
/// product 가 가리키는 Android 구독 offer. 다른 플랫폼이면 null.
static SubscriptionOfferDetailsWrapper? _offerOf(ProductDetails product) {
if (product is! GooglePlayProductDetails) return null;
final index = product.subscriptionIndex;
final offers = product.productDetails.subscriptionOfferDetails;
if (index == null || offers == null || index >= offers.length) return null;
return offers[index];
}
/// offer 안의 0원 phase(=무료체험). 없으면 null.
static PricingPhaseWrapper? _freeTrialPhase(ProductDetails product) {
final offer = _offerOf(product);
if (offer == null) return null;
for (final phase in offer.pricingPhases) {
if (phase.priceAmountMicros == 0) return phase;
}
return null;
}
포인트는 GooglePlayProductDetails.subscriptionIndex 다. in_app_purchase의 상위 ProductDetails만 봐서는 “이 인스턴스가 어느 offer인지”를 알 수 없고, 플랫폼 타입까지 내려가야 subscriptionOfferDetails[subscriptionIndex]로 그 offer의 pricingPhases를 볼 수 있다.
고치기 ② — 표시 가격은 “정기 결제 phase”에서 뽑는다
두 번째 증상(₩0 표시)도 같은 뿌리다. ProductDetails.price는 무료체험 offer에서 첫 pricing phase를 가리키는데, 그게 0원(체험 phase) 이라 그대로 쓰면 “₩0/월”로 잘못 고지된다. 사용자에게 보여줄 건 체험이 끝난 뒤 계속 청구되는 실제 구독료 — 즉 무한 반복(infiniteRecurring) phase의 가격이다.
/// 화면에 노출할 "월 정가". price 는 무료체험 offer 의 첫 phase(=0원)를
/// 가리키므로 그대로 쓰면 결제 금액을 잘못 고지한다. 정기 결제 phase 를 쓴다.
String? get premiumPriceLabel {
final product = premiumProduct;
if (product == null) return null;
final offer = _offerOf(product);
if (offer == null) return product.price; // iOS 등: price 가 이미 정가
final phases = offer.pricingPhases;
if (phases.isEmpty) return product.price;
// 무한 반복 phase = 체험/할인이 끝난 뒤 계속 청구되는 실제 구독료.
final recurring = phases.lastWhere(
(phase) => phase.recurrenceMode == RecurrenceMode.infiniteRecurring,
orElse: () => phases.last,
);
return recurring.formattedPrice;
}
한 offer의 pricingPhases는 시간 순서로 들어온다(예: 0원 7일 → ₩3,900 무한반복). 그래서 정가는 infiniteRecurring phase, 체험 여부는 0원 phase 존재로 각각 판별하면 화면 고지와 실제 결제가 어긋나지 않는다.
iOS는 왜 안 건드렸나
iOS(StoreKit)에는 이런 “한 상품에 offer 여러 개” 모델이 없다. ProductDetails.price가 이미 정가이고, offer 선택이라는 개념 자체가 없다. 그래서 위 로직은 전부 GooglePlayProductDetails일 때만 타도록 하고(_offerOf가 null이면 product.price 그대로 반환), iOS 동작은 손대지 않았다.
체험 노출 여부도 마찬가지다. Android는 스토어가 실제로 체험 offer를 내려줬는지(=_freeTrialPhase != null)로 판단하지만, iOS는 그 정보를 안 주므로 원격 설정 값(trialEnabled)을 그대로 따른다.
/// 스토어가 실제로 무료체험 offer 를 내려줬는지. Android 는 자격 있는
/// offer 만 오므로 이 값이 정확하다. iOS 는 이 정보가 없어 remote config
/// 를 따른다 — 즉 iOS 동작은 기존과 동일.
bool get storeTrialAvailable {
final product = premiumProduct;
if (product == null || _offerOf(product) == null) return trialEnabled;
return _freeTrialPhase(product) != null;
}
즉 Android는 스토어 실데이터를 신뢰하고, iOS는 원격 설정을 신뢰하는 두 갈래로 갈라두면, 스토어가 체험 offer를 안 주는 상황(설정 실수·자격 소진)에서도 “체험 있다고 표시했는데 실제로는 없음” 같은 어긋남이 안 생긴다. 스토어가 체험을 안 주면 문구 자체를 숨긴다.
회귀 테스트로 못을 박는다
이런 종류는 “고쳤다”로 끝내면 다음 리팩터에서 조용히 되살아난다. offer 목록 순서를 뒤집은 케이스를 포함해 단위 테스트를 붙였고, 구 구현으로는 2건이 실패하는 걸 확인했다(= 테스트가 실제로 이 버그를 잡는다).
테스트 관점 체크리스트:
- offer가
[base plan, 무료체험]순일 때 체험이 선택되는가 - offer가
[무료체험, base plan]으로 뒤집혀도 동일하게 동작하는가 - 체험 offer가 없을 때 base plan이 선택되고 체험 문구가 숨는가
- 표시 가격이 0원이 아니라 정기 결제가인가
정리
- 구독 앱에서 “결제 라이브러리 버전”과 “offer 처리”는 별개 문제다. 라이브러리는 플러그인이 올려주지만, offer가 여러 개인 구독은 “어느 offer로 결제하고 어느 가격을 보여줄지”를 코드가 명시적으로 골라야 한다.
- Android는 한 productId에 offer 개수만큼
ProductDetails, 인스턴스가 곧 offer(offerToken). 첫 항목 아무거나 집으면 안 된다. - 선택은 0원 phase를 가진 offer 우선, 표시 가격은
infiniteRecurringphase. 판별엔in_app_purchase_android의GooglePlayProductDetails.subscriptionIndex→subscriptionOfferDetails→pricingPhases를 쓴다. - 무료체험/인트로 offer를 붙이는 순간 표면으로 드러나는 함정이라, offer 구조를 바꿀 때마다 위 4개 테스트를 돌려두는 게 싸다.
이 버그는 Play Billing 8 / target 36 정책 대응 작업 중 전체 점검에서 잡혔지만, 그 정책 변경이 만든 회귀는 아니다. “버전 올리는 김에 근처를 점검하다 기존 버그를 잡는” 흐름의 전형적인 산물이다.