광고 제거 일회성 구매의 권한을 유지하고 되돌리는 방법

광고를 영구히 없애는 상품을 스도쿠 앱 DailySudoku의 iOS판과 Android판에 넣었어요. 처음에는 구매가 성공했는지만 저장하면 될 것처럼 보였어요. 그런데 구매 직후의 결과만으로는 그 뒤의 상태를 판단하기 어려웠어요. 앱을 다시 설치하거나, 결제가 대기 상태로 남거나, 환불되는 경우가 있기 때문이에요.

소스의 기준은 2026년 8월 6일 develop 브랜치의 커밋 9c999305예요. 스토어에 출시된 판에서 확인한 것과는 별개예요. 아래에는 그 소스의 동작, Apple과 Google 문서의 설명, 자동 테스트가 다루는 범위를 구분해서 적었어요. 이 글에서 확인할 수 있는 것은 여기까지이고, 실기기에서 결제하고 환불한 기록과 실행 로그는 들어 있지 않아요.

구매 결과만 저장하면 놓치는 일

구매 알림은 입력 가운데 하나일 뿐이에요. 보유 여부는 지금의 entitlement 목록을 읽어서 갱신해요. 구매 결과만 저장하면 아래 일들을 놓쳐요.

  • 사용자가 결제를 취소한 경우
  • 대기 상태였던 결제가 앱이 꺼진 뒤에 완료된 경우
  • 다른 기기에서 구입한 경우
  • 데이터를 초기화했거나 새로 설치한 경우
  • 환불, 취소, revoke가 일어난 경우
  • 결과를 받기 전에 연결이 끊긴 경우

그래서 입력 세 갈래를 하나의 권한 상태로 모아요.

구매 이벤트 ───────┐
앱 시작·복귀 조회 ─┼─> 검증된 현재 권한 ─> 로컬 캐시 ─> 광고 게이트
스토어 외부 변경 ──┘

갈래마다 맡은 일이 달라요. 구입 알림은 사용자에게 바로 안내하는 데 써요. 앱을 시작하거나 foreground로 돌아올 때의 조회는 놓친 변경과 다른 기기의 변경을 회복해요. update는 앱이 실행 중일 때의 변경을 받아요. 셋이 최종적으로 같은 adFree 값을 공유해요.

로컬에 둔 값은 캐시예요

기기에 저장한 Boolean은 화면을 그리기 위한 캐시이고, 소유권의 원본이 아니에요. snapshot과 update 모두 true와 false를 그대로 반영해요. 환불되면 false가 들어가서 광고가 다시 허용돼요.

여기에 문제가 하나 있어요. adFree == false는 «보유하지 않음»일 수도 있고 «아직 조회하지 않음»일 수도 있어요. 두 경우를 구분하지 않으면 구매한 사용자에게 앱을 켠 직후 광고가 나올 수 있어요. 그래서 조회를 마쳤는지를 entitlementsSynced로 따로 표시해요.

광고를 허용하는 조건은 세 값으로 계산해요.

adsEnabled = entitlementsSynced && !adFree && consentAllowsAds

entitlementsSynced == false인 동안에는 광고를 요청하지 않아요. 구매하지 않은 사용자는 첫 복원이 끝난 뒤에 광고 경로로 들어가고, 구매한 사용자는 adFree == true라서 계속 막혀요. 설정, 홈, 난이도 선택, 게임, 통계 화면은 이 판단을 함께 써요.

광고 객체의 처리는 플랫폼마다 달라요. 막힌 상태일 때 Android에서는 Compose subtree를 아예 만들지 않고, 붙어 있던 native view를 해제해요. iOS에서는 배너를 접고 추가 load를 억제해요. iOS에서 이미 로드된 provider를 파기하는지는 자동 테스트로 검증하지 않아요. 광고가 화면에서 사라졌다는 것만으로 SDK의 작업이 끝났다고 말할 수는 없어요.

iOS: StoreKit 2에서 읽는 것

구매는 Product.purchase()의 결과에 따라 처리해요.

  • .success(.verified(transaction))이면 권한을 부여하고 transaction을 finish해요.
  • .success(.unverified(...))이면 권한을 부여하지 않아요.
  • .pending과 .userCancelled에서도 부여하지 않아요.

실행 중의 변경은 Transaction.updates로 관찰해요. Ask to Buy, 다른 기기에서의 구매, revoke가 여기로 와요. 앱을 시작할 때와 foreground로 돌아올 때에는 Transaction.currentEntitlements에서 광고 제거 상품의 product ID가 있는지와 verified인지를 확인해요. 문서에 따르면 이 목록은 non-consumable의 최신 entitlement를 담고, 환불되거나 revoke된 항목은 빠져요. 조회가 끝났는데 일치하는 상품이 없으면 false를 저장해요.

재설치하거나 새 기기를 쓸 때에는 최신 transaction이 자동으로 제공되므로, 평소에는 currentEntitlements 조회로 충분해요. 수동 복원은 사용자가 설정에서 복원을 고를 때에만 실행해요. 이때 AppStore.sync()를 호출한 뒤 권한을 다시 조회해요. 이 호출은 인증 창을 띄울 수 있어서 앱을 시작할 때 자동으로 실행하지 않아요.

가격은 따로 갱신해요. 소유권과 동의의 흐름이 가격 응답을 기다릴 필요는 없어요.

Android: Play Billing에서 읽는 것

상품 유형은 Android에서 INAPP이에요. 일회성 상품에는 non-consumable을 위한 별도 API가 없어서, consume을 하지 않고 acknowledge를 해요.

흐름은 Play Billing 연동 절차를 따라요. queryProductDetailsAsync로 그 사용자에게 팔 수 있는 상품과 현지화된 가격을 받고, 받은 ProductDetails와 offer token으로 launchBillingFlow를 호출해요. 구매는 PurchasesUpdatedListener로 오거나, 나중의 queryPurchasesAsync에서 발견돼요.

권한은 product ID와 구매 상태를 확인해서 PURCHASED일 때에만 부여하고, 소모하지 않는 구매이므로 acknowledge해요. PENDING은 실패가 확정된 것이 아니에요. 현금 결제나 추가 승인처럼 완료를 기다리는 상태예요. Google의 지침은 PENDING인 동안 혜택을 주지 말고 PURCHASED로 바뀐 뒤에 처리하라는 것이에요. 상태는 앱이 꺼져 있는 동안에도 바뀔 수 있어서, onResume()에서 queryPurchasesAsync()로 놓친 변경을 회복해요.

검증과 acknowledge는 다른 일이에요. 검증은 구매가 정당한지와 이미 처리한 것인지를 보는 일이고, acknowledge는 혜택을 전달했다고 Play에 알리는 일이에요. 문서에 따르면 일반 구매는 PURCHASED로 바뀐 뒤 3일 안에 acknowledge하지 않으면 자동으로 환불될 수 있고, 라이선스 테스터의 구매는 이 시한이 3분이에요.

지금 구현이 믿는 범위는 좁아요. BillingClient가 알려 준 PURCHASED와 product ID를 확인하고 클라이언트에서 acknowledge해요. 서버에서 purchase token을 검증하지 않아요. Google의 결제 보안 안내는 혜택을 주기 전에 secure backend에서 Google Play Developer API로 검증하고, acknowledge도 서버에서 하기를 권해요. 지금은 클라이언트를 믿는 구성을 받아들인 것이고, 서버 검증을 마쳤다고 말할 수 없어요.

복원, 환불, 그리고 조회 실패

복원의 목적은 과거에 샀는지를 찾는 것이 아니에요. 지금의 snapshot과 캐시를 일치시키는 것이에요. 시작할 때와 돌아올 때 Android에서는 queryPurchasesAsync(INAPP)로 조회하고, 실행 중의 변경은 listener로 받거나 다음 resume의 조회에서 회복해요. 수동 복원도 현재 inventory를 다시 조회하는 것이에요.

환불이 반영되는 시점은 플랫폼마다 달라요. iOS는 update나 그 뒤의 snapshot에서 반영돼요. 다음 inventory 조회가 성공했을 때 Android의 값이 false가 돼요.

빈 결과와 오류는 구별해야 해요. 상품이 없다는 것과 스토어에 접속하지 못했다는 것은 다른 사건이에요. 지금 Android는 연결이나 inventory 조회에 오류가 나면 adFree를 그대로 두고 entitlementsSynced = true로 해요. 이 정책에는 양쪽으로 불이익이 있어요. 재설치한 구매자는 캐시가 false라서 복구되기 전까지 광고를 볼 수 있어요. 환불한 사용자는 조회가 성공하기 전까지 광고가 계속 막힐 수 있어요.

iOS의 모델에도 빈 곳이 있어요. verified이면서 일치하는 항목이 없으면 false가 되는데, unverified인 경우나 일시적으로 잘못 비어 있는 경우를 따로 분류하지 않아요. 두 플랫폼의 장애 정책은 서로 다르고, 어느 쪽도 안전하다는 보장은 아니에요.

개선안은 상태를 unknown, owned, notOwned로 나누거나, 오류와 성공한 빈 결과를 구별하는 것이에요. 그 위에서 제품마다 fail-open으로 할지 fail-closed로 할지 정해요. 아직 제안이고 고친 것이 아니에요.

동시에 도는 복원도 확인해야 해요. 캐시에 쓰는 비동기 경로는 구매, update, 시작, resume, 수동 복원 다섯 개예요. 시작 조회와 foreground 조회가 어떤 순서로 끝나는지가 문제이고, connection을 잠그는 것만으로는 reconcile 전체가 직렬화되지 않아요. 늦게 도착한 과거의 snapshot이 새 값을 덮어쓸 수 있어요.

가격을 받지 못했을 때

가격은 스토어가 주는 현지화된 값을 쓰고, 고정된 가격을 화면에 적지 않아요. 두 플랫폼의 설정 화면은 가격이 nil이면 광고 제거 행을 숨겨요. 그런데 수동 복원이 그 행의 화면에 있어요.

그 결과 가격 조회가 실패하면 수동으로 다시 시도할 입구가 사라져요. 시작과 resume의 자동 복구는 계속 돌아요. 등록되지 않은 상품과 일시적인 네트워크 장애를 화면에서 구별하지도 못해요.

개선안은 구매 버튼만 비활성화하고 복원은 따로 보여 주는 것, 그리고 상품 조회의 빈 응답과 예외를 재시도와 관측에서 구별하는 것이에요. 이것도 제안이에요.

시험할 수단과 기댓값

Apple 쪽에서는 Xcode의 StoreKit configuration으로 transaction, 중단된 구매, refund, revoke를 로컬에서 시험할 수 있어요. 계정이 관여하는 흐름은 Sandbox나 TestFlight에서 봐요.

license tester를 쓰면 Android에서 실제 구매 흐름과 테스트 결제 수단을 시험할 수 있어요. package name과 tester 계정 조건이 맞으면 debug 서명으로 sideload한 빌드에서도 시험할 수 있고, 테스트 트랙은 배포하기 전에 확인하는 용도예요. ADB로 직접 설치하면 무조건 실패한다거나 Play로 설치한 것만 된다는 단정은 맞지 않아요.

아래는 확인하려는 기댓값이에요. 실행해서 통과한 기록이 아니에요.

  • 신규 구매: 광고 요청이 멈추고, 다시 실행해도 유지되고, 다시 구매할 수 없어요.
  • 사용자 취소: 권한도 성공 메시지도 없고, 다시 구매를 시도할 수 있어요.
  • 대기 뒤 승인: 대기 중에는 혜택이 없고, PURCHASED가 된 뒤 한 번 부여돼요.
  • 대기 뒤 취소: 혜택이 부여되지 않아요.
  • 이미 보유: 새 기기, 재설치, 수동 복원에서 보유가 회복돼요.
  • 앱 밖에서 완료: update나 다음 resume에서 회복돼요.
  • 통신 단절: 성공한 빈 응답과 조회 오류를 구별해요.
  • acknowledge 실패: 다시 시도해서 정해진 기한 안에 끝내요.
  • 환불과 revoke: 캐시가 false가 되고 광고가 다시 허용돼요.
  • 빠른 앱 전환: background와 foreground를 반복해도 과거 조회가 덮어쓰지 않아요.

자동 테스트가 다루는 범위는 좁아요. iOS에서는 보유하지 않은 상태의 복원이 false를 저장하는 것과 가격 갱신을 다뤄요. hosted unit test로는 실제 구매가 성공하는 UI 경로를 다루지 못해요. PlayBillingProvider의 구매와 복원, pending 안내, acknowledgement 실패, Settings의 행, 광고 gate의 진리표 전체는 Android 쪽 자동 테스트에 없어요. 로컬 테스트가 통과했다는 것만으로 실제 스토어 결제를 확인했다고 말할 수 없어요.

남은 일은 Android의 서버 검증, pending 상태의 화면, 오류 모델, 동시 reconcile, 복원 입구, 실기기에서의 스토어 시나리오 확인이에요. acknowledge의 3일과 3분 시한, offer token의 조건, debug 서명 sideload의 자격은 기준일 당시의 문서 설명이므로, 정책이나 라이브러리가 바뀌면 공식 문서에서 다시 확인해야 해요.