J.
BLOG
글 목록소개구독
기술

일회성 광고 제거 IAP: StoreKit 2·Play Billing에서 구매와 권한 분리하기

2026.08.06·읽기 10분

광고 제거 상품은 화면상으로는 버튼 하나다. 결제가 성공하면 배너를 숨기고, 이미 산 사용자는 다시 결제하지 않게 하면 끝처럼 보인다. 실제 문제는 그 다음 실행부터 시작된다. 사용자가 앱을 재설치하거나 다른 기기에서 열 수 있고, 결제가 앱 밖에서 완료될 수 있으며, 환불이나 revoke로 권한이 사라질 수도 있다. 구매 직후 받은 콜백 하나로는 이 상태를 설명할 수 없다.

DailySudoku는 iOS에서 StoreKit 2 non-consumable, Android에서 소비하지 않는 one-time product로 영구 광고 제거를 구현한다. 이 글에서 말하는 현재 구현은 2026년 8월 6일 develop9c999305 소스 기준이며 스토어 배포 상태와는 구분한다. 두 플랫폼의 API 모양은 다르지만 핵심 계약은 같다. 구매 이벤트는 입력이고, 현재 entitlement 조회가 소유 상태를 다시 맞추는 기준이다.

구매 응답과 현재 권한은 같은 사실이 아니다

구매 버튼의 반환값만 저장하면 다음 상황을 놓친다.

  • 결제창은 열렸지만 사용자가 취소했다.
  • 결제가 pending 상태로 남았다가 앱을 닫은 뒤 완료됐다.
  • 같은 계정이 다른 기기에서 상품을 샀다.
  • 앱 데이터가 삭제되거나 새 기기에 설치됐다.
  • 구매가 환불·취소·revoke됐다.
  • 결제는 끝났지만 앱이 결과를 받기 전에 네트워크가 끊겼다.

그래서 입력 경로를 둘로 나눠야 한다.

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

구매 이벤트는 사용자에게 즉시 결과를 보여 주는 빠른 경로다. 앱 시작과 foreground 복귀 때의 조회는 놓친 이벤트와 다른 기기의 변경을 회복하는 경로다. 스토어가 보내는 update는 앱이 살아 있는 동안 발생한 외부 변경을 줄이는 경로다. 셋이 마지막에 같은 adFree 상태로 합쳐져야 한다.

로컬 Boolean은 소유권의 원본이 아니다. 스토어 권한을 매 화면에서 직접 기다리지 않도록 만든 렌더링 캐시다. 캐시를 빠르게 읽되, 스토어 snapshot과 update가 true뿐 아니라 false도 기록해야 환불 뒤 광고가 다시 활성화된다.

adFreeentitlementsSynced를 함께 둔다

앱을 막 시작한 순간의 adFree == false에는 두 뜻이 섞여 있다.

  1. 실제로 상품을 보유하지 않았다.
  2. 아직 스토어를 조회하지 못했다.

이 둘을 구분하지 않으면 기존 구매자에게 콜드 스타트 동안 광고가 잠깐 나타날 수 있다. DailySudoku는 별도의 entitlementsSynced를 두고 광고 조건을 다음처럼 접는다.

adsEnabled = entitlementsSynced && !adFree && consentAllowsAds

앱 시작 시에는 entitlementsSynced == false라 광고 요청 자체가 닫힌다. 복원이 한 번 끝난 뒤에만 비구매자가 광고 경로로 들어간다. 구매자라면 adFree == true가 계속 게이트를 닫는다. 설정 화면, 홈, 난이도 선택, 게임, 통계가 각자 결제 상태를 해석하지 않고 이 결과만 소비한다.

이 구조에서 중요한 것은 배너를 시각적으로 가리는 데서 멈추지 않는 것이다. Android는 게이트가 닫히면 Compose 광고 subtree를 만들지 않고 이미 붙은 native view도 해제한다. iOS는 배너를 접고 추가 load를 막지만, 현재 개발 소스에서는 이미 로드된 provider를 함께 파기하는지까지 자동 검증돼 있지 않다. “안 보인다”와 “광고 SDK 작업이 멈췄다”는 별도 확인 항목이다.

StoreKit 2는 verified transaction만 권한으로 바꾼다

iOS 구매 경로는 Product.purchase() 결과를 세 갈래로 나눈다.

  • .success(.verified(transaction)): 광고 제거를 부여하고 transaction을 finish한다.
  • .success(.unverified(...)): 권한을 부여하지 않는다.
  • .pending 또는 .userCancelled: 권한을 부여하지 않는다.

결제창을 닫았다는 사실이 아니라 StoreKit 검증을 통과한 transaction이 기준이다. 앱이 실행 중일 때는 Transaction.updates를 계속 관찰해 Ask to Buy, 다른 기기의 구매, revoke 같은 변화를 받는다. 앱 시작과 foreground 복귀 때는 Transaction.currentEntitlements에서 광고 제거 product ID의 verified transaction을 다시 찾는다.

Apple 문서상 currentEntitlements는 non-consumable의 최신 entitlement를 제공하며 환불되거나 revoke된 상품은 포함하지 않는다. 따라서 현재 목록에서 상품을 찾았을 때 true만 저장하는 것으로는 부족하다. 조회를 끝냈는데 일치하는 항목이 없다면 false도 기록해야 닫혀 있던 광고 경로가 정상 상태로 돌아온다.

복원 버튼은 일반적인 시작 경로와 다르게 취급한다. StoreKit은 재설치나 새 기기에서도 최신 transaction 정보를 자동으로 제공하므로 평소에는 currentEntitlements를 읽으면 된다. AppStore.sync()는 인증 prompt를 띄울 수 있어 앱 시작 때 자동 호출하면 안 된다. DailySudoku도 자동 복원에서는 snapshot만 읽고, 사용자가 설정에서 명시적으로 복원을 눌렀을 때만 AppStore.sync() 후 다시 조회한다.

가격 조회도 entitlement 복원과 분리한다. 현지화 가격이 늦게 도착했다는 이유로 광고 권한 확인과 동의 흐름 전체가 기다릴 필요는 없다. 현재 개발 구현은 가격을 별도로 새로 고치고, 소유권 조회는 그 결과를 기다리지 않는다.

Play Billing은 PURCHASED·검증·acknowledge를 구분한다

Android의 광고 제거 상품은 INAPP one-time product다. 별도의 “non-consumable API”를 쓰는 것이 아니라 구매를 consume하지 않아서 한 번 산 상품으로 유지하고, 처리 완료는 acknowledge한다.

구매 흐름은 다음 순서다.

  1. queryProductDetailsAsync로 현재 사용자에게 판매 가능한 상품과 현지화 가격을 얻는다.
  2. 같은 ProductDetails와 offer token으로 launchBillingFlow를 연다.
  3. PurchasesUpdatedListener 또는 이후 queryPurchasesAsync에서 구매를 찾는다.
  4. product ID와 상태를 확인하고, PURCHASED일 때만 권한을 부여한다.
  5. 비소모성 상품을 acknowledge한다.

PENDING은 결제 실패가 아니다. 현금 결제나 추가 승인처럼 아직 결제가 끝나지 않은 상태다. Google은 PENDING일 때 benefit을 주지 말고 PURCHASED로 바뀐 뒤 처리하라고 안내한다. 앱이 꺼져 있을 때 전환될 수 있으므로 실행 중 listener만 믿지 말고 onResume()에서도 queryPurchasesAsync()로 회복해야 한다.

acknowledgement는 구매 검증과도 다르다. 검증은 이 구매가 정당하고 아직 처리되지 않았는지 확인하는 단계이고, acknowledge는 entitlement를 전달했다는 사실을 Google Play에 알리는 단계다. 일반 구매는 PURCHASED가 된 뒤 3일 안에 acknowledge하지 않으면 자동 환불될 수 있다. 라이선스 테스터 구매에서는 이 시간이 3분으로 줄어 테스트 실패가 빨리 드러난다.

여기에는 현재 구현의 명확한 경계가 있다. DailySudoku Android 코드는 BillingClient가 돌려준 PURCHASED 상태와 product ID를 확인하고 client에서 acknowledge하지만, purchase token을 서버로 보내 Google Play Developer API로 검증하지 않는다. Google의 현재 보안 지침은 benefit 부여 전 secure backend 검증과 서버 acknowledgement를 권장한다. 따라서 지금 구조는 변조 저항이 필요한 권한 시스템의 완성형이 아니라, client trust를 받아들인 광고 제거 구현이다. 이를 서버 검증이 끝난 것처럼 표현해서는 안 된다.

복원은 true만 찾는 함수가 아니라 reconcile이다

두 플랫폼의 복원 함수는 “예전에 샀는가?”에만 답하면 안 된다. 현재 snapshot을 로컬 캐시에 맞추는 reconcile이어야 한다.

상황 iOS Android
구매 직후 verified transaction이면 true 검증된 PURCHASED라면 true가 이상적이나, 현재 코드는 client 상태만 확인
결제 대기 .pending, 권한 미부여 PENDING, 권한 미부여
앱 시작·복귀 currentEntitlements 재조회 queryPurchasesAsync(INAPP) 재조회
실행 중 외부 변경 Transaction.updates listener, 놓치면 다음 resume 조회
명시적 복원 사용자 동작에서 AppStore.sync() 후 재조회 같은 inventory 재조회
환불·revoke update 또는 다음 snapshot에서 false 다음 성공한 inventory 조회에서 false

실패할 때 false를 쓰는 규칙은 더 조심해야 한다. “상품이 없음”과 “스토어에 연결하지 못함”은 같은 결과가 아니다.

현재 Android 구현은 연결 또는 inventory 조회가 실패하면 기존 adFree 캐시를 유지하고 entitlementsSynced만 true로 바꾼다. 일시 장애 때문에 구매자의 권한을 지우지 않는 선택이다. 대신 재설치 직후처럼 캐시가 false인 구매자는 Play가 복구될 때까지 광고를 볼 수 있고, 환불된 사용자는 다음 성공한 조회까지 광고가 계속 숨겨질 수 있다.

현재 iOS 구현은 verified matching entitlement가 없으면 false를 기록한다. 성공한 빈 snapshot에서는 올바른 동작이지만, unverified 결과나 일시적인 false negative를 별도 상태로 구분하지 않는다. 어느 쪽도 “항상 안전하다”고 말할 수 없다. unknown, owned, notOwned처럼 조회 결과를 명시하거나, 최소한 오류와 성공한 빈 결과를 구분해 제품 정책에 맞는 fail-open/fail-closed 결정을 해야 한다.

동시에 시작된 복원과 foreground 조회의 완료 순서도 테스트 대상이다. connection만 잠그고 전체 reconcile을 직렬화하지 않으면 오래된 snapshot이 나중에 도착해 새 상태를 덮을 수 있다. 단순 Boolean 캐시는 작지만, 그 값을 쓰는 비동기 경로는 구매·update·시작·resume·수동 복원으로 여러 개다.

가격이 없다고 복원 경로까지 숨기지 않는다

스토어의 현지화 가격을 사용하고 하드코딩 가격을 표시하지 않는 것은 맞다. 그러나 가격 조회 실패를 “이 사용자에게 IAP 기능이 없다”로 해석하면 문제가 생긴다.

현재 두 앱의 설정 화면은 가격이 nil이면 광고 제거 행을 숨기고, 복원 동작은 그 행이 여는 화면 안에 있다. 자동 시작·resume 복원은 계속 작동하지만 사용자가 직접 복원을 다시 시도할 진입점은 사라진다. 제품 미등록처럼 지속적인 구성 오류와 네트워크 같은 일시 오류도 UI에서는 똑같이 보인다.

가격 표시와 복원은 서로 다른 자격 조건이다. 판매할 가격을 얻지 못했으면 구매 버튼을 잠시 비활성화할 수 있지만, 이미 구매한 사용자의 복원 동작은 독립적으로 남기는 편이 회복 가능하다. 재시도 상태와 오류 관측도 제품 조회의 빈 결과와 예외를 구분해야 한다.

테스트는 성공 결제 한 번으로 끝나지 않는다

iOS는 Xcode의 StoreKit configuration으로 transaction, interrupted purchase, refund·revoke 같은 로컬 시나리오를 빠르게 만들고, Sandbox나 TestFlight에서 실제 App Store 계정 흐름을 확인할 수 있다. Android는 license tester가 실제 구매 흐름과 테스트 결제 수단을 사용할 수 있다. 현재 Google 문서상 package name과 tester 계정 조건을 만족하면 debug 서명 앱을 sideload해 개발 테스트도 할 수 있으며, 테스트 트랙 설치본은 배포 전 검증에 사용한다. “반드시 직접 ADB 설치라서 실패한다”거나 “항상 Play 설치본이어야 한다”처럼 단정하면 현재 문서와 맞지 않는다.

최소 검증 행렬은 다음과 같다.

시나리오 확인할 결과
신규 구매 성공 광고 요청 중단, 재실행 뒤에도 유지, 중복 구매 불가
사용자 취소 권한·성공 안내 없음, 다시 구매 가능
pending 후 승인 pending 동안 광고 제거 없음, PURCHASED 뒤 한 번만 부여
pending 후 취소 권한 부여 없음
이미 보유 새 기기·재설치·수동 복원에서 복구
앱 밖 구매 완료 update 또는 다음 resume에서 복구
네트워크 단절 성공한 빈 목록과 오류를 혼동하지 않음
acknowledge 실패 재시도되고 제한 시간 안에 완료
환불·revoke 로컬 캐시가 false로 돌아가고 광고 gate가 다시 열림
빠른 background/foreground 오래된 조회가 새 상태를 덮지 않음

현재 자동 검증도 이 행렬 전체를 덮지는 않는다. iOS 테스트는 미소유 복원이 false를 저장하는 경로와 가격 새로고침을 확인하지만 hosted unit test에서 실제 구매 성공 UI 경로는 검증하지 못한다. Android에는 PlayBillingProvider 자체의 구매·복원 테스트가 없고, pending 안내, acknowledgement 실패, Settings 행, 광고 게이트의 전체 truth table도 비어 있다. 따라서 로컬 테스트 통과만으로 스토어 결제를 검증했다고 결론 내릴 수 없다.

정리

일회성 광고 제거는 결제 버튼보다 entitlement 수명 주기를 구현하는 일에 가깝다. 구매 콜백은 즉시 반응을 위한 한 입력일 뿐이고, 앱 시작·복귀 snapshot과 실행 중 update가 같은 로컬 캐시로 수렴해야 한다. entitlementsSynced는 “아직 모름”과 “미보유”를 구분해 기존 구매자에게 광고가 번쩍이는 일을 막는다.

StoreKit 2에서는 verified transaction, currentEntitlements, Transaction.updates, 사용자 동작에 한정한 AppStore.sync()가 이 계약을 만든다. Play Billing에서는 PURCHASED 확인, queryPurchasesAsync, 검증, acknowledgement가 각각 다른 책임을 가진다. 환불과 오류에서 false를 언제 기록할지, 가격 조회가 막혔을 때 복원 진입점을 남길지, 겹친 reconcile의 순서를 어떻게 보장할지는 별도의 제품 결정이다.

DailySudoku의 현재 개발 구현은 구매와 광고 렌더링을 분리하고 시작·resume 복원을 수행하지만, Android 서버 검증, pending UX, 오류 상태 모델, 동시 reconcile, 복원 진입점과 실기기 스토어 행렬에는 검증 공백이 남아 있다. 결제가 한 번 성공했다는 사실보다 이 공백을 명시하고 재현 가능한 시나리오로 닫는 일이 영구 권한에 더 중요하다.

출처

광고Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

#iOS#Android#StoreKit#Google Play Billing#In-App Purchase
이전 글검증 스크립트가 성공을 거짓말할 때: Negative Control과 Mutation으로 자기검증하기다음 글위치 권한 없이 광고를 지역화하기: GeoIP와 Provider Chain 설계
© 2026 진재명 · blog.jaemyeong.com
iOS 소프트웨어 엔지니어 · 부산