결제 테스트만 빼고 다 해봤다
Google Play 권고대로 최적화를 켰다. 로그인도 녹음도 채점도 확인했고, 결제만 안 눌러봤다. 열흘 뒤 매출을 보고 알았다.
내가 운영하는 오픽 말하기 연습 앱은 결제의 대부분이 스토어 인앱결제에서 나온다. 전체 기간 결제 54건 중 40건, 74%가 앱이다. 웹에도 결제 수단이 있지만 주력은 앱이다.
9월 1일부터 10일까지 안드로이드 결제가 0건이었다. 나는 열흘째 되는 날 밤에 알았다.
켜라고 해서 켰다
Play Console 이 DEX 코드 최적화를 요구했다. 권고가 아니라 요구사항 형태로 올라와 있었고, 기한이 붙어 있었다.
8월 31일에 minifyEnabled 를 켰다. 9월 1일에 shrinkResources 를 켜고, 최적화 옵션을 조정하고, 라이트모드 릴리스까지 세 번 연속으로 올렸다. 9월 2일에 프로덕션 심사를 통과했다.
빌드는 전부 성공했다. 경고도 없었다.
결제 테스트만 빼고 다 해봤다
승인된 뒤에 앱을 켜서 확인했다. 로그인, 녹음, 채점, 히스토리. 다 됐다.
결제는 안 눌러봤다. 내부 테스트 트랙에 먼저 올릴 수도 있었는데 그것도 안 하고 프로덕션으로 바로 갔다.
왜 그랬는지는 분명하다. 이 앱의 코드는 대부분 AI 에이전트로 작성하고 수정한다. 그동안 문제가 없었다. 그래서 이번에도 없을 거라고 생각했다. 빌드 설정을 바꾼 릴리스인데도 그렇게 생각했다.
에이전트가 틀린 게 아니다. minifyEnabled 와 shrinkResources 는 Google 이 켜라고 한 옵션이고, 켜는 방법도 문서에 있는 그대로다. 틀린 건 그 다음이다. 코드를 누가 썼든 매출 경로를 한 번 눌러보고 올리는 건 운영하는 사람의 일이고, 나는 그 일을 안 했다.
열흘 동안 아무 신호도 없었다
두 가지가 겹쳤다.
첫째, 실패가 클라이언트에서 끝났다. 결제 실패는 서버를 부르기 전에 끝난다. 스토어 초기화가 안 되면 상품을 못 불러오고, 상품이 없으면 구매 요청 자체가 나가지 않는다. 서버에는 요청이 아예 도착하지 않는다. Vercel 로그에도 Sentry 에도 남을 것이 없었다.
둘째, 에러 핸들러가 진짜 에러를 사용자 취소로 분류했다. 플러그인 에러 코드 세 개를 전부 "취소 계열"로 묶어두고 조용히 무시하고 있었다.
6777001 ERR_SETUP 스토어 초기화 실패 → 취소로 처리됨
6777002 ERR_LOAD 상품 로드 실패 → 취소로 처리됨
6777006 ERR_CANCELLED 진짜 사용자 취소진짜 메시지인 Init failed - Class not found 는 이 핸들러 뒤에서 열흘을 버텼다. 화면에는 alert() 하나가 떴다 사라졌다.
웹 결제는 정말로 늘고 있었다
두 달 전에 시작한 SEO 가 효과를 보기 시작한 시점이었다. 데스크톱 유입이 늘었고 웹 결제가 늘었다. 8월 3건에서 9월 9건. 세 배다.
그래서 대시보드가 이상해 보이지 않았다. 결제 채널이 웹으로 옮겨간 것처럼 읽혔다.
웹 증가도 진짜였고 앱 소멸도 진짜였다. 둘이 같은 기간에 겹쳐서 합계가 멀쩡해 보였을 뿐이다. 하나가 오른 이유와 하나가 사라진 이유는 아무 관계도 없었다.
결국 같은 데이터가 문제를 드러냈다. 9월 10일 밤, 웹 결제 비중을 들여다보다가 질문이 하나 남았다. 꾸준히 있던 안드로이드 결제는 왜 한 건도 없나.
폰을 꺼내서 직접 눌러봤다. "처리 중…" 에서 멈췄다. 나갔다 다시 들어가니 상품 정보를 불러올 수 없다는 문구가 떴는데, App Store Connect 를 확인하라고 적혀 있었다. 안드로이드에서 iOS 문구가 나오고 있었다.
세 번 틀리고 네 번째에 맞았다
R8 은 무엇을 지웠는지 기록을 남긴다. mapping/release/usage.txt 다.
첫 번째 시도. 열어보니 결제 콜백이 지워져 있었다. 상품 정보 응답 콜백, 상수 클래스 통째, BillingClient 빌더 메서드 몇 개. Cordova 플러그인은 문자열로 클래스를 찾고 인터페이스 콜백으로만 불리기 때문에 R8 의 참조 그래프에 안 잡힌다. keep 규칙을 넣었다. 여전히 실패했다.
두 번째 시도. 더 넓게 보니 플러그인만이 아니었다. org.apache.cordova 전체가 잘려 있었고, JS 와 네이티브를 잇는 브리지 클래스가 통째로 없었다. 플러그인만 지키고 프레임워크는 안 지킨 것이다. 프레임워크까지 keep 에 넣으니 제거 항목이 98건에서 9건으로 줄었다. 여전히 실패했다.
이 시점에 실패 지점마다 계측을 붙여 배포했고, 처음으로 값이 나왔다.
[IAP] store_init_timeout
[IAP] product_not_found registered: 0 ← 3ms 뒤3밀리초. 그리고 logcat 에 BillingClient 로그가 단 한 줄도 없었다. 네이티브 호출이 나가지도 않았다는 뜻이다.
세 번째 시도. 추측을 멈추고 앱 안을 직접 열었다. 릴리스 빌드에 웹뷰 디버깅을 켜고 DevTools 프로토콜로 붙어서, 앱 코드와 같은 경로로 초기화를 직접 실행했다.
code: 6777001
message: "Init failed - Class not found"
platform: android-playstore드디어 진짜 에러가 나왔다. 그런데 apkanalyzer 로 APK 를 열어보니 클래스는 전부 있었다.
cc.fovea.PurchasePlugin 있음
org.apache.cordova.PluginManager 있음
org.apache.cordova.ExposedJsApi 있음
res/xml/config.xml 없음클래스가 아니라 등록부가 없었다.
원인은 두 개였다
config.xml 은 Cordova 플러그인 등록부다. 어떤 서비스 이름이 어떤 클래스에 대응하는지가 여기 적혀 있다. Capacitor 는 이 파일을 리소스 ID 로 리플렉션 조회하는데, 리소스 축소기는 그 조회를 못 본다. 참조가 없다고 판단해서 지웠다.
노드에 커서를 올리면 연결된 것만 남는다
| 원인 | 켠 시점 | 무엇이 사라졌나 |
|---|---|---|
| 리소스 축소 | 9/1 | 플러그인 등록부 res/xml/config.xml |
| 코드 축소 | 8/31 | 결제 콜백, Cordova 브리지 클래스 |
둘 다 증상이 똑같다. "처리 중…" 에서 멈춘다. 그래서 하나를 정확히 고쳐도 화면은 그대로였고, 나는 맞는 수정을 두 번 하고도 틀렸다고 생각했다.
첫 수정이 논리적으로 맞는데 증상이 그대로면, 가설을 버리기 전에 원인이 두 개일 가능성을 먼저 의심해야 한다.
고친 것
리소스 축소기에게 등록부를 남기라고 알려주는 파일 한 줄.
<!-- android/app/src/main/res/raw/keep.xml -->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@xml/config" />코드 축소는 네 줄.
-keep class cc.fovea.** { *; }
-keep class com.android.billingclient.** { *; }
-keep class org.apache.cordova.** { *; }
-keep class com.getcapacitor.cordova.** { *; }에러 분류는 목록을 하나로 줄였다.
// before: [6777001, 6777002, 6777006] ← SETUP, LOAD 까지 취소로 묶여 있었다
const userCancelCodes = [6777006];그리고 실패 지점 아홉 곳에 이벤트와 Sentry 메시지를 붙였다. 플러그인 부재, 초기화 타임아웃, 상품 없음, 검증 실패. 다음에 이 경로가 죽으면 매출이 아니라 알림으로 먼저 알게 된다.
같은 폰에서 다시 눌러보니 1.1초 만에 초기화가 끝나고 상품 네 개가 올라왔다. 9월 10일 23시 41분에 프로덕션으로 승격했다.
확인하는 법
같은 사고를 겪는 사람을 위해 검증 명령을 남긴다. 리소스 쪽이 특히 함정인데, APK 안에서 경로가 res/ZW.xml 처럼 축약되기 때문에 파일 이름으로 찾으면 못 찾는다.
# R8 이 지운 것 (빌드 후)
grep -c "cc.fovea" android/app/build/outputs/mapping/release/usage.txt
# 리소스가 살아있는지 — 이름이 아니라 매핑을 봐야 한다
grep "xml:config" android/app/build/outputs/mapping/release/resources.txt
# 실기기에서 네이티브까지 갔는지
adb logcat | grep "D/CdvPurchase"남은 것
손실 자체는 작다. 8월 앱 결제 11건 69,300원이 9월 0건이 된 것이다. 문제는 금액이 아니라, 매출 경로 하나가 100% 죽어 있는데 열흘 동안 아무 장치도 그 사실을 말해주지 않았다는 것이다.
결제 시도 후 포기한 사람 수는 영영 모른다. 실패가 서버에 도달하지 않았으니 세어볼 데이터가 없다. 구매 버튼을 누르고 "처리 중…" 을 보다가 앱을 지운 사람이 몇 명인지는 관측 불가다.
AI 에게 코드를 맡기는 것과 검증을 맡기는 것은 다른 일이다. 이게 제일 크게 배운 것이다. 에이전트가 오래 잘해오면 확인 절차가 조용히 헐거워진다. 나는 릴리스마다 하던 확인을 어느 순간부터 "지금까지 괜찮았으니까" 로 대체하고 있었고, 그 대체가 처음 실패한 날이 하필 빌드 설정을 바꾼 날이었다. 코드를 누가 쓰든 무엇을 확인하고 올릴지는 넘길 수 없는 몫이다.
조급함이 절차를 지웠다. 스토어의 기한 압박이 있었다. 이유는 되지만 변명은 안 된다. 기한이 있는 릴리스일수록 내부 테스트 트랙을 거쳐야 하는데 그때 판단은 반대로 갔다.
웹앱을 네이티브로 감싼 구조는 한 겹 더 봐야 한다. Capacitor 자체 플러그인은 라이브러리가 keep 규칙을 같이 배포해서 보호된다. Cordova 호환 플러그인은 그렇지 않다. 평소에는 웹만 배포하니 이 층이 있다는 사실조차 잊고 지내다가, 네이티브 빌드를 건드리는 날 한꺼번에 청구된다.
아직 하루가 안 지났다. 실기기에서 초기화와 상품 조회까지는 확인했다. 실제 사용자의 결제가 다시 들어오는 것은 아직 못 봤다. 고쳐졌다고 말하려면 며칠은 더 봐야 한다.
끝