디버그 빌드에서는 홈, 입력, 통계, 백업까지 전부 정상이었다. 그런데 release AAB로 뽑아서 실제 기기에 올리자 스플래시 직후에 앱이 사라졌다. 코드 변경은 cargo-log/lib/core/app_config.dart에서 AdMob 테스트 광고 ID를 실제 ID로 바꾼 것(+17/-11)과 AndroidManifest.xml 두 줄(+2/-3)이 전부였다. 기능 커밋이 아니라 문자열 교체 커밋 하나에서 릴리스만 죽은 셈이다.

오늘 하루 커밋은 44개, 변경량은 +35523/-1367줄이었다. 그중 마지막 커밋 1f5dbd7의 diff는 cargo-log/android/app/build.gradle.kts 한 파일, +4/-1줄이다. 네 줄을 찾는 데 걸린 시간이 앱 하나를 M1부터 M6까지 만드는 시간과 비슷했다.

release 빌드만 죽은 이유

증상부터 정리하면 이렇다.

구분debugrelease
앱 실행정상스플래시 직후 종료
배너 광고테스트 광고 표시확인 불가(그 전에 종료)
코드 차이없음없음
빌드 파이프라인 차이축소/난독화 없음R8 축소 동작

코드가 같은데 결과가 다르면 범인은 코드가 아니라 빌드 파이프라인이다. Flutter 안드로이드 릴리스 빌드는 R8이 붙어서 참조되지 않아 보이는 클래스를 지운다. 문제는 google_mobile_adsandroidx.work처럼 리플렉션이나 매니페스트 등록으로만 참조되는 클래스다. 정적 분석 입장에서는 아무도 안 부르는 코드라 지워도 되는 것처럼 보이고, 실제로 지워지면 런타임에 초기화 단계에서 바로 넘어진다.

선택지는 두 가지였다. 하나는 keep 규칙을 하나씩 써서 필요한 클래스만 살리는 방법, 다른 하나는 축소 자체를 끄는 방법이다.

// android/app/build.gradle.kts — release 블록
buildTypes {
    release {
        // R8이 google_mobile_ads / androidx.work 클래스를 스트립해서
        // 릴리스에서만 죽는 문제. 우선 축소를 끈다.
        isMinifyEnabled = false
        isShrinkResources = false
    }
}

이 네 줄을 넣자 release AAB가 정상 실행됐다. 대신 APK 크기 이득을 포기했다. 지금 단계에서는 “출시할 수 있는 앱"이 “작은 앱"보다 먼저라고 판단했다. proguard keep 규칙은 어떤 클래스가 왜 필요한지 정확히 알아야 최소한으로 쓸 수 있는데, 그걸 알아내려면 다시 릴리스 빌드를 반복해야 한다. 축소를 끄는 쪽이 원인과 결과가 명확하고 되돌리기도 쉽다.

테스트 광고 ID에서 실제 ID로 바꾼 커밋이 방아쇠였다

크래시가 처음 보인 시점은 28f41b8, AdMob 실제 광고 ID를 넣은 직후다. 그래서 처음에는 광고 ID를 의심했다. 잘못된 앱 ID를 매니페스트에 넣으면 SDK 초기화에서 죽는 사례가 흔하기 때문이다.

의심을 접은 근거는 debug 빌드였다. 같은 실제 ID로 debug를 돌리면 앱이 죽지 않았다. ID가 틀렸다면 빌드 타입과 무관하게 죽어야 한다. 그러니 ID 자체가 아니라 “실제 ID를 넣은 뒤 처음으로 release 빌드를 뽑아봤다"는 사실이 진짜 순서였다. 광고 ID 교체가 원인이 아니라, 그 커밋이 릴리스 빌드를 처음 실행하게 만든 계기였을 뿐이다.

이 구분을 못 했으면 app_config.dart를 계속 들여다봤을 것이다. 증상이 나타난 커밋과 원인을 만든 커밋이 다르다는 건 자주 겪는 함정이다.

화물기사 장부앱을 M1에서 M6까지

cargo-log는 오늘 처음 만들어진 프로젝트다. 커밋 순서대로 M1부터 M6까지 진행했다.

Flutter 화물 장부앱 하루 만에 M1~ 흐름도

각 단계에서 무엇을 먼저 세웠는지가 중요했다.

M1 (c8b3068) 은 뼈대다. .gitignore(+45), analysis_options.yaml(+28), android/app/build.gradle.kts(+45), AndroidManifest.xml(+45)까지 프로젝트 설정과 화물 데이터모델, DB, 계산 로직을 한 번에 깔았다. 화면은 아직 없다.

M2 (a8ab92a) 에서 홈과 운송·지출 입력을 붙였다. 커밋 메시지에 적어둔 판단이 이 앱의 성격을 결정한다. “GPS 없이 장부로 성립”. 즉 GPS 권한을 거부한 사용자도 앱을 그냥 장부로 쓸 수 있어야 한다는 뜻이다. lib/app.dart(+81), lib/app_providers.dart(+67), lib/core/theme.dart(+45), lib/data/repositories/client_repository.dart(+32)가 이때 들어갔다. 브랜드명도 여기서 “운행로그"로 정했다.

M3 (0f05bbb) 에서 GPS를 얹었다. lib/data/models/trip.dart가 +140줄, lib/data/repositories/trip_repository.dart가 +119줄이다. 상차와 하차 지점을 경로로 잇고 타임라인에 표시하는데, 지도는 처음부터 OSM으로 갔다.

M4 (a08b542) 는 통계와 종합소득세 경비 리포트다. lib/features/stats/stats_screen.dart(+237), stats_providers.dart(+95). 화물·용달 기사에게 필요한 건 “이번 달 얼마 벌었나"보다 “수수료랑 유류비 빼고 실제로 손에 남은 게 얼마고, 세금 낼 때 경비로 뭘 넣을 수 있나"다. 출처별 실수취를 따로 뽑는 이유가 그것이다.

M5 (381f577) 에서 온보딩(onboarding_screen.dart +62), 설정(settings_screen.dart +219), 백업(backup_service.dart +128)을 붙여 앱 형태를 완성했다. test/ledger_csv_test.dart(+28)로 CSV 내보내기를 테스트로 묶었다.

M6 (b6c10e0) 이 수익화 배선이다. lib/features/ads/banner_ad.dart(+63), lib/core/app_config.dart(+18), pubspec.lock(+40). 그리고 이 배선이 위에서 말한 R8 문제로 이어졌다.

M1부터 M6까지 순서를 이렇게 잡은 이유는 하나다. 앱이 매 단계 끝에서 실행 가능한 상태로 남게 하려고 했다. M2가 끝난 시점에 이미 “GPS 없는 수동 장부앱"으로 쓸 수 있고, M4가 끝난 시점에 “통계까지 되는 장부앱"이 된다. 중간에 멈춰도 버릴 게 없다.

GPS 코드를 앱 밖으로 뺀 이유

d74510c 커밋에서 packages/core_gps라는 별도 패키지를 만들었다. lib/src/trip_tracker.dart(+83), lib/src/geo.dart(+18), lib/core_gps.dart(+5), 그리고 test/core_gps_test.dart(+77)다. 테스트가 구현부보다 긴 구조다.

동기는 단순하다. rider-log(배달 라이더)와 cargo-log(화물·용달)는 화면과 용어가 다르지만, “위치를 주기적으로 받아서 이동 거리를 누적한다"는 부분은 똑같다. 이 계산을 각 앱에 복사해 두면 좌표 필터링 기준을 한 번 바꿀 때마다 두 군데를 고쳐야 한다.

거리 계산과 트립 누적은 부작용이 없는 순수 계산이라 테스트하기 쉽다. 그래서 이 부분만 떼어내면 앱 UI를 띄우지 않고도 검증할 수 있다. core_gps_test.dart가 +77줄이 된 게 그 결과다. 반대로 권한 요청이나 백그라운드 실행처럼 플랫폼에 붙는 부분은 앱 쪽에 남겼다. 그건 패키지로 빼도 테스트가 쉬워지지 않는다.

유가 API가 죽어도 앱은 살아 있어야 한다

rider-log 쪽 오늘 작업 중 제일 손이 많이 간 건 유가 연동이다.

3a929bd에서 오피넷 유가 자동 연동을 넣었다. lib/features/fuel/opinet_service.dart(+73), test/opinet_service_test.dart(+32), lib/core/app_config.dart(+7). 커밋 메시지에 “그레이스풀 폴백"이라고 적어둔 게 핵심이다. 외부 API는 언제든 응답이 없거나 형식이 바뀐다. 그때 앱이 예외를 던지고 죽으면 사용자는 유가와 아무 상관 없는 화면에서 앱이 종료되는 경험을 한다.

그래서 3381a66에서 한 겹을 더 씌웠다. lib/features/fuel/fuel_price_updater.dart(+25)와 test/fuel_price_updater_test.dart(+62)를 추가하고, 갱신 실패 시 마지막으로 받아온 값을 그대로 유지하도록 했다. 갱신 시각도 함께 저장한다. 여기서도 테스트가 구현보다 길다. 실패 경로가 성공 경로보다 종류가 많기 때문이다.

상황처리
API 정상 응답새 유가 저장 + 갱신 시각 갱신
네트워크 실패마지막 값 유지, 화면 그대로
응답 형식 이상마지막 값 유지
저장된 값도 없음사용자가 설정에서 직접 입력

7ec6754에서는 앱 시작 시 유가를 fire-and-forget으로 갱신하게 했다. lib/main.dart +11/-1줄. 갱신 결과를 기다리지 않고 화면을 먼저 띄운다. 유가 하나 때문에 첫 화면이 늦게 뜨는 건 손해가 명백하다.

에러 문구를 사람 말로 바꿨다

ad3d8f4는 “오류방지 감사"라는 이름을 붙인 커밋이다. lib/core/friendly_error.dart(+36)를 만들고, 입력 화면, 설정 화면, 통계 화면, 타임라인, 월 캘린더에 각각 한두 줄씩 연결했다.

바꾼 건 두 가지다. 첫째, 예외 메시지를 그대로 토스트에 띄우던 자리를 사람이 읽을 수 있는 문장으로 교체했다. 둘째, 숫자 파싱을 하드닝했다. 사용자가 금액 칸에 쉼표나 공백을 섞어 넣는 건 예외 상황이 아니라 기본값에 가깝다.

이 앱의 사용자는 배달과 화물 일을 하는 기사다. 화면에 알 수 없는 영문 예외가 뜨면 앱을 지운다. 앱이 죽지 않는 것과 사용자가 당황하지 않는 것은 다른 문제다. 전자는 try-catch로 끝나지만 후자는 문구까지 손봐야 한다.

OCR 파서를 미션과 운행거리까지

f61a112에서 lib/features/ocr/receipt_parser.dart를 +71/-3줄 고치고 test/receipt_parser_test.dart(+27)를 늘렸다. 배달 앱 정산 화면 스크린샷에서 금액만 뽑던 것을, 미션 수행 여부와 플랫폼 종류, 운행거리까지 뽑도록 확장했다.

플랫폼 감지가 들어간 이유는 그 다음 커밋을 보면 드러난다. 8e6e336에서 입력폼을 개편하면서(input_screen.dart +78/-11) 플랫폼을 자동 선택하고 자동 지출과 연동했다. 스크린샷 한 장을 넣으면 플랫폼, 금액, 거리, 미션이 자동으로 채워지고, 거리로부터 기름값이 자동 계산되는 흐름이다.

자동 지출 계산은 d1733b0에 있다. lib/features/expense/auto_expense_service.dart(+69), test/auto_expense_test.dart(+95), lib/features/tax/tax_estimator.dart(+16/-3), lib/core/settings_store.dart(+30). 기름값과 식비를 자동으로 잡아 세금 추정까지 밀어 넣는다. 여기도 테스트가 구현보다 길다. 세금 계산은 틀렸을 때 사용자가 확인할 방법이 없어서 테스트로 고정해두는 편이 낫다.

홈 화면을 순수익 하나로 정리

4118b44에서 홈 화면을 갈아엎었다. lib/app_providers.dart(+95)와 화면 파일(+166/-35)이 함께 움직였다. 바꾼 방향은 이렇다.

  • 헤드라인을 매출이 아니라 월 순수익으로 바꿨다. 기사에게 의미 있는 숫자는 들어온 돈이 아니라 남은 돈이다.
  • 진도 배지를 넣었다. 이번 달 목표 대비 어디까지 왔는지를 숫자 대신 배지로 보여준다.
  • 지난달 같은 시점과 비교를 붙였다. 절대값보다 방향이 더 빨리 읽힌다.

4e8a548에서는 월 캘린더에 수익 히트맵을 넣었다. lib/features/timeline/month_calendar.dart +14/-1줄. 많이 번 날일수록 셀 색이 진해진다. 14줄짜리 변경인데 화면 인상이 제일 크게 바뀐 작업이었다. 숫자를 읽지 않아도 이번 달 리듬이 보인다.

10d406b에서 장부 CSV 내보내기를 붙였다(backup_service.dart +63, settings_screen.dart +7/-1, test/ledger_csv_test.dart +34). 세무 자료를 요구받았을 때 엑셀로 넘길 수 있어야 한다는 요구를 그대로 반영했다.

7338838에서는 설정 화면에 자동입력과 목표 섹션을 추가했다(+160). 자동 계산이 늘어날수록 “무엇을 자동으로 할지” 끄고 켜는 자리가 필요해진다.

지도를 OSM으로 바꾸고 릴리스 준비물을 만들었다

73d5353은 rider-log 쪽 큰 정리 커밋이다. 크래시 수정, 지도 OSM 전환, 미션 필드 추가와 DB 마이그레이션이 한 번에 들어갔다. android/app/build.gradle.kts(+13/-1), AndroidManifest.xml(+1/-5), 런처 아이콘 이미지 여러 해상도가 함께 바뀌었다.

지도를 OSM으로 정한 이유는 비용과 키 관리다. 상용 지도 SDK는 API 키를 앱에 넣고 사용량에 따라 과금된다. 개인 부업 앱에서 사용량이 얼마나 튈지 모르는 상태로 과금 API를 넣는 건 위험이 크다. OSM은 키 없이 붙고, 나중에 타일 서버를 바꿔도 코드 구조가 그대로다.

같은 커밋에 문서 두 개가 들어갔다. CLOSED-TESTING-GUIDE.md(+63)와 RELEASE-CHECKLIST.md(+66)다. 스토어 비공개 테스트를 돌리려면 매번 같은 절차를 밟아야 하는데, 그걸 머리로 기억하면 반드시 빠뜨린다. 릴리스 AAB 빌드 검증까지 마친 스냅샷을 d74510c에 남겼다.

개발일지를 사람 없이 발행하는 파이프라인

df8a47a에서 dev-blog 프로젝트를 git에 편입했다. 이 글 자체를 만드는 파이프라인이다.

주요 파일은 autoblog/collector.py(+408), autoblog/coordinator.py(+114), autoblog/config.py(+92), README.md(+164), CLAUDE.md(+45)다. assets/mermaid.min.js(+2024)도 함께 들어갔는데, 티스토리는 mermaid 코드블록을 렌더하지 못해서 다이어그램을 PNG로 미리 그려 넣어야 하기 때문이다.

Flutter 화물 장부앱 하루 만에 M1~ 흐름도 2

수집기가 가장 큰 이유는 입력이 지저분해서다. 여러 저장소의 커밋을 날짜로 모으고, 파일별 증감을 세고, 자동 커밋과 사람 커밋을 구분하고, diff에서 본문에 쓸 만한 조각만 남긴다. 여기서 한 번 잘못 걸러내면 뒤 단계가 전부 헛돈다.

무인 발행에서 제일 무서운 건 개인정보다. 사람 검수가 없으니 마스킹이 뚫리면 그대로 공개된다. 그래서 발행 직전에 누출 검사를 두고, 한 건이라도 걸리면 push를 중단하는 fail-closed 구조로 잡았다. 통과해야 나가는 게 아니라, 확신이 없으면 멈추는 쪽이 기본값이다.

정규식 마스킹의 한계도 그대로 인정했다. 라벨 없이 등장하는 한국어 실명은 패턴으로 못 잡는다. 그래서 실명 같은 항목은 설정의 차단 목록에 직접 넣어두는 방식으로 처리한다. 자동으로 잡히는 척하는 것보다, 못 잡는다고 적어두고 사람이 목록을 채우는 편이 안전하다.

폰 버튼으로 승인하는 워커

5547ccd에서 원격 승인 파이프라인 2단계 워커를 만들었다. remote-approval/mailbox.py(+142), config.py(+56), actions.py(+47), integrity.py(+17), README.md(+59), 설계 문서 REMOTE-APPROVAL-DESIGN.md(+104), 그리고 승인 대기 원장 APPROVALS-PENDING.md(+63)다.

문제 상황은 이랬다. 자동화가 밤에 돌다가 사람 승인이 필요한 지점에 걸리면, 아침까지 아무것도 진행되지 않는다. 그렇다고 폰에서 임의 명령을 실행할 수 있게 열어두면 승인 게이트를 만든 의미가 없어진다.

절충안이 메일박스 구조다. 폰에서는 미리 정의된 승인 항목에 “예/아니오"만 남긴다. 실행은 로컬 워커가 한다. actions.py가 47줄로 짧은 게 핵심인데, 워커가 할 수 있는 행동을 좁게 고정해뒀기 때문이다. integrity.py는 17줄짜리 무결성 확인이다. 승인 응답이 중간에 바뀌지 않았는지만 본다.

APPROVALS-PENDING.md를 따로 둔 이유는 사각지대 때문이다. 대화 중에 “이건 나중에 결정하자"로 미룬 항목은 자동 점검 스크립트가 잡지 못한다. 미루는 순간 파일에 한 줄 적어두면, 아침 보고가 열린 항목을 다시 올려준다.

자동화를 감시하는 자동화

b1341ef14d1f6c에서 운영 스크립트가 늘었다. scripts/backup_bundle.ps1(+235), scripts/automation_health.ps1(+206), scripts/RESTORE.md(+159), scripts/adsense_gate_check.ps1(+116), scripts/notify_telegram.ps1(+33), scripts/daily_report_run.ps1(+38/-17), scripts/content_harvest_run.ps1(+28)이다.

백업은 폴더 원본을 클라우드에 그대로 올리는 방식을 쓰지 않는다. 저장소에는 설정 파일과 토큰 흔적이 섞여 있어서, 폴더 통째 동기화는 시크릿을 그대로 외부에 올리는 것과 같다. 대신 git 번들로 묶어서 올린다. 커밋 이력이 그대로 보존되고, 무엇이 들어가는지 목록이 명확하다.

RESTORE.md가 159줄인 이유는 백업보다 복구가 어렵기 때문이다. 백업 스크립트는 매번 돌아가니 고장 나면 바로 안다. 복구 절차는 실제로 필요해지는 순간이 처음이라, 그때 문서가 부실하면 백업이 있어도 못 쓴다.

automation_health.ps1은 등록된 자동화가 실제로 돌았는지 확인한다. 스케줄러에 등록만 해두고 조용히 실패하는 상황이 제일 위험하다. 실패 알림이 없으면 성공한 것처럼 보인다.

에이전트 정의를 매달 감사하기로

d25fef7에서 AGENT-AUDIT.md(+42)를 만들었다. 하위 프로젝트 전체에 흩어진 에이전트 정의가 약 45개, 스킬이 약 13개까지 늘어난 상태다.

여기서 한 판단은 “상시 감시 자동화를 만들지 않는다"였다. 근거는 로그가 없다는 사실이다. 서브에이전트 호출을 기록하는 로그가 없으니 활동성을 실시간으로 측정할 방법이 없다. 측정할 수 없는 대상에 상시 자동화를 붙이면, 돌아가는 척만 하는 스크립트가 하나 더 늘어난다. 로스터가 월 단위로만 바뀌니 월 1회 점검이 적정하다고 봤다. 그리고 존폐 판단은 추천까지만 하고 삭제는 사람이 확정하도록 못 박았다.

같은 날 0cf67ee에서 에이전트 이름 충돌을 정리했다. 카드뉴스 프로젝트의 에이전트 이름이 coin-card-designer에서 card-designer로 바뀌었다.

 ---
-name: coin-card-designer
+name: card-designer
 description: data/daily_data.json을 templates/*.html에 주입해 output/page1~4.png를 생성하는 에이전트.
 ---

이름 규칙은 이렇다. 상위 폴더의 정의는 하위 프로젝트가 자동으로 상속받고, 같은 이름이 여러 층에 있으면 작업 디렉토리에 가까운 정의가 이긴다. 그래서 프로젝트별로 동작을 다르게 하고 싶으면 이름을 같게 두고 가까운 곳에 덮어쓰면 되고, 전역에서 구분하고 싶으면 이름을 다르게 둬야 한다. 이번 변경은 프로젝트 폴더 안에서 같은 역할을 가리키므로 접두어를 뗐다.

1367줄을 지운 쪽

오늘 삭제된 1367줄 중 상당 부분은 문서 정리다.

  • rider-log/HANDOFF-FROM-TAXI-LOG.md(-78)와 HANDOFF-REPLY-TO-TAXI-LOG.md(-64): 폐기된 택시 앱 계획의 인수인계 문서다. 그 방향을 접었으니 문서도 지웠다.
  • NEXT-SESSION-TODO.md(-53): 다음 세션 할 일을 적어두던 파일인데, 승인 대기 원장과 아침 보고로 역할이 옮겨가서 없앴다.
  • rename_projects.ps1(-51): 폴더 이름 일괄 변경용 일회성 스크립트다. 한 번 쓰고 남겨두면 나중에 누군가 다시 돌린다.

죽은 문서를 남겨두면 다음에 읽는 사람이 그게 현행인지 확인하는 비용을 낸다. 확인 비용이 반복되는 문서는 지우는 게 맞다.

나머지 트랙

한 줄씩만 남긴다.

  • bitget-trading: a_momentum/a_momentum.py(+322)로 모멘텀 전략 코드가 들어왔고, 백테스트 캐시 파일들이 함께 커밋됐다.
  • us-stock: backtest/forward_run.py(+56/-7). 페이퍼 포워드 테스트가 매일 NAV를 한 줄씩 쌓는 중이다.
  • hell-train: index.html이 +1830/-468로 크게 움직였고, .claude/skills/ 아래 규칙 문서가 들어왔다. git-workflow(+716), tdd-workflow(+583), coding-standards(+551), error-handling(+377), verification-loop(+129). SPEC.md도 +279줄 늘었다.
  • video-pipeline: assets/broll/hell-train/record_broll.py(+121)로 게임 플레이 영상을 자동 녹화하고, 개발일지 롱폼용 폰트와 클립을 모으는 중이다.
  • insta-card-news: CLAUDE.md(+76), SCHEDULE-PLAN.md(+95), SETUP-TODO.md(+47). 발행은 아직 수동 고정이다.
  • ai-influencer: HANDOFF.md(+84/-38), 캡션 로그와 포스팅 큐 정리.
  • doc-maker: .claude/skills/doc-design-skill/SKILL.md(+124)와 docx 변환 실험 파일들.

오늘 남긴 세 가지

하나. 증상이 나온 커밋과 원인을 만든 커밋은 다르다. AdMob ID 교체 커밋에서 크래시가 보였지만 원인은 R8이었다. debug와 release를 비교해서 “코드가 같은데 결과가 다르다"를 확인한 게 방향을 바꿨다.

둘. 실패 경로 코드는 성공 경로보다 테스트가 길어진다. 유가 갱신은 구현 25줄에 테스트 62줄, 자동 지출은 구현 69줄에 테스트 95줄이었다. 성공은 한 갈래인데 실패는 네트워크, 형식, 빈 데이터로 갈라진다.

셋. 매 단계 끝에서 실행 가능한 앱으로 남겨야 한다. cargo-log를 M1부터 M6까지 밀 수 있었던 건 M2 시점에 이미 쓸 수 있는 장부앱이었기 때문이다. GPS도 광고도 그 위에 얹은 것이고, 마지막에 R8 문제로 M6이 막혔을 때도 앞 단계는 멀쩡했다.

다음 할 일은 R8을 다시 켜는 것이다. 축소를 끈 건 임시 조치다. 어떤 클래스가 왜 필요한지 릴리스 로그에서 확인하고 keep 규칙을 최소로 쓰는 게 원래 자리다. 지금은 출시 가능한 상태를 먼저 확보했다.