본문 바로가기
프로젝트/DIP 스위치 계산기

Play 결제 광고 제거를 다 만들고, 메뉴에서는 입구만 숨겼다

by iwbap 2026. 10. 22.
728x90
반응형

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

어두운 배경에 Play 결제 광고 제거, 메뉴 입구만 숨겼다라는 제목과 서명 영수증, 권한 판정, 광고 스위치 하나라는 설명이 있고, 오른쪽 메뉴 그림에는 미니게임과 다른 앱들 항목 아래 광고 제거 항목이 점선과 빨간 줄로 숨겨진 모습을 그린 대표 이미지
결제 코드는 그대로 두고, 메뉴에서 들어가는 길만 닫았다

게임센터 확장과 결제가 한 커밋에 들어갔다

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 조회가 끝날 때까지는 광고를 띄우지 않는다. 조회가 실패해도 확인 중 상태는 풀리니, 구매하지 않은 사용자에게는 결국 광고가 뜬다. 대신 광고가 조금 늦게 뜨는 쪽을 택했다.

앱을 시작하면 저장된 영수증을 다시 검증하고, 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 초기화도 광고가 켜진 상태에서만 한다. 종료 화면 광고는 요청마다 번호를 붙였다. 늦게 도착한 광고의 번호가 다르거나 그 사이 구매가 확인됐으면 바로 해제한다.

구매하지 않았고 확인이 끝났을 때만 참이 되는 구매 상태 값 하나가 LocalAdsEnabled로 전달되어 상단 배너, 하단 배너, 게임 목록 중간 광고, 메뉴 네이티브 광고, 종료 화면 광고가 모두 같은 값을 보고, 꺼지면 요청과 빈 자리를 만들지 않고 늦게 온 광고를 해제하는 구조 도식

메뉴에서 입구만 빠졌지만 결제 코드는 남았다

문서에는 메뉴에서 구매·복원한다고 적혀 있다. 하지만 커밋의 메뉴에는 미니게임과 다른 앱들만 있다.

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

728x90
반응형