일기 slecs

앱 열두 개 챙기는 날은 끝이 없다

목차

오늘은 숫자부터 보자. 커밋이 아흔 개가 넘는다. 프로젝트가 하나였으면 못 믿었을 텐데, 오늘은 Daily Bible, filmdev, RedDose, CamberPro, launch-notify 봇, 포트폴리오 사이트, hwpconv 서버, ASC 파이프라인 스크립트, 그리고 거기에 딸린 온갖 iOS/Android 빌드까지 전부 건드렸다. 팀이 크면 나눠서 했겠지만 아니니까. 오늘은 그냥 그런 날이었다.

그 중에서 가장 많은 커밋을 잡아먹은 게 뭔지 알면 웃을 것 같다. Apple이 ageRatingDeclarationsocialMediasocialMediaAgeRestricted 문항 두 개를 조용히 추가한 거다. API 문서에도 제대로 공지가 없었다. 어느 앱 PATCH 요청이 터지고 나서야 알았다. 처음엔 단순 fix였다 - 두 필드 추가하면 되겠지. 근데 문제는 이게 모든 앱의 모든 스토어 파이프라인 스크립트에 다 적용돼야 한다는 거였다. asc_appinfo.py, asc_meta.py, asc_finish.py, asc_new_version.py... 각 앱마다 스크립트가 분산돼 있고 구조도 조금씩 달라서 그냥 일괄 패치가 안 됐다.

한 파일 고치고 fix 커밋 찍고, 그 직후에 feat 커밋 하나 더 - PATCH 전에 실제 스키마를 GET으로 가져와서 누락된 문항을 자동 병합하는 로직을 붙였다. 이걸 앱별로 전부 반복했다. git log 보면 같은 제목의 fix+feat 쌍이 여섯 번 이상 반복된다. 솔직히 찝찝하다. "왜 처음부터 공통 모듈로 뽑아두지 않았나"는 지금 봐도 할 말이 없다. 스크립트가 앱마다 조금씩 달리 진화해왔고, 리팩토링할 여유를 미뤄온 결과다. 오늘 그 대가를 제대로 치렀다. 언젠가는 공통 라이브러리로 통합해야 한다고 CLAUDE.md에 적어두고 싶었지만, 또 미뤘다.

Threads가 계정을 정지시켰다

launch-notify 작업이 제일 소란스러웠다. 처음에는 간단해 보였다. App Store Connect에서 앱 상태가 READY_FOR_SALE로 전환되는 걸 감지하면 Threads에 출시 알림을 올리는 봇. 이걸 check_launches.py로 구현하고, 하루 한 번씩 라이브 앱 홍보 포스트를 올리는 promo_daily.py도 붙였다.

그런데 Threads가 계정을 정지해버렸다. 기록 남기려고 CLAUDE.md에 "Threads 정지 사고"라고 명시적으로 적었다. 발행 페이스가 문제였을 거다. 봇이 너무 빠르게, 너무 규칙적으로 올렸을 가능성이 높다. 그래서 이날부터 발행 페이스 제한 룰을 명문화해뒀다.

복구하고 나서 두 번째 벽이 Threads 500자 캡이었다. 제대로 된 앱 홍보 문구를 쓰면 500자가 거뜬히 넘는다. 처음 대응은 단순했다 - 380자 압축 프롬프트에 길이 가드를 달았다. 근데 이건 마음에 안 들었다. 압축한 글은 티가 난다. 문장이 어색하게 끊기거나 정보가 날아가거나.

결국 선택한 방식은 체인 발행이다. 본문 포스트를 먼저 올리고, 해시태그를 답글로 달고, 앱 링크는 체인 맨 끝에 붙인다. 이렇게 하면 500자 캡을 자연스럽게 우회하면서 본문의 품질도 지킬 수 있다. 노출 최적화 측면에서도 링크가 본문에 있을 때보다 체인 끝에 있는 게 낫다고 판단했다. 구현하고 나서 체인 순서를 한 번 더 바꿨다 - 처음엔 순서가 틀렸었다.

언어 설정도 세 번 바뀌었다. 처음엔 앱별로 자동 판별(한글 앱이면 ko, 해외판이면 en). 그다음엔 사용자 요청으로 전 포스트 영어 고정. 그 사이에 ko/en 번갈아 발행하는 버전도 있었다. 커밋이 그대로 증거다. 요구사항이 명확하지 않은 채로 구현을 먼저 했을 때 생기는 전형적인 지그재그. 최종은 영어 고정.

해시태그 이스케이프 버그도 하나 잡았다. 첫 번째 해시태그가 #으로 시작하면 Threads가 이걸 토픽 태그로 흡수해버려서 독립된 포스트 내용처럼 보이지 않는 문제가 있었다. ##으로 이스케이프해서 막았다.

iOS가 오늘따라 까다로웠다

ITMS-90683. 이 에러코드는 이제 외운다. iOS 앱이 카메라나 위치 정보를 쓰면서 Info.plist에 purpose string을 빠뜨리면 App Store 업로드가 튕겨나온다. 오늘 두 앱에서 이 에러가 났다 - 하나는 빌드 넘버 +7로 픽스, 다른 하나는 +8. 같이 넣은 게 ITSAppUsesNonExemptEncryption=false다. 암호화 면제 선언. 이걸 안 넣으면 심사마다 "암호화 사용하냐"는 질문에 수동으로 체크해줘야 하는데, 대부분의 앱은 해당 없으니 그냥 false로 박아두는 게 맞다. 자잘하지만 계속 빠뜨리는 항목이라 이번에 확실히 짚고 넘어갔다.

google_mobile_ads가 5.2에서 9.0으로 올라갔다. 메이저 버전 4개를 한 번에 뛰어넘은 거다. 이게 단순 버전 bump가 아니라 Android 툴체인 전체를 같이 올려야 했다. SDK 36, AGP 8.11.1, Kotlin 2.2.20, Gradle 8.14. 각각 의존성 요구사항이 맞아야 해서 하나씩 올리다 보면 다른 게 깨지는 패턴. 그 결과가 build.gradle.kts, gradle-wrapper.properties, settings.gradle.kts, Podfile.lock, pubspec.lock, pubspec.yaml 전부 건드리는 커밋. iOS 쪽은 Podfile.lock까지 갱신해줬다. 이 업그레이드를 미뤘다가 AdMob 정책이 SDK 버전 요구사항 올리면 광고 수익이 끊기니까 어차피 해야 하는 일이었다.

filmdev 풀 출시

filmdev가 오늘 드디어 심사 제출까지 들어갔다. App Store Connect에 앱 등록(앱 ID 6792327784)하고, IAP filmdev_pro 만들고, 법적 텍스트 시드하고, 8개 로케일 스토어 스크린샷 찍고 프레임 씌우고 업로드하고, 연령등급 설정하고, 가격/가용 국가 설정하고, 마지막으로 asc_submit.py로 제출. 둘 다 WAITING_FOR_REVIEW.

근데 한 번 더 뒤집어졌다. IAP 리뷰 스크린샷이 불량이었다. 페이월 화면이 제대로 안 잡혔다. 심사 중에는 DELETE가 409 에러로 막히니까 먼저 취소 선행하고, lib/paywall_shot_harness.dart로 페이월 재캡처하고, asc_resubmit.py로 재제출. 이 플로우 자체를 모르면 막히는 지점인데, 이제 문서화해뒀으니 다음엔 덜 헤맬 거다.

USA 가용성 버그도 하나 잡았다. asc_price_avail.py가 attr을 빈 딕셔너리로 PATCH하면서 USA available이 false로 떨어지는 버그. available: true를 명시적으로 박아줘야 한다. 발견했을 때 175개국 중 USA만 빠져있었다. 이게 실제 판매 영향이 있기 전에 잡아서 다행이다.

앱 아이콘도 두 번 갔다. 첫 번째 아이콘 올리고, 필름 릴 타이머 다이얼 컨셉으로 v2 교체. filmdev라는 이름에 맞게. ANR 버그도 픽스했다 - 메인 스레드에서 플랫폼 채널을 블로킹하고 있던 문제, 그리고 스테이지 시트 Done 핸들러 누락.

Daily Bible, 두 언어에서 여덟으로

Daily Bible 백엔드가 오늘 크게 바뀌었다. 기존 한국어+영어 2개 언어에서 스페인어, 포르투갈어, 독일어, 프랑스어, 중국어, 러시아어까지 더해서 8개 언어 지원. 단순히 번역 추가가 아니라 DB 스키마에 book_names_i18n 컬럼을 각 언어별로 추가하고, 매퍼 XML을 수정하고, DailyPickCatalog 서비스 전반을 손댔다.

daily_pick 자동 시딩도 붙였다. 매일 아침 30일치 앞을 미리 채워두는 스케줄러. 처음엔 쿼리가 너무 관대해서 애가(哀歌) 같은 우울한 구절이 올라오거나 테마와 안 맞는 verse가 pick되는 문제가 있었다. 그래서 큐레이션된 184절 allowlist를 만들었다. 184개 - 전체 구절에서 품질 기준으로 걸러낸 숫자다.

8개 언어 전부에서 resolve되는 구절만 daily_pick에 들어가야 한다는 제약도 생겼다. 언어 하나라도 번역이 없으면 그 날 어떤 언어 사용자는 오류를 보게 된다. 그래서 실제로 모든 8개 언어에서 resolve 가능한 서브셋 176절을 기준으로 좁혔다. CLAUDE.md에 176/184라고 수치까지 박아뒀다.

앱 쪽은 verse share 카드, 읽기 스트릭, AdMob 9.0 업그레이드가 한꺼번에 들어갔다. 스트릭은 DST 안전하게 처리하는 게 포인트 - 서머타임 전환일에 날짜 계산이 틀어지면 스트릭이 깨지는 버그가 생기기 때문. share 쪽은 mounted guard - 위젯이 unmount된 후에 setState가 불리는 오류 방지.

이 앱은 8개 로케일 스토어 제출까지 오늘 마쳤다. Noto 폰트 스택도 붙였다 - 중국어와 다국어 문자가 제대로 렌더링되려면 OS 기본 폰트만으론 부족한 경우가 있어서.

두 앱을 동시에 스캐폴딩

RedDose와 CamberPro, 두 앱이 오늘 각자 첫 커밋을 찍었다. 둘 다 기존 앱에서 포크한 scaffold - RedDose는 camberpro에서, CamberPro는 camperlevel에서. 이 방식이 효율적인 게, 프로젝트 구조, l10n 설정, AdMob 배선, 빌드 스크립트 같은 보일러플레이트를 매번 처음부터 세팅하지 않아도 된다.

RedDose는 RLT(Red Light Therapy) 세션 타이머 앱 도메인으로 재작성. 글로우 링, 디바이스 프로파일, 신체 부위별 세션, 경과 사진 기능이 핵심. reviewer pass에서 사진 Pro gate(무료 5장/부위 제한), 삭제 확인 다이얼로그, WCAG 색상 대비, 이미지 다운스케일 캐싱, 타이머 리스타트 리셋, 권한 오류 UX, pulse gating까지 한 번에 정리했다. 스크린샷 데모 사진도 실제 기기 사용 느낌으로 교체. start/pause 액션 바가 스크롤 아래로 숨어버리는 버그는 인테그레이션 테스트가 잡아줬다 - 이건 사람이 보지 않았으면 심사에서 걸렸을 거다.

CamberPro는 캠버 게이지 앱. 다이얼 게이지, 플립 캘리브레이션, 바퀴별 세션이 핵심 도메인. l10n 8개 로케일, 런처 아이콘, AdMob IDs, 법적 텍스트까지. 스토어 파이프라인도 바로 붙였다.

포트폴리오 사이트에 앱 열두 개

index.html 커밋 세 개. 신규 앱 카드를 12개 추가했다. resono, holdon, snorewise, hwpviewer, quitcaffeine, unbitten, vibrascope, camperlevel 8개를 한 배치에, 그다음 fastleaf, pdfmate, filmdev, harucam 4개를 또 한 배치에, 그리고 TapPilot. 각각 썸네일 이미지도 같이 올라갔다. 라이브 전환된 앱 5개는 스토어 링크도 붙이고, 심사 상태 라벨도 실측으로 정정했다.

포트폴리오 관리가 사실 제일 신경 쓰이는 부분 중 하나다. 심사 중인지 라이브인지 라벨이 틀려있으면 외부에서 봤을 때 신뢰 문제가 된다. 그래서 상태 바꿀 때마다 사이트도 같이 업데이트하는 습관을 들이고 있는데, 오늘은 그게 제법 큰 배치였다.

hwpconv /healthz

hwpconv 서버에 /healthz 엔드포인트 추가. 무인증, 단순 라이브니스 프로브. uptime 모니터링 서비스에 물리기 위한 것. 버전 1.2.2. 작은 커밋이지만 운영 측면에서 중요하다 - 지금까지 서버가 조용히 죽어있어도 모르는 경우가 생길 수 있었다. hedvion-CLAUDE.md에 API 서버 온보딩 규칙도 명문화했다. uptime 감시, 키 백업, SSOT 세 가지.


하루 끝에 뭐가 남았나 보면, 출시 파이프라인이 많이 성숙해졌다. 한 앱 출시할 때 쓴 스크립트가 다음 앱에서 재활용되고, 거기서 발견한 버그(USA availability, IAP 리뷰 스크린샷 교체 플로우, ageRating 신설 문항)가 문서화되고 자동화된다. socialMedia 문항 사태도 결국은 "PATCH 전에 실스키마 GET해서 merge"라는 패턴이 정착된 계기였다.

다만 스크립트가 앱별로 분산된 건 여전히 기술 부채다. 오늘 같은 Apple 스키마 변경이 다음에 또 오면 또 같은 방식으로 앱 개수만큼 반복할 거다. 언제 한 번 공통 라이브러리로 뽑아야 하는데, 그 "언제"가 계속 밀리고 있다.

psy 자동 테스트 세 개는 스케줄러가 알아서 찍어준 것이고, 오늘 하루의 마지막 줄은 그게 됐다.

댓글 0

첫 댓글 달아줘.