세 앱을 하루에 스토어 심사 넣은 날
목차
CamperLevel, Haru Cam, HWP Viewer. 세 개 앱이 동시에 각자 다른 문제를 들고 대기 중인 하루였다. 어느 걸 먼저 건드리느냐의 문제가 아니라, 세 방향 동시 소화를 기본값으로 깔고 시작해야 했다. 아침부터 이미 그 무게가 느껴졌다.
R8이 실기기에서만 죽는 이유
첫 번째 벽은 CamperLevel Android였다. AGP9 환경에서 R8 minify를 켜면 에뮬레이터에서는 멀쩡한데 실기기 설치 후 즉시 크래시가 난다. 에뮬레이터와 실기기의 차이가 R8 난독화 과정에서 어떤 클래스나 메서드를 strip해버리는 데서 오는 거라는 건 알겠는데, 오늘 그걸 파고들 여유는 없었다. minifyEnabled = false로 내리는 건 앱 바이너리 크기나 리버스엔지니어링 면에서 손해지만 기능에 영향은 없다. 출시 일정이 먼저다. 나중에 proguard rules를 제대로 잡아서 다시 켜는 게 맞다는 걸 알면서도 오늘은 일단 치워두고 넘어갔다.
R8 크래시랑 동시에 pubspec.yaml 버전도 건드린 건 그 시점에 1.0.1이 ASC에서 이미 승인 상태였기 때문이다. 같은 버전 번호로 올리면 충돌이 나니 1.0.2+6으로 올렸다. 버전 관리가 iOS/Android/ASC/Play 네 개 시스템을 동시에 만족시켜야 할 때 항상 약간의 숫자 산수가 따라온다.
스크린샷 파이프라인을 사람이 짜야 한다
오늘 가장 많은 시간을 쓴 건 단연 스크린샷 파이프라인이었다. 단순히 캡처가 많아서가 아니라 구조가 여러 겹이기 때문이다.
Flutter 하네스로 시뮬레이터에서 원본 캡처를 먼저 뜬다. iPhone/iPad 두 기기, 8로케일, 화면 5장이면 80장. 거기서 ASC 포맷용 framing - 디바이스 목업 위에 캡처를 얹고 마케팅 카피를 박는 작업 - 이 또 64장. Play 쪽은 composite이 다른 치수로 96장. 합치면 240장에 가까운 이미지를 하루 안에 뽑아야 한다.
build_cap_screenshots.sh 자체는 이전에도 있던 구조인데 오늘 부하 내성을 건드렸다. Flutter 렌더러가 빠른 연속 스크린샷에 따라잡지 못하면 흰 화면이나 로딩 중인 화면이 찍힌다. 이걸 막으려면 화면 전환 후 충분한 대기를 넣어야 하는데 너무 넣으면 80장 찍는 데 두 시간이 걸린다. 오늘 조정한 마진이 아직 최적인지는 솔직히 모르겠다. 일단 깨진 화면 없이 캡처가 나왔으니 오늘은 그걸로 충분하다.
frame_screenshots.py로 framing을 돌리다가 아랍어 로케일에서 뭔가 이상한 걸 발견했다.
GeezaPro는 라틴 숫자를 모른다
ar 로케일 framed 이미지에서 블록 수 표시 텍스트의 숫자 부분이 tofu(□□)로 찍혔다. '24블록 필요' 같은 텍스트에서 '24'가 사각형으로 나오는 것.
Pillow에서 아랍 문자 렌더링할 때 쓰던 폰트가 GeezaPro였다. GeezaPro는 아랍 문자, 히브리 문자 같은 건 잘 처리하는데 라틴 digits - 즉 0-9 숫자(역설적이지만 '아라비아 숫자'라고 부르면서 라틴 인코딩을 쓰는) - 의 글리프가 없다. 당연히 tofu.
Al Nile로 교체했더니 바로 해결됐다. 폰트 한 줄 바꾸는 거라 수정 자체는 5분도 안 걸렸는데, 이걸 발견하려면 8로케일 결과물을 실제로 눈으로 봐야 한다. 자동화 파이프라인에서 결과물 QA를 빠뜨리면 이런 게 그대로 스토어에 올라간다. 심사관한테 아랍어 화면에서 숫자가 두부로 뜨는 스크린샷을 보여주는 상황은 생각만 해도 민망하다.
IAP가 조용히 0개 국가로 활성화되는 트랩
스크린샷보다 더 위험하게 숨어 있던 버그는 IAP availability였다. asc_iap.py에서 IAP를 생성할 때 availableTerritories 파라미터를 빈 배열로 넘기면 ASC API가 에러를 뱉지 않는다. 그냥 조용히 0개 국가로 IAP를 활성화한다.
IAP가 콘솔에 ACTIVE로 표시되는데 실제로는 아무 국가에서도 구매할 수 없는 상태. 이걸 직접 콘솔 들어가서 territory 설정을 눈으로 확인하기 전까지는 알 방법이 없다. 앱이 심사를 통과하고 라이브까지 올라갔어도 IAP가 팔리지 않는 상황이 될 수 있다는 얘기다.
~175개 territory를 명시적으로 배열에 다 넣는 방식으로 수정했다. ASC API에서 "전체 국가"를 뜻하는 특수 값이 따로 없어서 일일이 나열하는 수밖에 없다. 코드가 보기 싫어졌지만 0-territory 트랩보다는 낫다.
오늘 mobile-app-launch.md에 함정 6종을 문서화했는데, 이 IAP territory 이슈가 그 중 하나다. 같은 실수를 또 하지 않으려면 글로 박아두는 게 최선이라는 걸 경험으로 안다.
iOS 배포 서명의 매번 반복
CamperLevel iOS 배포 프로필을 새로 만들었다 - CamperLevel AppStore 20260718. 이름에 날짜가 들어가는 이유는 프로필이 만료되거나 재생성할 때마다 구별이 필요해서다. pbxproj와 ExportOptions.plist 양쪽에 반영했다.
코드 서명 identity를 Automatic으로 통일하는 커밋도 따로 들어갔다. 포크해온 프로젝트들은 종종 Apple Distribution을 하드코딩으로 박아놓는 경우가 있다. 그 상태로 팀 계정이 다른 환경에서 빌드하면 서명이 맞지 않아 실패한다. 자동 서명으로 통일하면 이식성이 좋다.
TestFlight 업로드까지 완료하고 ASC에서 WAITING_FOR_REVIEW 상태를 확인했을 때의 느낌은 안도와 불안이 동시에 온다. 빠진 게 없나, IAP가 제대로 활성화됐나, AdMob 실ID가 맞나, 법무 링크가 정상인가. 제출 버튼을 누른 다음에도 체크리스트가 머릿속에서 계속 돌아간다.
Play는 vc3, 177개국 알파
Play Store 쪽은 좀 더 순탄했다. vc3(version code 3) 기준으로 177개 국가 알파 트랙 제출, declarations 10개, 스크린샷 업로드 완료.
스크린샷 업로더에서 하나 고친 게 있다 - asc_ss_upload.py에서 슬롯이 최대 4장으로 하드코딩돼 있었다. Play는 화면별로 최대 8장까지 올릴 수 있고 우리는 5장을 쓰는데 4장 cap에 걸려서 마지막 한 장이 계속 안 올라갔던 것. 5장으로 올리고 보니 그제야 전부 들어갔다. 얼마나 오래 이 상태로 운영했는지 생각하면 좀 찜찜하다.
es → es-419 매핑도 오늘 처리했다. Play Console에서 스페인어는 es-419(라틴아메리카)가 기본 매핑이라 listing 스크립트에서 es로 넘기면 맞는 로케일을 못 찾는다. 이런 게 스크립트를 새로 짤 때마다 한 번씩 걸리는데, 매번 당하면서도 매번 잊어버린다.
Haru Cam, 오늘 처음부터 만든 앱
CamperLevel이 기존 앱의 수정과 출시라면, Haru Cam은 오늘 스캐폴드부터 제출까지 전체를 새로 쌓은 케이스다. quitcaffeine 프로젝트를 포크해서 패키지명, bundle identifier, 앱 이름, 모든 스토어 스크립트를 갈아엎는 것부터 시작했다.
quitcaffeine 잔재를 정리하는 커밋이 따로 있는 이유가 있다. 포크 직후에 앱 이름만 바꾸고 스크립트를 그대로 두면 나중에 자동화 스크립트가 구 앱 ID로 API 콜을 날리는 사고가 생긴다. 스크립트 파일 6개를 열어서 하드코딩된 앱 ID, bundle ID, 앱 이름을 전부 바꿨다. 귀찮지만 안 하면 반드시 사고가 난다.
핵심 기능은 LUT 필터 엔진이다. 24개 필터 - ame(비), aoi(파랑), asa(아침), cha(차), fuyu(겨울), hanabi(불꽃) 등 일본 감성 이름으로 채웠다. PNG 형식 LUT 파일을 로드해서 픽셀 색상값을 매핑하는 방식이고, 카메라 뷰파인더에서 실시간 미리보기가 돌아가야 한다. LUT 처리 성능이 문제가 될 수 있는데 그 부분은 compute isolate로 우회하는 방식을 썼다.
UI는 필름 뷰파인더 컨셉으로 잡았다. dusk-orange 액센트 컬러, 필름 스트립 느낌의 레이아웃. dual-theme(라이트/다크) 감사도 오늘 했다. 각 화면에서 컬러 토큰이 테마별로 제대로 적용되는지 하나하나 확인해야 하는 작업인데, 의외로 시간이 잡힌다. camera_screen, editor_screen, home_screen, paywall_screen, app_theme까지 전부 건드렸다.
앱 아이콘은 조리개 모양을 일본 황혼 그라디언트 위에 얹은 디자인으로 만들었다. Android mipmap 5세트(hdpi/mdpi/xhdpi/xxhdpi/xxxhdpi) 전부 교체. assets/icon/app_icon.png도 함께.
AdMob 세팅은 CamperLevel과 거의 동일한 흐름으로 진행했다. Info.plist에 GADApplicationIdentifier, ad_config.dart에 배너/전면/앱오픈 유닛 실ID 반영, useTestAds = false. 두 앱을 동시에 하다 보니 어느 앱의 어느 ID인지 헷갈리지 않으려고 CLAUDE.md에 앱별 ID를 따로 정리해뒀다. admob-ids.md 파일도 만들었다.
스크린샷 하네스는 5개 화면, 실제 라이선스 사진 샘플 5장(도쿄 야경, 교토 녹지, 구름, 인물, 음식)으로 구성했다. railway 샘플이 가로/세로 두 버전으로 나뉘는 건 화면 2가 세로 비율 crop을 써야 했기 때문이다. 그 화면의 구도 때문에 가로 원본을 그냥 썼더니 피사체가 잘렸다. SOURCES.md에 이미지 출처를 기록해두는 건 나중에 라이선스 문제가 불거질 때 방어할 수 있게 하려는 것이다.
screenshots_harness.dart는 오늘 여러 번 건드렸다. 마케팅 스크린샷 하네스 초기 구성, railway 세로 crop 추가, AdMob 실ID 반영 후 useTestAds 확인, 최종 framing 연동까지. 한 파일이 오늘 커밋에 여러 번 등장하는 게 이 흐름 때문이다.
HWP Viewer 서버 사이드
세 번째 앱 HWP Viewer는 결이 좀 다르다. 앱 자체도 있지만 hwpconv.hedvion.com 서버가 진짜 핵심 인프라다.
오늘 1.2.1 버전으로 올라가면서 /v1/convert?to=docx 엔드포인트 - HWP/HWPX를 Word로 변환하는 - 가 LibreOffice OOXML 필터 기반으로 40 서버에서 라이브 검증됐다. 200 응답 확인.
그 전 버전인 1.1.2에서는 /v1/tohwpx - HWP를 HWPX로 변환하는 - 가 들어갔다. hwp2hwpx JitPack 의존성을 쓰는데, HWPX가 ZIP 기반 포맷이라 재패키징할 때 전 엔트리를 STORED(비압축) 모드로 넣어야 한다. DEFLATE로 넣으면 HWPX 파서가 내부 구조를 못 읽는다. ZIP 파일이 유효한데 파싱이 안 되는 상황이라 처음엔 원인 찾기가 까다롭다.
앱 쪽에서는 내보내기 4종(Word/PDF/이미지/원본)과 무료 하루 1회 변환 제한, IAP 광고제거 무제한 통합을 오늘 묶었다. 8로케일 ARB 파일 동기화가 항상 귀찮다. ar/en/es/hi/id 등 다 건드리면서 빠진 키 없는지 확인하는 데 일정 시간이 소요된다.
iOS 빌드는 altool 경고 두 개를 잡았다. 90683은 NSCameraUsageDescription 관련 - DKPhotoGallery 라이브러리가 카메라 API를 참조하는 탓에 앱이 직접 카메라를 안 써도 usage description이 있어야 한다. 90737은 LSSupportsOpeningDocumentsInPlace - 이 키를 false로 명시하지 않으면 경고로 걸린다. 각각 별도 빌드(build 4, build 2)로 올라간 게 그날 ASC 사이클이 얼마나 촘촘하게 돌아갔는지를 보여준다. 빌드 번호가 4까지 올라간 앱은 그만큼 수정-재제출을 여러 번 거쳤다는 뜻이다. 경고 하나 때문에 빌드를 새로 올리고 ASC가 처리하는 걸 기다리는 시간이 축적되면 꽤 무시못할 타임로스다.
CDP 9222 상시 접속으로 구조를 바꾼 이유
문서 작업 중에 Playwright 자동화 구조 변경도 정리했다. Play Console이나 ASC를 Playwright로 자동화할 때 크롬 프로필을 직접 여는 방식이 락 문제를 일으킨다는 건 오늘 실제로 막혀서 확인했다. 스크립트가 크롬 프로세스를 직접 띄우면 기존에 열려 있는 프로필과 충돌하거나 세션 정보가 날아가는 경우가 생긴다.
상시 크롬을 CDP 9222 포트로 열어두고 Playwright가 거기에 attach하는 구조로 바꾸면 이 문제가 없다. 프로세스를 새로 띄우지 않고 기존 세션에 붙기 때문에 프로필 락도, 로그인 세션 초기화도 없다. 이게 오늘 실제로 막혀서 나온 변경이다. 같은 상황이 반복됐고, 구조를 바꿨다.
mobile-app-launch.md에 이 내용과 함께 오늘 만난 함정들 - kt/swift sed, key.properties 캐리오버, 에뮬 가상센서 부호 검증, reviewSubmissionItems 500 에러, CDP 폴백, 스샷 부하 내성 - 을 6종으로 정리해뒀다. 다음에 새 앱 출시할 때 같은 데서 막히지 않으려면 이 정도는 남겨둬야 한다. 문서화는 항상 그날 삽질의 흔적이다.
psy 테스트와 하루의 끝
하루 끝에 chore(psy): daily auto-generated tests 커밋이 조용히 들어갔다. delivery-order-type, friend-new-closer, midnight-studyroom-visitor - 오늘 날짜 기준 자동생성 결과물이다. i18n 영문 파일도 함께. 이건 파이프라인이 알아서 만들어준 거라 직접 손댄 게 없다. 오늘처럼 복잡한 날 자동화가 알아서 돌아가는 영역이 하나 있다는 게 작은 위안이다.
결국 오늘은 CamperLevel, Haru Cam, HWP Viewer 세 앱이 동시에 서로 다른 단계에 있었고, 각각 iOS/Android 양쪽 플랫폼을 다루면서 스토어 제출, 빌드 수정, 파이프라인 개선이 병렬로 돌아갔다. 커밋 수가 많아 보이는 건 각 수정이 독립적으로 추적될 수 있도록 쪼개놓은 것이지 작업이 분산돼 있었던 건 아니다. 실제로는 하나의 긴 집중 세션이었다.
ASC는 WAITING_FOR_REVIEW, Play는 in review. 결과는 내일 이후다. GeezaPro 폰트 버그랑 IAP 0-territory 트랩을 심사 전에 잡은 건 다행이었다. 그게 심사 단계에서 걸렸으면 각 앱마다 적어도 며칠씩 더 묶였을 거다.
댓글 0
첫 댓글 달아줘.