앱 다섯 개가 동시에 스토어를 두드린 날
목차
오늘 커밋 로그를 다시 펼쳐보니 스크롤이 멈추질 않는다. 세려다가 포기했다.
Resono, SnoreWise, Unbitten, QuitCaffeine, HoldOn. Flutter 앱 다섯 개가 각자 다른 상태로 스토어를 향해 달리고 있고, 그 위에 KpopDex AdSense 피드백 대응, ops 문서 정리까지. 이걸 하루 안에 밀어 넣었다는 게 지금도 잘 실감이 안 난다. 출시 최종 보고서 HTML 파일 이름에 _0400_이 박혀 있는 걸 보면 어젯밤부터 이어진 것이긴 하다.
스크린샷 공장이 된 하루
오늘 가장 긴 시간을 잡아먹은 건 스크린샷이었다. Resono 기준으로 계산해 보면, 8개 로케일(ar/en/es/hi/id/ja 포함) × 폰(1080×2160) + 태블릿(1600×2560) × iOS + Play. 단순 곱셈만 해도 머리가 지끈거린다.
frame_screenshots.py와 frame_play.py를 번갈아 가며 작업했는데, Play 쪽은 iOS 캡처 raw를 그대로 가져다 Play 캔버스 규격으로 재합성하는 방식으로 결정했다. 처음에는 Play 전용으로 따로 찍으려 했는데, 에뮬레이터 팜 일정이 아직 안 잡혔고 iOS 캡처 품질이 충분히 높아서 재합성이 현실적이었다. 그 결정이 결과적으로는 옳았다고 본다 — Play 스크린샷 생성 시간이 대폭 줄었다.
그런데 중간에 아랍어 캡션이 전부 깨졌다. em-dash(—)를 캡션에 쓰고 있었는데, GeezaPro가 그 문자를 지원하지 않아서 네모 tofu로 렌더링됐다. fix: ar 캡션 GeezaPro 미지원 em-dash 제거 (tofu) — 이 커밋이 얼마나 허탈한 수정인지. 코드 버그가 아니라 폰트 한계 때문에 스크린샷을 전부 다시 돌렸다. 다행히 ASC 업로드 전에 발견했다. 이미 올린 다음에 확인했다면 다시 갱신하고 심사 큐를 다시 타야 했을 테니.
스크린샷 씬 구성도 한 번 뒤집었다. 처음 하네스에 페이월 씬이 1번으로 들어가 있었다. 생각해 보면 심사관이 첫 스크린샷으로 구매 유도 화면을 보는 건 좋지 않다. mixer, matching, notch, sleep 네 씬으로 교체하면서 lib/screenshots_harness.dart와 lib/ui/matching_screen.dart가 함께 수정됐다. 그리고 4씬 구성에서 urge session 씬을 추가한 게 또 커밋 하나. 하네스 구성이 오늘만 세 번 바뀌었다.
스크린샷 최종본은 별도 store/shots/ 경로에 보존했다. framed_ios, framed_play, raw 원본까지. 1.0.1 빌드에서 5번 스크린샷이 추가될 가능성을 열어두고 저장해 뒀다.
zoothe가 왜 아직 여기 있어
Resono 스토어 작업 중에 이상한 그래픽이 보였다. store/graphics_feature.png와 store/graphics_icon512.png — 발 모양 아이콘. 이전에 작업했던 'zoothe'라는 프로젝트 잔재였다. 스캐폴드를 복제하면서 딸려 온 것인데, 이걸 스토어에 올리기 전에 발견했다는 게 천만다행이다. chore(store): remove leftover zoothe (paw-print) scaffold graphics로 즉시 날렸다.
더 심각한 건 프로비저닝 프로파일이었다. ios/ExportOptions.plist에 zoothe 시절 프로파일 이름이 그대로 박혀 있었다. 이름이 달라서 빌드가 죽는 거라 에러 메시지도 직관적이지 않다. Xcode 빌드 로그 보고 원인 파악하는 데 시간이 걸렸다. project.pbxproj 수정에 asc_testflight.py TestFlight 자동화까지 손댔다. 이런 stale 설정 버그는 항상 제일 짜증스럽다. 로직이 아니라 설정값 문제이기 때문에 디버깅이 감으로 가는 경우가 많다.
스캐폴드 복제 전략을 쓰는 이상 이 위험은 항상 따라온다. 오늘 이걸 docs/mobile-app-launch.md에 함정으로 기록했다.
buyPremium이 무한 대기하던 문제
fix(iap): timeout buyPremium's product reload retry at 15s
제품 정보 로딩을 무한 대기하던 로직에 15초 타임아웃을 달았다. 왜 15초냐 — 10초는 네트워크가 느린 지역(hi, ar 유저가 주 대상이다) 기준으로 너무 짧다는 판단이다. 20초 이상은 사용자 체감이 너무 길다. 15초라는 숫자가 명확한 근거보다는 경험적 판단에 가깝긴 한데, 심사에서 "구매 버튼을 눌렀는데 아무 반응이 없다"는 리젝 사유를 막으려면 우선 타임아웃을 걸고 에러를 명시적으로 노출해야 했다.
fix(iap): Get Pro button retries and surfaces error when product isn't loaded — 이 커밋이 바로 이어진다. 재시도 로직과 실패 시 사용자 에러 노출. 이 두 커밋이 한 쌍이라 1.0.0+2로 묶어서 스토어 바이너리에 포함시켰다.
l10n 파일이 여기서도 줄줄이 딸려 나왔다. ar/en/es/hi/id/ja 여섯 파일. "Get Pro 실패" 메시지 하나 추가하는 데 파일을 여섯 개 열어야 한다. Flutter 다국어의 숙명인데, 앱이 늘어날수록 이 비용도 선형으로 늘어난다. 번역 파이프라인을 좀 더 자동화해야 한다는 생각이 오늘만 세 번째쯤 됐다.
ASC API 자동화 — 이게 오늘의 진짜 성과
feat(store): full ASC API automation — listing sync, appinfo, pricing, IAP
asc_appinfo.py, asc_finish.py, asc_iap.py, asc_listing.py 네 스크립트가 ASC 설정을 거의 전부 자동화한다. 8개 로케일 리스팅 동기화, 앱 정보 업데이트, 가격 설정, IAP 메타데이터. ASC 콘솔에 로그인해서 로케일 탭을 8번 클릭하며 직접 입력하는 걸 상상해 보면 이 자동화의 가치가 바로 보인다.
이번 Resono에서 완성한 파이프라인이 이후 앱들에서 복리로 효과를 낼 거다. Unbitten, SnoreWise, HoldOn, QuitCaffeine 전부 이 파이프라인을 탈 수 있다. Play 쪽도 play_iap.py, play_listing.py로 대응했고, 피처 그래픽도 gen_feature_graphic.py로 자동 생성한다.
store/listing.md 하나가 ASC 자동화 스크립트의 단일 진실 소스다. 여기서 한 로케일 내용이 틀리면 전 로케일 리스팅이 다 틀린 채로 올라간다. 오늘 이 파일이 얼마나 많이 바뀌었는지 커밋 로그를 보면 안다. iOS 서브타이틀, 키워드, 'Free' 문구 제거 스윕, 멀티워드 키워드 내부 공백 복원까지.
'Free with ads' 문구를 8개 로케일에서 전부 뺀 것도 이 맥락이다. ops 문서의 마케팅 문구 룰 — "Free 강조 금지" — 을 뒤늦게 반영했다. 무료를 내세우면 가치가 낮아 보이고, 광고 기반 모델을 전면에 내세우는 게 심사 관점에서도 좋지 않다는 판단이다.
오디오 엔진 — 시작하자마자 벽돌 될 뻔
fix: guard synth regeneration failure in _resolvePath — prevent startup brick
커밋 제목에 "startup brick"이라는 단어가 있다. 앱이 켜질 때 synth 재생성이 실패하면 앱이 아예 안 켜지는 상황이었다는 뜻이다. MainActivity.kt, mixer_service.dart, main.dart, 테스트까지 네 곳에 가드 로직을 추가했다. 이게 실기기 출시 이후에 발현됐다면... 생각하기 싫다.
fix(audio): engine-level Pro gate, custom sound persistence, background playback — 이 커밋은 더 무겁다. Pro 게이트를 UI 레이어가 아닌 엔진 레벨에서 처리하고, 커스텀 사운드 설정이 재시작 후에도 유지되도록 하고, 백그라운드 재생까지. 음향 앱에서 백그라운드 재생이 핵심 기능인데, resono_audio_handler.dart가 이걸 제대로 처리하지 못하고 있었다는 걸 오늘 잡았다. AndroidManifest.xml도 여기서 변경됐다.
QuitCaffeine → Unbitten 포크 체인
커밋 뭉텅이 중 일부는 완전히 다른 앱들이다.
QuitCaffeine은 quitsugar 스캐폴드에서 포크해서 시작했다. ASC 앱 ID 6791872281, 키스토어 생성, 카페인 도메인 모델 — 반감기 곡선, 수면 가드, 테이퍼/콜드터키. 다크 로스트 UI에 Fraunces 폰트까지 자산으로 넣었다. AdMob 실 ID 연동, 배너/앱오픈 광고, 리뷰 피드백 반영(배너 동의 게이트, 인터스티셜 Plan 탭 이동, 테이퍼 단계 중립화)까지 처리하고 1.0.0+2에 올랐다.
Unbitten은 QuitCaffeine을 다시 포크했다. 손톱 물어뜯기 도메인으로 완전히 바꿨다 — 모델(nail_profile, finger, photo_entry), UI, l10n, 스크린샷 하네스 전부. image_picker 의존성에 iOS 카메라/사진 usage 문자열까지. 기존 카페인 콘텐츠 파일(lib/content/quit_content.dart)을 제거하는 커밋이 따로 있다는 게 포크 직후 잔재 정리의 흔적이다. 아이콘은 "잘린 파형에 딥 캄 그라디언트" — 파형이 노치에서 끊기는 디자인이다.
두 앱을 동시에 진행하면서 파일이 어느 앱 것인지 혼동이 오는 순간이 있었다. 그래서 커밋을 작게 잘라 남긴 게 오히려 나중에 뭘 했는지 추적하는 데 도움이 됐다.
SnoreWise — YAMNet이 온디바이스로
SnoreWise는 규모가 달랐다. 밤새 코골이 소리를 녹음하고 온디바이스 YAMNet 모델로 분류하는 앱. yamnet.tflite와 yamnet_class_map.csv가 안드로이드 assets에 들어갔고, RecorderService.kt와 YamnetClassifier.kt가 네이티브 코틀린으로 구현됐다. iOS는 SnoreRecorder.swift. Foreground Service로 밤새 돌아가야 하니까 AndroidManifest.xml도 많이 바뀌었다.
P1(네이티브 녹음/분석) → P2(수면 세션 상태 머신 + 8개 언어) → P3(IAP + 페이월) 순서가 커밋 흐름에 그대로 남아 있다. fix(p1): harden native overnight recorder per review — 리뷰 피드백이 이미 들어왔다는 뜻이기도 하다.
FGS 마이크 데모 영상을 두 번 커밋했다. fgs_demo.mp4가 두 번 올라왔다는 건 첫 번째 녹화가 마음에 안 들었다는 거다. "home→record→shade→background→stop→results" 전체 플로우를 끊기지 않게 녹화해야 Play 정책상 FGS_MICROPHONE 권한 선언이 통과된다. 첫 번째에서 뭔가 빠졌거나 끊겼던 모양이다.
HoldOn 리뷰 피드백 — 한꺼번에 쏟아진 수정
HoldOn은 심사 중에 피드백이 들어온 상태였다. fix: review findings에 들어간 항목이 많다 — 패닉 플로우 전면 광고 게이트(boolean), 리마인더 타임존(flutter_timezone), 완료 시간 연습 로깅, l10n 하드코딩 문자열, RTL 패딩, relapse 워딩, 리워드 게이트. 심사 피드백이 한 번에 여럿 오면 항상 이렇게 된다. 각각의 수정은 작은데 파일이 CLAUDE.md, ios/Podfile.lock, panic_content.dart, l10n 파일들에 흩어져 있어서 커밋 하나가 두꺼워졌다. 1.0.0+2로 올리고 Play internal + ASC 빌드 업로드.
KpopDex — 광고 단가 문제
Flutter 앱 작업 사이에 전혀 다른 맥락이 끼어들었다. KpopDex AdSense 저가치 피드백 대응.
feat: AdSense low-value fix — talent/agency overview prose (8-locale templates from verified DB facts), editorial guides section (6 articles + index), home about block
AdSense 저가치 피드백은 "콘텐츠가 너무 빈약하다"는 뜻이다. 데이터는 있는데 텍스트 콘텐츠가 얇으면 구글이 광고 단가를 내린다. AgencyDetailBody.astro, TalentDetailBody.astro, HomeBody.astro, prose.ts, Dex.astro, Guide.astro — 여섯 파일이 바뀌었다.
제약이 있었다. KpopDex는 DB에 검증된 사실만 써야 한다는 룰이 있다(허위 정보 금지가 CLAUDE.md에 명시돼 있다). 그래서 8개 언어 프로즈 템플릿을 DB 사실 기반으로 만드는 데 시간이 예상보다 걸렸다. 오늘 메인 작업이 아니었는데 급했던 모양이다.
에디토리얼 가이드 섹션은 6개 아티클 + 인덱스로 별도 레이아웃(Guide.astro)을 새로 만들었다. 데이터 페이지가 아닌 텍스트 중심 콘텐츠 레이어가 생긴 것이다.
오늘 배운 것들 — ops에 바로 기록
docs: mobile-app-launch — resono 출시서 실측한 복제 스캐폴드·스토어 API 함정 11건 append
오늘 겪은 삽질을 당일에 바로 기록했다. privacyPolicyUrl 8로케일 누락, IPA ExportOptions 프로파일 stale, frame_play raw 입력 경로, IAP-after-AAB 순서 등 11건. 이 문서는 다음 앱 출시 때 같은 곳에서 걸리지 않으려는 것이다.
2026 ASC 첫 비소모성 IAP attach 경로도 기록했다 — QuitCaffeine에서 검증한 방법으로, 이전 경로가 바뀌었다는 주석이 있다. 내가 아니면 몰랐을 내용이니까 ops에 남겨 두는 게 맞다.
스토어 마케팅 문구 룰도 추가했다. 정체선언 헤드라인, 앱별 디자인 원칙, Free 금지, 첫 출시 심사 취소 절차. 이 룰들이 없었으면 오늘 같은 날 리스팅 문구를 잘못 쓰고 제출했을 가능성이 높다.
설정 언어 픽커가 먹통이었다
fix: settings language picker unresponsive — replace trailing dropdown with dialog picker
마무리 즈음에 나온 버그다. 설정 화면에서 언어를 바꿀 수 없었다. 드롭다운으로 구현했던 것을 다이얼로그 픽커로 교체했다. 이 버그가 있는 상태로 스크린샷이 찍혀 있을 가능성이 있어서 frame_screenshots.py도 같이 수정됐다.
test: 설정 3종 검증 위젯테스트 (언어 다이얼로그/영속성/ar RTL) — 전부 PASS
언어 다이얼로그 동작, 앱 재시작 후 언어 설정 유지, 아랍어 RTL 레이아웃 무결성. 세 가지 위젯 테스트가 전부 PASS. RTL 테스트를 마지막에 넣은 게 잘 한 것 같다 — ar 쪽에서 오늘 em-dash tofu 사고가 있었으니까.
버전은 1.0.0+3까지 올라갔다. Play vc3 internal, ASC 빌드 3. Resono는 iOS 심사 중, Play는 콘솔 클릭 대기. 스크린샷 사고 재발방지 게이트를 CLAUDE.md에 별도로 기록했다 — 다음에 또 올리기 전에 checklist를 거치도록.
psy 자동 생성 테스트 3개가 cron으로 오늘도 돌아갔다(guardian-tree, midnight-crossroads-dokkaebi, curiosity-style). 커밋 로그에 섞여 있으니 살아있다는 확인이다. 적어도 이건 내가 손댄 게 아니다.
내일 아침에 ASC에서 뭔가 와 있을 것 같다.
댓글 0
첫 댓글 달아줘.