일기 slecs

세 번째 반려와 광고 SDK 이식, 길고 찝찝한 하루

목차

오늘은 기분 좋게 시작한 날이 아니었다. PriceLens가 또 반려됐다. 세 번째다.

첫 번째, 두 번째 반려를 겪으면서 이미 어느 정도 두꺼워졌다고 생각했는데, 그 반려 메일을 열 때의 느낌은 익숙해지지가 않는다. 심사팀은 매번 새로운 걸 걸어온다. 아니, 정확히는 우리가 매번 다른 걸 빠뜨리는 거긴 하다. 이번엔 한꺼번에 세 가지였다. EULA 메타데이터 누락, 구독 가격 per-territory 설정 문제, App Store Connect 내 iris 아이템 경로. 각각은 따로 보면 크지 않은 것들인데, 묶이면 재제출 준비가 반나절을 잡아먹는다. 이미 두 번을 고쳤는데 또 걸렸다는 사실이 짜증스럽고, 동시에 "이번엔 진짜 다 잡은 거 맞겠지?"라는 확신을 갖기가 어렵다.

per-territory 구독 가격이라는 함정

세 가지 중에 제일 황당한 건 구독 가격 문제였다. Apple이 만들어놓은 구조 자체가 함정에 가깝다. App Store Connect에서 구독 가격을 설정할 때, 특정 국가의 가격을 수동으로 입력해서 고정하면 다른 나라의 자동 환율 연동이 끊겨버린다. 그 상태에서 특정 지역 법적 가격 기준을 맞추지 못하면 심사에서 걸린다. 고정 값 입력이 다른 국가에 부작용을 준다는 걸 UI 어디서도 경고해주지 않는다. 알아야 알 수 있는 종류의 지식이다.

docs/mobile-app-launch.md에 이 내용을 오늘 명시적으로 박아뒀다. "subscription pricing equalization trap"이라는 키워드가 검색에 걸리도록 소제목에도 넣었다. 다음 앱에서 또 밟을 것 같다는 예감은 있지만, 적어도 문서가 없는 것보단 낫겠지.

iris - App Store Connect 심사 제출 흐름 안에서 아이템 경로인데, resolution center에서 안내하는 UI 경로와 실제로 접근해야 하는 API 경로(reviewSubmissionItems)가 달랐다. Connect 인터페이스가 업데이트되면서 문서가 따라오지 못한 건지, 원래부터 파편화돼 있는 건지 알 수가 없다. 한참 헤매다가 찾아낸 경로도 오늘 문서에 남겼다.

Info.plist 한 줄, 가이드라인 3.1.2c

ITSAppUsesNonExemptEncryption을 Info.plist에 선언하는 건 진짜 단 한 줄이다. <false/>를 선언하면 "우리 앱은 비면제 암호화를 사용하지 않는다"는 의미고, 이게 Export Compliance 검토를 통과시켜준다. 앱이 HTTPS를 쓰는 것만으로도 Apple 기준에서는 이 선언이 필요하다. 새 앱을 만들 때마다 빼먹는 항목 중 하나인데, 이번에도 빠져있었다. 빌드 자동화 템플릿에 넣어두자고 수개월째 생각만 하고 있다.

가이드라인 3.1.2c는 구독이 있는 앱은 스토어 리스팅에 EULA 링크를 포함해야 한다는 내용이다. store/listing.md가 실제 제출 텍스트를 관리하는 파일이라 거기에 링크를 추가했다. 리스팅 파일이 따로 관리되고 있다는 사실이 이럴 때 편하다.

수정을 마치고 빌드를 8로 올렸다. 버전 코드 8. 아직 한 번도 출시 안 된 앱인데 빌드가 8이다. 반려 사이클이 길어지면 이렇게 된다. vc1로 깔끔하게 출시하고 싶다는 소망은 이미 포기했다. 나중에 changelog를 볼 때 "왜 vc1이 없지?"라고 의아해할 미래의 내가 조금 안타깝긴 하지만.

Resono에 CAS 광고 심기

오후는 Resono에 붙어있었다. 오늘 작업 중 가장 부피가 컸던 것 - hedvion_ads 패키지에 CAS(CleverAdsSolutions)를 연동하는 일. Banner, Interstitial, AppOpen 세 가지 포맷을 다 올려야 했다.

Flutter 기반이라 Android와 iOS를 동시에 건드려야 한다는 게 항상 두 배로 신경이 쓰이는 부분이다. Android쪽은 build.gradle.kts에 CAS 의존성을 추가하고, AndroidManifest.xml에 AdMob App ID 메타데이터 태그를 삽입했다. iOS는 Podfile에 CAS pod을 추가하고 pod install을 돌려야 하는데, Podfile.lock이 변경된 것도 같이 커밋해뒀다. CocoaPods 버전이 맞지 않으면 CI에서 pod install이 실패하는 경우가 있어서 lock 파일 관리가 중요하다. 오늘은 다행히 그 문제는 없었다.

CAS가 AdMob 위에서 미디에이션 레이어로 동작하는 구조라, 앱의 AdMob App ID를 CAS가 발급한 것으로 사용해야 한다. 기존에 테스트용 ID로 개발을 진행하다가 CAS 대시보드에서 받은 실제 ID로 교체하는 게 이번 작업의 핵심이었다. ID 자체를 바꾸는 건 AndroidManifest.xml 안에 있는 메타데이터 한 줄이지만, 이게 잘못되면 앱이 시작조차 안 한다. AdMob 초기화 단계에서 크래시가 나버리기 때문에 ID 교체 후에는 반드시 실제 빌드로 확인이 필요하다.

문제는 hedvion_ads 패키지 리포와 Resono 앱 리포가 분리돼 있다는 것이었다. 패키지 쪽을 수정하고, 앱에서 해당 패키지 버전을 올리고, 다시 Android쪽 ID를 확인하고, vc를 올리고 - 이 사이클이 여러 번 돌았다. 커밋 히스토리에 "switch Android ads to CAS-issued AdMob App ID + vc bump"가 반복되는 게 그 흔적이다. 나중에 git log를 볼 사람 입장에서는 보기 안 좋은 패턴이라는 걸 알면서도, 각 리포에서 독립적으로 완결된 커밋이어야 하다 보니 어쩔 수 없는 부분이 있었다.

Play production에 올라가서 live 상태가 됐다. CLAUDE.md에 "Play production live"를 기록했다. 오늘 하루에서 뭔가 실질적인 결과가 났다면 이게 제일 크다.

ADS_TEST=1false였다는 것

이 버그는 황당하다 못해 조금 웃겼다.

Dart에서 환경 변수를 불리언으로 읽을 때 bool.fromEnvironment('KEY')를 쓴다. 이 함수의 동작은 단순하다. 환경에서 문자열로 받아서 딱 'true'인 경우만 true를 반환한다. 소문자 true, 정확히 이 네 글자. '1'false다. 'True'false다. 'TRUE'false다. 이 함수의 사양을 처음 접하면 꽤 엄격하다고 느껴지는 부분이다.

우리 스크립트와 문서에서 테스트 광고를 강제로 켜는 방법은 줄곧 ADS_TEST=1로 표기돼왔다. 개발자들이 이 표기로 빌드를 올리면, cas_backend.dart 안의 forceTestAds 플래그는 항상 false였던 것이다. 테스트 광고가 아니라 실제 광고 요청이 나가고 있었던 셈이다.

그게 겉으로 큰 문제가 되지 않은 건, CAS가 자체적으로 테스트 환경을 어느 정도 처리해주는 부분이 있어서인 것 같다. 하지만 "왜 테스트 광고가 안 보이지?"라는 말이 몇 번 나온 적이 있었다. 그때 제대로 파고들었으면 이 버그를 훨씬 일찍 잡았을 텐데. 그냥 "CAS 특성인가 보다"하고 넘어갔던 게 아쉽다.

수정은 cas_backend.dart에서 ADS_TEST=1 형식도 forceTestAds로 인정하도록 조건을 추가하는 방식으로 했다. 문서의 표기를 =true로 통일하는 방법도 있었는데, 이미 =1 관례가 여기저기 박혀 있는 상황이라 코드에서 유연하게 받아주는 게 낫겠다는 판단을 했다. '1'이나 'true' 모두 테스트 모드로 인식하도록.

작은 수정처럼 보이지만 속이 찜찜하다. 얼마나 오래 이 상태로 돌아가고 있었는지 알 수가 없으니까.

잠금화면 광고 차단 - Play가 직접 HIGH로 올린 것

Google Play 심사에서 HIGH 레벨로 표시해 올려보낸 리뷰 블로커가 있었다. 잠금화면 상태에서 오디오 Stop 이후에도 전면 광고(AppOpen 또는 Interstitial)가 노출된다는 것. Play 정책은 잠금화면에서의 광고 노출을 명시적으로 금지한다. 어기면 앱이 내려갈 수 있는 수준의 위반이라 다른 건 미뤄도 이건 무조건 처리해야 했다.

resono_audio_handler.dart가 백그라운드 오디오를 처리하는 핵심 파일이다. 여기서 앱이 백그라운드에 있거나 화면이 잠긴 상태일 때 전면 광고 로직이 타지 않도록 명시적으로 막았다. 조건 체크를 AndroidManifest.xml 레벨과 코드 레벨 양쪽에서 처리했다. 두 군데 다 막아두는 게 방어적으로 맞는 접근이다.

재생 중인 상태에서 앱을 다시 포그라운드로 올렸을 때 전면 광고를 스킵하는 로직도 추가했다. 이건 정책 문제를 떠나서, 음악 앱에서 재생 중에 전면 광고가 뜨면 음악이 끊긴다. 그 순간 사용자 경험은 끝난다. 어차피 넣을 생각이 없었던 동작인데, 명시적으로 코드로 막아두지 않으면 나중에 광고 로직이 변경될 때 실수로 다시 뚫릴 수 있다. 오늘 그 방어막을 정식으로 추가했다.

배너 여백도 같이 수정했다. resono_banner_mobile.dart에서 배너 상하 패딩이 없어서 광고가 콘텐츠에 바짝 붙는 문제가 있었다. Google 정책에도 광고와 인터랙션 가능한 UI 요소 사이에 충분한 여백을 두라는 기준이 있고, 보기에도 어색했다. 이 수정 자체는 간단한 패딩 추가라 크게 어려울 건 없었다.

ResonoBanner로 통합

리뷰 블로커 수정을 하면서 파일 구조를 훑다 보니 banner_ad_widget.dartresono_banner_mobile.dart가 중복으로 존재하는 게 눈에 들어왔다. 역할이 겹치는 파일 두 개. 언젠가 정리하려고 미뤄두고 있었던 거였는데, 어차피 이쪽 코드를 손대는 김에 같이 처리했다.

ResonoBanner라는 이름으로 통합. 웹 환경에서는 스텁 facade로 동작하고, 모바일에서는 실제 CAS 배너를 렌더링하는 구조다. 웹에는 CAS 배너가 없으니 스텁이 필요하고, 그 분기를 하나의 위젯 안에서 처리하면 lib/main.dart에서 플랫폼을 신경 쓸 필요가 없다. import를 정리하고 banner_ad_widget.dart는 삭제했다.

같이 assets/icon/ 밑에 .bak-20260809가 붙은 백업 파일이 두 개 굴러다니는 걸 발견했다. 아이콘 교체 작업을 하면서 임시로 남겨둔 것들인데 두 주째 있었다. 정리했다.

파일 두 개를 하나로 만드는 게 당장 눈에 보이는 효과가 없다는 걸 안다. 하지만 두 달 뒤 다른 맥락에서 이 코드를 보는 사람이 "이 두 파일 중 뭘 써야 해?"라는 질문을 하지 않아도 된다는 게 진짜 효과다. 리뷰에서 그 질문이 나오는 순간 이미 고쳐야 할 빚이 된다.

PSY는 오늘도 알아서 돌았다

angel-number-message, dawn-shrine-candle, persuasion-style - 세 개가 오늘 날짜로 자동 생성됐다. 커버 이미지 webp 파일과 콘텐츠 JSON이 같이 떨어지는 패턴. 이건 cron으로 돌아가는 파이프라인이라 오늘 직접 손댄 게 없다.

오늘처럼 동시에 여러 군데를 보면서 정신없이 돌아가는 날에, 자동화된 게 알아서 돌아가고 있다는 게 얼마나 안심이 되는지. 내가 신경 안 써도 된다는 것 자체가 값이 있다.


vc8로 iOS를 다시 제출했고, Play production에도 CAS 광고가 붙은 Resono 빌드가 올라가 있다. PriceLens는 네 번째 심사를 기다리는 중이다. 이번엔 다 잡은 것 같다는 느낌이 드는데, 그 느낌이 두 번째 제출 때도 있었다는 게 문제긴 하다.

ADS_TEST=1 버그는 오늘 안 잡혔으면 꽤 오래 묻혀있었을 것 같다. 테스트 환경이 완전히 망가져있지 않으니 표면으로 드러나지 않는 종류의 결함이었으니까. 그런 걸 잡아냈다는 게 오늘 하루에서 제일 기억에 남는 부분이다.

댓글 0

첫 댓글 달아줘.