일기 slecs

전 앱 광고 수술하면서 카나스타까지 출시한 날

목차

발단은 CAS.ai 도입 검토였다. AppLovin MAX만 쓰던 구조에서 CAS를 끼워 넣으면 필 레이트를 높일 수 있다는 판단이 있었고, 그 검토를 하다가 더 근본적인 문제를 발견했다. 광고 코드가 앱마다 완전히 제각각이었다.

어떤 앱은 lib/services/ad_config.dart, 어떤 앱은 lib/core/ads/ad_config.dart, 또 어떤 앱은 lib/monetization/ad_config.dart. 이름만 다른 게 아니라 구조도 다르고 초기화 시점도 다르다. 여기에 CAS를 또 각자 방식으로 심으면 나중에 수습이 안 된다.

결론은 단순했다. 공통 패키지로 뽑는다.

hedvion_ads — 처음부터 제대로 짠다

hedvion_ads 첫 커밋에 AppLovin MAX, 원격설정, 무효트래픽 가드를 한 묶음으로 담았다. 원격설정이라는 건, 앱을 다시 배포하지 않고 서버에서 광고 유닛 ID나 노출 전략을 바꿀 수 있는 것이다. 무효트래픽 가드는 QA 기기나 봇 트래픽이 실 광고 클릭을 내는 걸 막는 안전망.

두 번째 커밋에서 CAS.ai 듀얼 백엔드를 추가했다. network 필드 하나로 max/cas를 원격에서 토글하는 구조. 값이 "max"면 AppLovin을 쓰고, "cas"면 CAS.ai를 쓴다. 앱을 다시 빌드하지 않아도 서버에서 한 줄로 바꿀 수 있다는 게 핵심이다. 어떤 앱에서 어느 네트워크가 더 잘 도는지 A/B로 확인하면서 실시간으로 조정하려면 이 구조가 필요하다.

패키지만 있으면 반이다. 서버에서 설정을 내려줘야 동작한다.

광고 원격설정 백엔드 — Prisma 두 방에 관리 UI까지

ops 레포에서 Prisma 마이그레이션을 두 번 날렸다. 첫 번째는 app_ad_configapp_test_device 테이블. 앱별로 광고 유닛 ID, 노출 전략, QA 기기 목록을 저장하는 구조다. 두 번째는 CAS 듀얼 백엔드를 지원하기 위한 network/casId/cas 포맷 필드 추가. 이게 hedvion_ads의 network 토글과 연결되는 지점이다.

공개 API는 /api/public/app-ads/[appId]. 앱이 시작할 때 자기 설정을 여기서 받아온다. 어드민 CRUD는 /api/admin/app-ads 계열로 분리했고, 관리 UI는 AppAdsClient.tsx로 만들었다. 거기다 app_ad_unit 테이블 생성기(_lib/app_ad_config_gen.py)와 cron 연동까지. 앱별 광고 유닛 DB 레코드를 동적으로 주입하는 스크립트다.

docs/snippetsremote_ad_config.dart 스니펫도 만들었다. 다음 앱부터는 이 파일 보고 연결하면 된다.

AdMob 쪽은 별도로 Node.js 서비스에 휴면 배선을 추가했다. 유닛 ID를 주입식으로 받아서 빈값이면 광고가 off가 되는 구조. "휴면"이라는 표현을 쓴 건, 지금 당장 AdMob을 쓰는 게 아니라 준비만 해둔 상태이기 때문이다.

test_ad_guard 스윕 — 지루하지만 사고 막는 방어선

hedvion_ads를 만들었다고 기존 앱들이 자동으로 정리되진 않는다. 마이그레이션은 앱마다 손으로 해야 한다. 오늘 전부 다 하기는 무리라서, 제일 급한 것만 적용했다. QA 광고 가드, 즉 test_ad_guard.

개념은 단순하다. kReleaseMode가 false면 광고 유닛 ID를 테스트 유닛으로 덮어씌운다. 그게 전부다. 근데 이걸 빠뜨리면 QA 기기에서 실 광고가 돌고, AdMob 정책상 무효 트래픽으로 잡힐 수 있다. 계정 정지까지 갈 수 있는 문제다.

커밋 제목이 "QA 광고 가드(test_ad_guard) — 비릴리즈 빌드 테스트 광고 강제"로 시작하는 커밋이 오늘만 스무 개가 넘는다. 다른 건 파일 경로뿐이다. lib/services/ad_config.dart인 앱, lib/ad/ad_config.dart인 앱, lib/core/ads/ad_config.dart인 앱, lib/features/monetization/ads_service.dart인 앱, lib/monetization/ad_config.dart인 앱, lib/ads_setup.dart인 앱. 같은 조직에서 만든 앱들이 이렇게 다를 수 있나 싶지만, 앱마다 시작한 시점과 관여한 사람이 달라서 그렇다. 이게 hedvion_ads를 만든 이유다.

docs/snippets/test_ad_guard.dart를 표준 스니펫으로 ops에 박아뒀다. 다음 앱부터는 시작할 때 이걸 참조하면 처음부터 올바른 구조로 간다. 이번 같은 20커밋 스윕이 반복되지 않아야 한다.

banner ad cold-start 버그도 오늘 잡혔다. initMonetization이 완료되기 전에 배너 위젯이 마운트되면 광고가 영영 뜨지 않는 문제였다. banner_ad_slot.dart에서 초기화 완료를 기다리는 폴링 로직을 추가. 신규 설치 후 첫 세션에서 배너가 아예 안 뜨는 경험으로 나타났을 가능성이 높다. 얼마나 많은 노출을 놓쳤는지는 숫자로 확인하기 전에는 모른다.

Canasta Score — scaffold에서 심사 대기까지 하루에

오늘 제일 집중했던 건 Canasta Score다.

시작은 casaproof 레포 포크. sed 스윕으로 식별자를 중립화하고 iCloud를 스트립하는 것부터. 이건 준비 단계다. 그다음부터가 진짜였다.

카나스타는 복잡한 카드게임이다. 팀전, 멜드(세트 놓기), 카나스타(7장짜리 완성 세트), 블랙/레드 3s 처리, 라운드 보너스, 나가기 점수가 얽혀 있다. 이 규칙들을 도메인 모델로 옮기는 게 먼저였다. game.dart, game_mode.dart, house_rules.dart, 스코어링 엔진. 유닛 테스트 23케이스로 검증했다. 단순히 코드가 돌아가는 게 아니라 규칙이 정확한지를 증명해야 한다. 스토리지는 sqflite로 오프라인에서도 영구 기록이 가능하도록.

UI 방향은 시니어 친화로 잡았다. 카나스타를 즐기는 층을 감안해서 글씨, 버튼, 대비를 넉넉하게. 테마는 felt-green/gold. 카드 테이블 느낌이 나야 했고, 앱 아이콘도 그 방향으로 디자인했다.

table-mode는 아이디어 자체가 재미있었다. 탁자에 폰을 올려놓고 두 팀이 마주보면서 점수판을 공유하는 모드. 화면을 반으로 나눠서 아래쪽은 정방향, 위쪽은 180도 뒤집어 보여준다. 더블탭으로 점수 입력. 근데 더블탭이 저장을 두 번 트리거하는 버그가 있었고, 화면 전환 시 pop이 두 번 되는 것도 있었다. 둘 다 리뷰 블로커였다. double-save guard와 no double-pop으로 해결.

i18n은 8개 언어. en/ko/ja/es/pt/fr/de/it. arb 파일을 처음부터 다 썼다. 포르투갈어에서 paywall의 "Assine"(구독하세요)를 "Desbloqueie"(잠금해제)로 바꾼 것도 여기 포함됐다. 카나스타는 영구 구매 모델이라 구독 언어를 쓰면 안 된다. 언어 하나가 심사 거절 이유가 될 수 있어서.

스토어 제출 툴링도 카나스타에 맞게 전면 조정했다. iOS 전용 앱이라 Play 관련 스크립트를 전부 지웠다. ASC에 앱 6793218150, IAP 6793219467을 생성하고 실 ID를 코드에 연결. 리스팅 카피는 8개 로케일 × 키워드 90-100자 검증. 스크린샷 프레임 헤드라인은 8로케일 × 5장. 법적 문서 SQL도 카나스타 앱에 맞게 재작성. 앱 카드도 포트폴리오 페이지에 추가했다.

결과적으로 메인 앱과 IAP 모두 WAITING_FOR_REVIEW 상태가 됐다. 오늘 하루 안에.

scaffold를 아침에 찍었고 심사 대기가 된 게 오늘이다. 하루 만에 카드게임 앱을 처음부터 스토어 심사까지 보낸 건, 도메인이 명확했기 때문에 가능했다. 카나스타 규칙은 이미 정해져 있고, casaproof 구조를 재활용할 수 있었으며, 스토어 제출 툴링이 어느 정도 표준화돼 있었다. 그래도 이 속도는 쓰고 나서도 좀 신기하다.

Spectrum Pro 1.1.0 — 배음 버그와 릴리즈

Spectrum Pro는 오늘 크고 작은 게 겹쳤다.

pro DSP/UI 모듈 완성이 먼저다. 1/3옥타브 밴드 분석, 피크파인더, 톤합성기, RTA뷰, 차트마커, 캘리브레이션을 meter_kit 패키지에 담았다. FFT 사이즈 16384. 주파수 해상도를 높이기 위한 선택인데, 프로 사용자가 세밀한 분해능을 요구하는 경우가 있다. IAP로 Pro를 구매하면 이 모든 기능이 열린다.

배음 앵커 버그가 기술적으로 제일 섬세했다. 어쿠스틱 기타 같은 악기에서 기본음보다 제2/제3 배음이 더 강하게 녹음되는 경우가 있다. 이런 신호에서 피크파인더가 기본음을 태깅하지 못하고 배음을 기본음으로 잡는 문제가 있었다. peak_finder.dart에서 최강 피크 -12dB 이내의 최저 주파수를 앵커로 잡도록 수정했다. 같은 세기라면 낮은 주파수를 우선한다는 개념이다.

릴리즈는 1.1.0+8. em-dash 스윕이 흥미로웠다. em-dash(—)를 일반 하이픈(-)으로 바꾸는 작업인데, 이게 리뷰 리젝 이유가 됐던 것 같다. 리스팅이나 앱 내 텍스트에 em-dash가 섞이면 App Store 심사에서 걸리는 케이스가 있다. integration test로 QA 투어도 만들었고, 릴리즈 스크립트도 정리했다.

Pilmori — 시뮬 캡처 금지와 스샷 하네스

Pilmori는 iOS 사진 필터 앱인데, 오늘은 스크린샷 관련이 주였다.

App Store는 시뮬레이터 raw 캡처를 실 기기 스샷 대용으로 허용하지 않는다. 시뮬레이터 UI 아티팩트가 보이는 이미지를 올리면 리젝된다. 얼핏 보기엔 괜찮은데 자세히 보면 티가 나는 것들이 있어서 함정이다. 이게 이전에 문제가 됐던 것 같고, 오늘 DEBUG 빌드 하네스를 만들었다.

SampleImage.swift에서 실제 사진을 주입하고, CameraScreen.swift에서 SAMPLE 배지를 숨기고, 옵션 시트를 자동으로 여는 launch args를 추가. 이 상태에서 실 기기로 찍은 스샷을 ASC에 올리는 흐름이다.

필터 스트립 스크롤 버그도 오늘 픽스됐다. 앱을 열면 마지막에 선택했던 필터로 복원되는데, 그 필터 칩이 스트립 끝에 있으면 뷰포트 밖에 있어서 안 보이는 문제. CameraScreen.swift에서 시작 시 해당 칩으로 자동 스크롤하도록 수정. 이게 포함된 빌드9로 ASC에 제출했다.

현재 iOS 1.0.2는 READY_FOR_SALE인데 스토어에서 안 보인다. AdMob 연결 대기 상태. 다 됐는데 안 보이는 게 제일 불편한 상태다.

앱 게이트 + AI채팅 — 서버 쪽 두 건

앱 게이트의 /admin·/partner 경로 화이트리스트를 전역에서 도메인별 pathWhitelist 설정으로 바꿨다. apple 도메인에 게이트를 적용할 때 전역 화이트리스트가 충돌을 일으킬 수 있어서. 커밋 순서가 재미있다. 먼저 경로를 추가하는 feat이 있고, 바로 다음 커밋에서 전역 화이트리스트를 철회하는 refactor가 있다. 추가하고 나서 "이 구조가 맞나" 싶어서 바로 뒤집은 것이다. 이런 판단 변경은 빠른 게 낫다.

AI채팅에서는 두 가지를 했다. 대화이력 서버측 트리밍 - 최근 6개 메시지만 유지하고 개별 메시지는 1000자에서 컷. 대화가 길어질수록 API 비용이 선형으로 올라가는 것을 막는 선택이다. Claude API 장애 시 Groq 폴백(gpt-oss-120b)도 추가했다. 이중화다.

targetSdk 36 러시

Play Store 2026-08-31 기한 대응이 오늘 여러 앱에 걸쳐 있었다. AGP 9.0.1/Gradle 9.1 + file_picker 10이 한 앱, Billing 8.0.0 + targetSdk 36 + AGP 8.11.1이 다른 앱, in_app_purchase ^3.3.0이 또 다른 앱. 각각 버전 찍고 제출까지.

Billing 8.0.0은 BillingManager.kt도 손을 댔다. API 변경이 있었다.

기한이 한 달 이상 남아있지만, 미루면 나중에 한꺼번에 몰린다. 어차피 건드린 앱이면 그때 올리는 게 맞다.


오늘 커밋 수를 세면 60개 가까이 된다. 대부분이 test_ad_guard 스윕이어서 숫자가 부풀려 보이지만, 실제 작업 범위는 공통 광고 패키지, 원격설정 백엔드, 새 앱 하나, DSP 릴리즈, 스토어 제출 툴링, 서버 두 건, targetSdk 대응이다.

Canasta가 제일 뿌듯하고, Pilmori의 미노출 상태가 제일 찝찝하다. test_ad_guard 스윕은 지루했는데, 이게 표준화되고 나면 다음 앱부터는 처음부터 올바른 구조로 시작할 수 있다. 이번 같은 20커밋 도배는 다시 없어야 한다.

댓글 0

첫 댓글 달아줘.