개인 앱인 DIP 계산기에 Google Play 결제 광고 제거를 구현했다. 국내 5,000원 일회성 상품이지만 실제 구매·복원은 미검증이다. 메뉴 입구만 숨기고 결제 코드를 남긴 과정을 정리했다.

게임센터 확장과 결제가 한 커밋에 들어갔다
10월 21일 21시 53분의 커밋 하나로 앱 버전을 1.6.0에서 1.10.2로 올렸다. 238개 파일에 28,842줄을 더하고 2,086줄을 지웠다. 대부분은 메뉴 149개와 근거리 게임방을 추가한 작업이다.
여기서는 Google Play Billing 9.1.0 구현만 다룬다. 설정 문서에 적힌 상태는 다음과 같다.
- 상품: 대한민국 한정 5,000원, 만료 없는 일회성 구매
- 다중 수량 구매와 소비 처리 없음
- 1.10.0(versionCode 21) AAB를 내부 테스트 트랙에 게시, 테스터 없음
- 프로덕션 미출시, 실제 구매·복원 미검증
'premium=true' 대신 서명된 영수증을 저장했다
구매 여부를 단순 설정값으로 저장하면 수정될 수 있다. Play가 준 구매 원문과 서명을 저장하고, 시작할 때 다시 검증했다.
/** Store the signed Play receipt, never an editable 'premium=true' preference. */
fun verify(purchase: Purchase): Boolean = runCatching {
if (publicKey == null || purchase.packageName != packageName ||
AdRemovalProduct.ID !in purchase.products ||
purchase.purchaseState != Purchase.PurchaseState.PURCHASED ||
purchase.purchaseToken.isBlank() || purchase.isSuspended
) return@runCatching false
Signature.getInstance("SHA1withRSA").run {
initVerify(publicKey)
update(purchase.originalJson.toByteArray(Charsets.UTF_8))
verify(Base64.decode(purchase.signature, Base64.DEFAULT))
}
}.getOrDefault(false)
결제 대기 중에는 권한을 주지 않고 패키지·상품·구매 상태·서명을 확인한다. 공개 키가 없거나 유효하지 않으면 구매 진입을 차단한다.
다만 서버 구매 검증은 없다. 공식 문서는 백엔드 검증을 권장하지만, 이 앱은 기기에서 RSA 서명만 확인한다. 실시간 환불 알림도 없다.
광고를 끄는 동안 구매 조회를 먼저 기다렸다
재설치한 구매자에게는 저장된 영수증이 없을 수 있다. 광고가 잠깐 보이지 않도록 공개 키가 설정되면 처음엔 구매 조회 중 상태로 둔다.
// Check Play first on a fresh install so a returning buyer sees no flash of ads.
val adsEnabled: Boolean
get() = !owned && !checkingPurchases
그래서 Play 조회가 끝날 때까지는 광고를 띄우지 않는다. 조회가 실패해도 확인 중 상태는 풀리니, 구매하지 않은 사용자에게는 결국 광고가 뜬다. 대신 광고가 조금 늦게 뜨는 쪽을 택했다.

구매 권한을 지우는 조건은 엄격하게 나눴다. 코드에서 구분한 경우는 다음과 같다.
- 전체 구매 조회 성공 후 검증된 구매가 없으면 저장된 영수증을 지운다.
- 조회 실패나 시간 초과에는 기존 권한을 유지한다.
- 구매 콜백보다 오래된 조회 결과는
purchaseRevision으로 무시한다.
검증에 실패한 구매가 있으면 권한을 바로 지우지 않는다. 영수증은 백업·기기 이전에서 빼고, 같은 Play 계정으로 다시 조회한다.
구매 승인은 즉시 한 번 시도하고, 실패하면 5초·30초·2분·10분·30분·1시간 뒤 재시도한다. 총 7회, 회당 제한 시간은 20초이며 앱 재시작 후에도 구매 조회를 통해 이어 간다. 3일 안에 승인하지 않으면 자동 환불과 권한 회수가 발생할 수 있다.
모든 광고 자리가 같은 스위치를 본다
배너와 게임 목록, 메뉴, 종료 화면이 따로 판단하면 누락되기 쉽다. Compose의 CompositionLocal로 광고 허용 상태를 공유했다.
internal val LocalAdsEnabled = staticCompositionLocalOf { true }
@Composable
fun AdMobAdaptiveBanner(adUnitId: String, modifier: Modifier = Modifier) {
if (!LocalAdsEnabled.current) return
// ...
}
꺼져 있으면 광고 요청도, 빈 자리도 만들지 않는다. 광고 SDK 초기화도 광고가 켜진 상태에서만 한다. 종료 화면 광고는 요청마다 번호를 붙였다. 늦게 도착한 광고의 번호가 다르거나 그 사이 구매가 확인됐으면 바로 해제한다.

메뉴에서 입구만 빠졌지만 결제 코드는 남았다
문서에는 메뉴에서 구매·복원한다고 적혀 있다. 하지만 커밋의 메뉴에는 미니게임과 다른 앱들만 있다.
AppMenuDrawer(
onDismiss = onDismissMenu,
onOpenGames = onOpenGameCenter,
onOpenDeveloperApps = onOpenDeveloperPage,
)
도움말도 두 항목만 안내하지만 결제 화면 파일과 문구는 남았다. 권한 조회와 광고 차단도 살아 있어 검증된 구매라면 광고가 꺼지는 구조다.
숨긴 이유를 커밋에 따로 적지는 않았다. 기록만 보면 실제 결제는 한 번도 확인하지 않은 상태였다. 남은 문제도 있다. 설정 문서와 게임센터 문서, 스토어 업데이트 문구는 아직 메뉴가 있다고 말한다.

광고 제거 메뉴가 있던 중간 상태는 Git에 없다. 해당 메뉴 설명은 문서에만 남았다.
이번에 배운 것
- 구매 권한은 수정 가능한 플래그 대신 서명된 영수증으로 판단한다.
- 조회 실패만으로 저장된 권한을 삭제하지 않는다.
- 메뉴를 숨기면 설명 문서와 스토어 문구도 함께 확인한다.
다음에는 문서를 고치고 라이선스 테스터로 구매·복원을 검증할 계획이다.
자주 묻는 질문
Q. 서버 없이 구매를 검증할 수 있나?
A. 기기에서 RSA 서명을 확인하지만 서버 검증을 대신하진 못한다. 공식 문서는 백엔드 검증을 권장한다.
Q. 구매 승인을 놓치면 어떻게 되나?
A. 3일 내 미승인 시 자동 환불·권한 회수된다. 즉시 시도 뒤 여섯 번 재시도하도록 구현했다.
관련 글: R8 릴리스 시작 크래시에 WorkManager keep 규칙 두 개를 더했다
참고: Android Developers: Integrate the Google Play Billing Library into your app
'프로젝트 > DIP 스위치 계산기' 카테고리의 다른 글
| 오목 대각선 테스트가 NPE로 깨졌다, 범인은 규칙이 아니라 테스트였다 (0) | 2026.10.21 |
|---|---|
| R8 릴리스 시작 크래시에 WorkManager keep 규칙 두 개를 더했다 (0) | 2026.10.21 |
| 블루투스 대국 Android 12 권한 방어와 메시지 순서 수정까지 10분 (0) | 2026.10.20 |
| JDK 25 경고에 Gradle을 21로 고정했다가 3분 만에 AGP 9로 올렸다 (0) | 2026.10.20 |