거절 메일의 제목은 "조치 필요: 앱이 Google Play 정책을 준수하지 않음"이었고, 본문에는 "앱이 응답하지 않음"과 스크린샷 파일 하나. 열어 보니 런치 스플래시, 아이콘만 덩그러니. Flutter의 첫 프레임이 끝내 그려지지 않은 것이다.
어디서 세부를 보는가
콘솔의 정책 센터에는 "손상된 기능 정책 위반 — 거부됨"과 날짜만 있다. 어떤 화면에서 어떻게 멈췄는지는 메일 첨부에만 있다. API로 상태를 물으면 재제출 뒤라 IN_REVIEW만 돌아온다. 거절은 메일을 찾아 읽어야 한다.
원인
앱 99개가 공유하는 main()이 이렇게 생겼었다.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initAds(); // MobileAds.initialize + 게이트
await billing.init(); // Play Billing 연결
runApp(const App());
}
에뮬레이터에서는 온라인이든 오프라인이든 1초 안에 뜬다. 문제는 심사 기기다. WebView가 비활성이거나, Play 계정이 없거나, GMS 광고 모듈이 없는 환경에서는 MobileAds.initialize나 Billing 연결이 플랫폼 스레드를 잡고 놓지 않거나 예외를 던진다. 둘 다 runApp 앞에 있으니 첫 프레임이 영원히 오지 않는다.
우리 쪽에서 재현은 안 됐다. 재현이 안 돼도 구조가 위험하다는 건 분명했다.
수정
공용 패키지에서 고쳤다. 앱 쪽 main()은 그대로 둬도 된다. await가 즉시 끝나기 때문이다.
initAds()는 즉시 반환한다. 게이트와MobileAds.initialize는addPostFrameCallback뒤에서 돌고, 끝나면adsReady를 완료한다.- 배너·전면·리워드·앱오픈 광고의
load()는adsReady를 기다린 뒤 유닛 ID를 읽는다. 게이트 전에 읽으면 실배포에서도 테스트 유닛이 나간다. Billing.init()도 첫 프레임 뒤에 연결한다.isAvailable에 10초 상한. 상품 조회와 복원은 백그라운드로 돌고, 타일은productsLoaded노티파이어를 구독해 채워진다.
규칙은 한 줄이다. 첫 프레임 전에 네트워크나 플랫폼 SDK를 기다리지 않는다. 새 앱 템플릿에도 같은 규칙이 들어갔다.
검증에서 배운 것
스플래시가 끝났는지 화면의 흰색 비율로 판정하려다 실패했다. 크림색 배경 UI가 0.91로 나와서 스플래시로 보인다. 대신 print('PROBE …')와 addPostFrameCallback으로 첫 프레임 시점을 직접 찍었다.
안전 빌드로 에뮬레이터 온라인은 첫 프레임 뒤 배너, 오프라인은 5초 안에 UI. 그날 밤 33개 앱을 다시 빌드해 재제출했다.