개발 slecs

오프라인 가계부 앱을 새벽 한 번에 양대 스토어까지 밀어붙이기

목차

자정이 넘어 커밋 히스토리를 보면 그 시간대의 집중력이 그대로 드러난다. 이날 새벽은 크게 두 축이 동시에 돌아갔다. 하나는 Flutter 가계부 앱 Moneyleaf의 MVP부터 스토어 제출까지의 전 과정, 다른 하나는 GSC 리포트에서 실측된 사이트맵 로케일 절벽 수리였다. 둘 다 "오늘 끝내야 한다"는 압박이 있었고, 결국 해가 뜨기 전에 마무리됐다.


Moneyleaf: MVP에서 리브랜딩까지의 흐름

앱의 첫 커밋 이름은 MoneyDiary MVP였다. sqflite 기반 오프라인 가계부, 월 요약 + 내역 리스트 + 3초 입력 시트. 뼈대만 있는 상태에서 이 새벽에 한꺼번에 살을 붙였다.

순서를 재구성하면 이렇다:

  • 결제수단 / 수입 카테고리 확장 + 캘린더 뷰: 지출 중심이던 구조에 수입 카테고리를 얹고, 일별 합계와 선택일 내역을 캘린더로 볼 수 있게 했다.
  • CSV 백업/복원: 로그인 없이 파일로 내보내기/불러오기. 엑셀 호환 BOM 처리를 붙인 건 실제로 Windows 엑셀에서 깨지는 걸 미리 막기 위해서였다.
  • 광고 풀배치 + IAP: 배너/전면 3회 주기에 광고제거 영구권 IAP를 동시에. 구매 복원도 같이 잡았다.
  • 테마 5종: 리워드 광고 해제 방식으로 테마를 잠금 해제. 필터바와 입력시트 오버플로우 수정도 이 커밋에 묶었다.
  • 고정 내역 자동 기입: 매달 반복되는 항목을 자동으로 적어주는 구조. recurring 모델이 이때 생겼다.
  • 통화 로케일 글로벌화: 기기 로케일 기반으로 ₩/$/¥ 자동 전환, 최소단위 정수 저장.
  • 다국어 8개 (en/ko/ja/zh/es/pt/de/fr): L.t 스윕으로 전 화면 덮었다. 날짜/요일 로케일 자동 처리가 여기 포함됐다.
  • 외화 입력 + 환율 환산: open.er-api 일 1회 캐시, 입력 시트에 통화 선택 칩, 원화 금액 메모 기록.
  • 요약카드 이번 달 남은 고정지출 표시: 홈 화면에서 바로 남은 고정비를 볼 수 있도록.

이 모든 게 한 세션에 커밋으로 찍힌 걸 보면, 실제로는 사전에 로컬에서 쌓아뒀다가 이 새벽에 정리해서 올린 흐름이다. 그리고 어느 시점에 MoneyDiary에서 Moneyleaf로 리브랜딩이 결정됐다. 번들 ID, 표시명, 상품 ID 전환, Lucide leaf 아이콘까지 한 커밋에 묶어서 처리했다.


AdMob 실 ID 연동과 StoreKit 테스트 환경

리브랜딩 이후 바로 AdMob 실 ID 반영으로 넘어갔다. 앱 2개(iOS/Android), 유닛 6개 발급. 코드 분기는 단순하게 kReleaseMode로 처리했다.

// kReleaseMode 분기 예시 패턴
final bannerId = kReleaseMode
    ? 'ca-app-pub-3485xxxx/실제유닛ID'
    : 'ca-app-pub-3419835839156966/6300978111'; // 테스트 ID

디버그 빌드에서 실 ID를 쓰면 정책 위반이 되고, 반대로 릴리즈 빌드에서 테스트 ID를 쓰면 수익이 안 잡힌다. 이 분기가 없으면 배포할 때마다 수작업으로 바꿔야 하니 처음부터 잡아뒀다.

iOS는 시뮬레이터에서 IAP 테스트를 위해 StoreKit 테스트 설정도 따로 추가했다. Configuration.storekit 파일을 Xcode scheme에 연결하면 실제 결제 없이 구매/복원 플로우를 돌려볼 수 있다. 이게 없으면 IAP 테스트할 때마다 TestFlight 빌드를 기다려야 해서 회전이 너무 느려진다.


스토어 파이프라인: Android 먼저, iOS는 ITMS-90683 걸림

스토어 제출 순서는 Android가 먼저였다.

단계 내용
서명 업로드 키스토어 생성, build.gradle.kts에 signing config 반영
Fastlane Appfile + Fastfile 세팅, Play 스토어 메타데이터(ko/en) 작성
IAP 인앱 상품 등록, 설문 완료

Android 파이프라인이 안정되고 나서 iOS로 넘어갔는데 여기서 한 번 막혔다. ITMS-90683이었다. 메시지 요약하면 Info.plistNSPhotoLibraryUsageDescription이 없다는 것. file_pickershare_plus 두 패키지가 내부적으로 포토 라이브러리 권한을 요구하는데, 앱이 직접 선언하지 않으면 App Store Connect가 업로드를 튕겨낸다.

<!-- Info.plist 추가 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>CSV 백업 파일을 저장하거나 불러올  사용됩니다.</string>

이거 하나 추가하고 빌드 번호를 2로 올려서 다시 제출. ASC 앱 생성, IPA 업로드, IAP $3.99 등록까지 마무리됐다.

스토어 제출에 필요한 스크린샷 세트(KR/EN 각 5장)도 이 새벽에 정리했다. store-assets/ko, store-assets/en 폴더에 프로모 이미지 넣고 구시안 중복 파일 하나 제거.

마케팅 랜딩 페이지(moneyleaf.hedvion.com)도 배포 완료 처리. 온보딩 스코프는 일단 결정만 해두고 구현은 뒤로 미뤘다.


사이트맵 로케일 절벽: GSC 리포트에서 실측으로 잡은 문제

앱 작업과 병렬로 돌아간 게 SEO 쪽이었다. GSC 자율감지 스크립트(gsc_allsites_report.py)에 노출절벽 감지와 다국어 사이트맵 로케일 누락 감지 두 가지를 추가했다. 이게 돌아가면서 실제로 문제가 잡혔다.

원인은 사이트맵이었다. 다국어 사이트(8개 로케일)에서 각 페이지의 로케일별 URL이 사이트맵에 개별 <url> 엔트리로 들어가지 않고 있었다. 홈과 일부 페이지만 로케일 URL을 포함하고, 나머지 전 페이지는 기본 URL 하나만 노출되던 상태.

<!-- 수정 전: 하나의 <url>에 hreflang 묶음 -->
<url>
  <loc>https://site.com/page</loc>
  <xhtml:link rel="alternate" hreflang="ko" href="https://site.com/ko/page"/>
  ...
</url>

<!-- 수정 후: 로케일별 개별 <url> 엔트리 -->
<url><loc>https://site.com/ko/page</loc>...</url>
<url><loc>https://site.com/en/page</loc>...</url>
...

사이트맵 자체도 단일 파일에서 언어별 인덱스 분할 방식으로 바꿨다. server.mjs에서 언어별 서브 사이트맵을 생성하고 인덱스가 이걸 참조하는 구조. 이렇게 하면 크롤러가 각 언어 콘텐츠를 별도로 처리할 수 있고, 특정 로케일 사이트맵만 교체할 때도 다른 언어에 영향이 없다.

/how-many-seconds-in-a-year 실수요 랜딩 페이지도 이 시간에 추가됐다. 라이브 카운터 + FAQ 스키마 + 환산표 구성. 홈 타이틀 키워드 보강, 사이트맵/푸터 링크 연결까지. --text2 명암비 문제(#7a7468, 4.26)도 이 시간에 잡았다. WCAG AA 기준(4.5)에 미달이어서 #8d8679로 올려 5.48을 맞췄다.


ssul 이미지 리호스팅 제거

publish_crawl.py에서 원문 이미지를 가져다 리호스팅하는 코드가 있었는데, 이걸 제거하고 텍스트만 발행하도록 바꿨다. 이미지 발행 금지 정책 정비 차원이었다. 이걸 CLAUDE.md에도 기록해뒀다. 이런 정책 결정은 운영하다 보면 흐지부지되기 쉬워서, 명시적으로 남겨두지 않으면 나중에 누가 또 비슷한 코드를 붙이게 된다.


새벽 6시간 동안 앱 하나가 MVP에서 양대 스토어 제출 상태까지 갔고, 사이트맵 구조가 바뀌었고, 크롤링 스크립트에 자율감지 기능이 붙었다. 작업량만 보면 며칠치가 압축된 느낌인데, 실제로는 각 작업이 서로 블로킹 없이 돌아갈 수 있어서 가능했다. 앱 빌드 기다리는 동안 SEO 코드 고치고, IPA 업로드 돌리는 동안 사이트맵 구조 잡는 식으로. 병렬로 굴리는 게 이 시간대를 버티는 방식이었다.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.