1.3.2+28을 스토어에 올리고 나니 큰 기능 대신 자잘하게 걸리던 것들이 남았다. Xcode에 뜨는 가짜 에러,
언어마다 들쭉날쭉한 작가용 글꼴 수, 그리고 위젯에 "무제"가 말씀인 척 끼어 있던 문제. 이번 업데이트는
이 셋이다. 이번부터 일지를 날짜가 아니라 업데이트 버전 단위로 쓰기로 했다 — 스토어 패치노트와
일지가 1:1로 맞아야 나중에 "이 버전에 뭐가 들어갔지"를 바로 찾을 수 있다.
작성: 2026-09-28
when을 못 찾는다고 한다iOS 빌드를 한 번 돌리고 나서 Xcode(그리고 VS Code)에 undefined function 'when' 에러가 줄줄이 떴다.
빌드도 되고 dart analyze로 lib/만 보면 멀쩡한데 IDE만 빨갛다.
에러가 난 파일 경로를 보니 build/ios/SourcePackages/ 아래였다. Xcode의 Swift Package Manager가
firebase_messaging 같은 플러그인 소스를 통째로 받아오는데, 그 안에 플러그인 자체의 테스트 코드가 들어 있다.
그 테스트는 mockito를 dev 의존성으로 쓰는데 우리 프로젝트엔 mockito가 없으니 when을 못 찾는 거였다.
우리 코드 문제가 아니라 분석기가 남의 테스트까지 훑고 있던 것.
analysis_options.yaml에서 build/ 제외analyzer:
exclude:
- build/**
build/는 어차피 생성물이라 분석할 이유가 없다. 이미 배포된 1.3.2 빌드엔 아무 영향이 없다(IDE 표시 문제일 뿐).
작성: 2026-09-28
업로드 에디터의 작가용 글꼴은 언어(정확히는 문자 체계)별 묶음이다. 한국어는 18종인데 번체 중국어는 2종,
베트남어는 7종이었다. 글로벌 마켓플레이스인데 외국 작가 입장에선 선택지가 너무 적다.
묶음은 content.dart의 fontSets에 문자 체계 10개로 나뉘어 있다. "16개 언어인데 왜 10개냐"는 질문이
나왔는데, 영어·스페인어·프랑스어·독일어·포르투갈어·이탈리아어·인도네시아어 7개는 같은 라틴 알파벳이라
latin 묶음 하나를 같이 쓴다(fontsFor()가 langScript에 없는 언어를 전부 latin으로 보낸다).
9개 개별 묶음 + 라틴 7개 = 16개 언어가 다 덮여 있다.
| 묶음 | 전 | 후 |
|---|---|---|
| 한국어 | 18 | 30 |
| 일본어 | 11 | 28 |
| 간체 | 8 | 9 |
| 번체 | 2 | 6 |
| 라틴(7개 언어) | 15 | 31 |
| 아랍어 | 8 | 21 |
| 히브리어 | 8 | 18 |
| 힌디어 | 8 | 24 |
| 베트남어 | 7 | 19 |
| 러시아어 | 8 | 27 |
글꼴은 google_fonts 패키지로 런타임에 받는다. 작품 이미지에 구워지는 글자라 서브셋 번들로는 일본어·한자가
깨지기 때문이다. 문제는 GoogleFonts.getFont(family)가 모르는 이름이면 예외를 던지고, 렌더러
(wallpaper_render.dart)가 그걸 잡아서 조용히 앱 기본 세리프로 대체한다는 점이다. 오타 하나면 작가는
글꼴을 골랐는데 적용이 안 되는 걸 게시하고 나서야 안다.
그래서 추가하기 전에 설치된 google_fonts 6.3.3 소스(~/.pub-cache/.../google_fonts_parts)에서 후보 이름을
전부 grep으로 대조했다. Chiron Hei HK·Chiron Sung HK·Iansui·Huninn 4개는 이 버전에 없어서 뺐다. 그리고
앞으로도 같은 사고가 안 나게 테스트를 하나 남겼다.
final known = GoogleFonts.asMap().keys.toSet();
for (final e in fontSets.entries) {
for (final f in e.value) {
expect(known, contains(f.family), reason: '${e.key}: ${f.family}');
}
}
GoogleFonts.asMap()이 패키지가 아는 전체 글꼴 이름 맵이라, 네트워크 없이 이름 존재 여부만 확인한다.
각 묶음의 첫 번째 글꼴은 건드리지 않았다. 구매자가 다른 언어로 받을 때 렌더러가
fontsFor(renderLang).first.family를 기본값으로 쓰기 때문에, 순서를 바꾸면 이미 팔린 작품이 다르게
그려진다. 새 글꼴은 전부 뒤에만 붙였다.
번체는 간체 장식체(ZCOOL 계열)가 번체자 글리프가 없을 수 있어서 원래부터 빼뒀던 곳이다. 이번에도 번체(TC/HK)
전용으로 나온 것만 넣었다 — LXGW WenKai TC, Cactus Classical Serif, Noto Serif/Sans HK.
글꼴 선택 줄은 SingleChildScrollView + Row였다. Row는 자식을 전부 한 번에 만들고, _FontChip은 자기
이름을 그 글꼴로 그리려고 GoogleFonts.getFont를 부른다 — 즉 선택 줄을 열면 목록 전체 글꼴을 다운로드한다.
18개일 땐 그냥 넘어갔는데 일본어가 28개가 되면 얘기가 다르다. CJK 글꼴은 파일 하나가 수 MB다.
ListView.separated(가로)로 바꿔서 화면에 보이는 칩만 만들게 했다. 가로 ListView는 높이가 정해져 있어야
해서 SizedBox(height: 40)로 감쌌다. _FontChip에는 원래부터 "가로 ListView가 높이를 늘리므로 필요"라는
alignment: Alignment.center 주석이 있었다 — 처음엔 ListView였다가 어느 순간 Row로 바뀌었던 흔적이다.


작성: 2026-09-28
홈 화면·잠금화면 인기 말씀 위젯이 가끔 "무제"를 보여줬다. 말씀 블록 없이 사진만 올린 정지 작품이다.
업로드 뷰모델(publish())은 말씀 블록이 없으면 verse_ref에 '무제'를 넣는다(verse_ref가 not null이라
뭐라도 들어가야 한다). 위젯 데이터는 popular_verses/popular_products_for_widget 두 Supabase RPC가
products.verse_ref를 집계해서 만드는데, 여기에 절 범위([-,])나 "외" 필터는 있어도 '무제' 필터가 없었다.
그러니 '무제'도 하나의 "구절"로 집계됐다.
움직이는 배경화면은 어떨까 해서 같이 봤다. live 타입은 verse_ref가 null이라(2026.09.21 스키마) null !~ ...가
null로 평가돼 원래부터 빠지고 있었다.
새 마이그레이션 20260928130000_widget_exclude_untitled.sql에서 두 함수를 create or replace로 다시 정의했다.
where status = 'published'
and type = 'still'
and verse_ref <> '무제'
type = 'still'은 지금 결과엔 차이가 없지만 "null이라 우연히 빠지는" 걸 의도로 못박아 둔 것이다. iOS
WidgetKit(홈·.accessoryRectangular 잠금화면)과 Android 위젯·잠금 오버레이가 전부 이 두 RPC 결과를
home_widget으로 받아 쓰기 때문에, 앱 코드는 한 줄도 안 고치고 서버만 바꿔서 모든 플랫폼이 같이 고쳐졌다.
앱 업데이트 없이 DB 적용 즉시 반영된다(기기 캐시는 다음 앱 실행 시 동기화).
적용 후 anon 키로 두 RPC를 curl로 직접 불러 확인했다.
| RPC | 결과 수 | 무제 |
|---|---|---|
popular_verses | 40 | 0 |
popular_products_for_widget | 53 | 0 |
작성: 2026-09-29
작품 이미지·말씀은 열람자 앱 언어로 다시 그려 보여주는데(§4-9), 그 밑 댓글은 쓴 사람 언어 그대로였다.
영어 댓글 밑에 한국어 댓글이 달리면 서로 못 읽는다. 인스타그램처럼 댓글 밑에 "번역 보기"를 두고, 누르면
그 자리에서 본문이 번역문으로 바뀌고 "원문 보기"로 되돌아가게 했다. 조건은 하나였다 — 무료로 많이 쓸 수 있는 AI 모델.
API 키를 앱에 넣을 수는 없으니 Supabase Edge Function translate-comment를 새로 만들었다. 모델은 무료 티어가
있는 Google Gemini를 REST(generateContent)로 부른다 — Deno라 SDK 없이 fetch 한 번이면 된다.
const { commentId, lang } = await req.json();
// 사용자 권한으로 읽는다 → RLS가 "게시된 작품의 댓글"만 통과시킨다
const { data: comment } = await userClient.from('comments').select('body').eq('id', commentId).maybeSingle();
일부러 번역할 텍스트가 아니라 댓글 id만 받게 했다. 텍스트를 받으면 누구나 이 함수를 공짜 번역기로 쓸 수 있고,
그럼 무료 할당량이 금방 바닥난다. lang도 앱의 langCode 16개 목록에 있는 값만 허용한다.
결과는 새 테이블 comment_translations(PK comment_id + lang)에 캐시한다. 같은 댓글을 같은 언어로 100명이 눌러도
모델 호출은 한 번이다. RLS는 켜고 정책은 하나도 안 만들었다 — service role(Edge Function)만 읽고 쓸 수 있어서
클라이언트가 번역문을 위조할 수 없다. 댓글이 지워지면 on delete cascade로 같이 사라진다.
앱 쪽은 기존 구조를 그대로 따랐다: CommentRepository.translate() → SupabaseCommentRepository가
functions.invoke('translate-comment') → CommentsViewModel.translate(). 화면(_CommentRow)은 번역문을 State에
들고 있다가 "원문 보기"↔"번역 보기"를 오갈 때 재사용한다. 댓글 목록이 새로고침돼도 번역 상태가 엉뚱한 댓글로
옮겨가지 않게 행마다 ValueKey(comment.id)를 달았다. 내 댓글엔 버튼을 안 보여준다(내 언어로 썼을 테니까).
문구 3개("번역 보기"/"원문 보기"/"번역하지 못했어요")는 16개 언어 ARB에 넣었다.
보안 경로는 curl로 실패 케이스를 확인했다.
| 요청 | 결과 |
|---|---|
| Authorization 없음 | 401 |
| 로그인 안 된 anon 키 | 401 unauthorized |
| 목록에 없는 언어 코드 | 400 bad_request |
배포하고 눌러 보니 "번역하지 못했어요"만 떴다. AI Studio에선 결제하라는 안내가 보여서 나는 키 문제라고 짚었다.
키가 AIza…가 아니라 AQ.로 시작하니 Vertex용 유료 키 아닐까, 모델 무료 한도가 0 아닐까 — 추측을 두 번 했는데
둘 다 틀렸다. 결국 Supabase 함수 로그를 직접 봤다.
gemini translate failed 404 {"error":{"code":404,"message":"This model models/gemini-2.5-flash-lite
is no longer available to new users. Please update your code to use models/gemini-3.5-flash-lite ..."}}
gemini-2.5-flash-lite는 신규 사용자에게 더 이상 안 열어주는 모델이었다. 요금 문서엔 여전히 "무료"로 적혀
있어서 문서만 봐선 알 수가 없었다. AQ.는 그냥 요즘 AI Studio 키 형식이었다. 모델 이름을 코드에 박지 않고 시크릿
GEMINI_MODEL로 뺀 덕분에 supabase secrets set GEMINI_MODEL=gemini-3.5-flash-lite 한 줄로 재배포 없이 바로
고쳐졌다(기본값도 코드에서 같이 바꿨다). 교훈: 로그부터 볼 것. 추측 두 번보다 로그 한 줄이 빨랐다.
AI Studio에 프로젝트가 두 개(atelier316, atelier316-7a3ce) 보여서, 나는 atelier316을 "키 발급 중에 새로
생긴 필요 없는 프로젝트"라고 판단하고 삭제 방법까지 안내했다. "정말 안 쓰이는 거 맞아?"라는 질문에 gcloud로
확인해 보니 완전히 틀렸다. 이 프로젝트는 8월 15일에 만든 운영 프로젝트였다.
| GCP 프로젝트 | 쓰이는 곳 |
|---|---|
atelier316 | 서비스 계정 verify-purchase(= GOOGLE_SERVICE_ACCOUNT_JSON, Play 영수증 검증), Pub/Sub play-rtdn → google-webhook(환불), Cloud Run atelier316-video-worker(라이브 배경화면) |
atelier316-7a3ce | Firebase(푸시), Gemini 댓글 번역 키(무료 등급) |
지웠으면 Android 결제는 되는데 소장이 안 되고, 환불도 안 먹고, 라이브 배경화면 업로드도 멈췄을 것이다. 그래서
Claude Code에 UserPromptSubmit hook을 하나 달았다 — 프롬프트에 "삭제/지우/제거/종료/delete…"가 있으면 "삭제를
안내하기 전에 gcloud·git grep·supabase로 사용처부터 확인하고, 쓰이면 먼저 물어보라"는 규칙을 매번 컨텍스트에
끼워 넣는다. CLAUDE.md에 적는 것보다 확실하다 — 대화가 길어져도 그 순간에 반드시 발동하니까. 다시 "삭제해 줘"라고
해 보니 사용처 표를 보여주고 먼저 물었다.
작성: 2026-09-29
관리자 메뉴의 "홈 화면 안내 팝업" 편집 화면에서 제목·본문을 고치고 저장을 누르면, 저장 토스트는 뜨는데
키보드가 그대로 떠 있었다. 사진 목록과 저장 버튼 위를 키보드가 덮고 있어서, 매번 빈 곳을 한 번 더
눌러야 했다.
이 화면은 9월 9일 요청으로 진입하자마자 본문 입력칸 끝에 커서를 두고 FocusNode.requestFocus()로
키보드를 바로 올린다(텍스트를 채운 직후엔 포커스를 못 받아서 addPostFrameCallback으로 한 프레임 미룬다).
그런데 저장 버튼(Cta)은 탭만 처리하고 포커스는 건드리지 않는다. 결국 포커스를 올리는 코드는 있는데
내리는 코드가 없었던 거다. 다른 관리자 화면은 버튼 콜백에서 이미 unfocus()를 부르고 있었는데,
이 화면만 빠져 있었다.
_save() 맨 앞에서 한 줄if (_saving) return;
FocusScope.of(context).unfocus();
Cta 위젯 자체에서 포커스를 풀게 할까도 생각했지만 Cta는 앱 전체가 같이 쓰는 버튼이다. 키보드를
유지해야 하는 곳까지 영향이 가기 때문에 이 화면의 _save()에만 넣었다. 저장이 실패해 토스트가 떠도
키보드는 이미 내려가 있으니 에러 문구도 가려지지 않는다. dart analyze와 flutter test(117개)를 통과했고,
실기기에서 저장하자마자 키보드가 내려가는 것도 확인했다.
작성: 2026-09-29
iOS 사용자들한테 "1.3.2로 업데이트했는데 + 버튼을 눌러도 움직이는 배경화면이 없다"는 문의가 왔다.
내 폰은 스토어에서 받은 같은 1.3.2인데 잘 됐다. 나는 당연히 일반 사용자한테도 열려 있는 줄 알았다.
_showUploadTypeChooser(main.dart) 맨 앞에 이런 조건이 있었다.if (!ref.read(sessionProvider).isAdmin || kIsWeb) {
context.push('/upload');
return;
}
관리자가 아니면 선택 시트를 건너뛰고 바로 정지 이미지 업로드로 보낸다. 내 폰이 된 건 내가 관리자라서였다.
9월 22일에 일부러 넣은 조건이다. 그때 스토어에 있던 구버전(1.2.x)은 verse_ref/verse_text가 항상 있다고
가정해서, 그 값이 null인 live 작품을 피드에서 만나면 "불러오지 못했어요"로 깨졌다. 새 버전이 나가기 전에 일반
사용자가 live 작품을 올리지 못하게 막은 거였다. 주석에도 "새 버전 배포 후엔 이 조건을 지워도 된다"고 적어
뒀는데, 1.3.0부터 1.3.2까지 세 번 배포하는 동안 아무도 안 지웠다. 서버 쪽(RLS·Edge Function)엔 관리자 제한이
원래 없어서, 막혀 있던 건 이 한 줄뿐이었다.
조건을 if (kIsWeb)만 남기고 지웠다. 앱 코드만 바뀐 수정이라 스토어 심사를 기다릴 필요 없이 Shorebird
(Flutter OTA 코드 푸시)로 바로 내보냈다.
여기서 하나 신경 쓴 게 있다. dev 브랜치엔 1.3.2+28 이후에 댓글 번역·글꼴 추가 같은 커밋이 이미 쌓여 있었다.
dev 그대로 패치하면 아직 검증 안 된 기능까지 같이 나간다. 그래서 +28 버전업 커밋(28c5261)에서
hotfix/1.3.2-live-upload 브랜치를 따고, 수정 커밋만 git cherry-pick으로 얹어서 패치했다.
shorebird patch android --release-version=1.3.2+28
shorebird patch ios --release-version=1.3.2+28
Flutter 버전은 릴리스(+28)에 3.38.9로 기록돼 있어서 패치는 그걸 그대로 따라간다. 워크트리엔 Android 서명
파일(key.properties, upload-keystore.jks)이 git에 없어서 원래 체크아웃에서 복사해 쓰고 끝난 뒤 지웠다.
iOS 빌드는 이번에도 project.pbxproj의 objectVersion을 70에서 54로 낮춰 놨길래 되돌렸다.
배포 직후 확인해 보니 여전히 안 보인다는 답이 왔다. 서버가 문제인지 기기가 문제인지부터 가르려고, 기기가 앱을
켤 때 보내는 요청을 Shorebird API에 직접 보내 봤다.
curl -X POST https://api.shorebird.dev/api/v1/patches/check \
-d '{"release_version":"1.3.2+28","platform":"ios","arch":"aarch64","app_id":"...","channel":"stable"}'
| 플랫폼 | 응답 |
|---|---|
| iOS | patch_available: true, 패치 #2 |
| Android | patch_available: true, 패치 #1 |
서버는 정상이었다. 원인은 Shorebird 동작 방식이었다. 첫 실행에서는 패치를 백그라운드로 받기만 하고,
앱을 완전히 종료한 뒤 다음 실행부터 적용된다. 앱을 두 번 껐다 켜니까 바로 떴다. 사용자 안내 문구는
"앱을 완전히 종료했다가 다시 여는 걸 두 번 해주세요"로 정했다. 홈으로 나갔다 들어오는 건 재실행이 아니라는
것도 같이 적었다. 삭제 후 재설치는 필요 없다.
교훈: 임시로 막아둔 건 푸는 시점을 주석에만 적으면 안 풀린다. 다음엔 버전업 체크리스트에 같이 넣어야 한다.
작성: 2026-09-29
4,900원 무제한 다운로드 멤버십을 산 사용자가 실수로 계정을 삭제했다가 바로 복구했다. 그 뒤 앱을 지우고 다시
받으려는데 App Store에 "이전 구입에 지불 문제가 있습니다."가 뜨면서 설치가 안 됐고, 5~10분 뒤엔 그냥 됐다고 한다.
계정 삭제·복구랑 엮인 건지 확인해 달라는 요청이었다.
이 문구는 Apple이 띄우는 시스템 메시지다. 그 Apple 계정에 결제가 안 끝난 이전 구매가 있으면 App Store는
무료 앱을 포함한 모든 다운로드를 막는다. 걸려 있던 구매는 다른 앱이나 iCloud 저장공간일 수도 있다.
우리 쪽이 아니라고 본 근거는 세 가지다.
| 확인한 것 | 결과 |
|---|---|
| 설치 단계에 우리 코드가 끼는가 | 아니다. App Store 설치 중엔 앱 코드도, 우리 서버도 안 불린다 |
| 계정 삭제·복구로 멤버십이 날아갔나 | 아니다. users 행에 unlimited_free_download: true, 거래번호, 부여 시각이 그대로다 |
| 우리 상품이 갱신 결제를 하나 | 아니다. atelier316.membership.unlimited_free는 1회 결제 상품이다(Products.storekit) |
5~10분 뒤에 풀린 건 Apple이 미결제 금액을 다시 청구해 성공했거나, 사용자가 팝업의 "계속"을 눌러 결제 수단을
확인했기 때문일 것이다. Apple은 결제를 모아서 나중에 청구하는 경우가 많다. 어떤 구매가 걸렸는지는 사용자만
볼 수 있어서, 궁금하면 설정 → Apple 계정 → 미디어 및 구입 항목 → 계정 보기 → 구입 내역에서 미결제
항목을 보라고 안내하기로 했다. 코드는 고치지 않았다.
작성: 2026-09-29
iPad 잠금화면을 편집해 위젯을 추가하려는데 목록에 인기 말씀 위젯이 없다는 제보가 왔다. 앱은 잘 깔려 있고
홈 화면 위젯을 쓰는 아이폰도 있으니 위젯 코드 문제는 아니었다.
위젯은 앱 본체(Runner)와 별개인 WidgetKit 익스텐션 타겟(PopularVerseWidget)이다. 이 타겟을 Xcode에서 새로
만들 때 들어간 기본값 IPHONEOS_DEPLOYMENT_TARGET = 26.5가 그대로 남아 있었다. 앱 본체는 더 낮은 iOS에서도 깔리지만,
익스텐션은 자기 배포 타겟보다 낮은 기기에는 설치 자체가 안 된다. 그래서 26.5 미만 iPad·iPhone에는 앱만 깔리고 위젯은
아무 에러 없이 빠져 있었다. 내 폰은 최신 iOS라 한 번도 못 봤다.
project.pbxproj에서 익스텐션의 Debug/Release/Profile 세 빌드 설정을 전부 17.0으로 바꿨다. 잠금화면 위젯
(accessoryRectangular 등)이 iOS 16부터라 17.0이면 충분하다. 위젯 코드(PopularVerseWidget.swift)는 이미 낮은
버전에서도 안전하게 짜여 있어서 주석만 고쳤다. 네이티브 설정이라 Shorebird 패치로는 못 보내고 1.4.0 스토어 빌드에
실려 나간다.
작성: 2026-09-30
"1.3.3으로 aab, ipa 추출해줘"라고 했다가 잠깐 멈췄다. 1.3.2 이후 들어간 게 버그 수정만이 아니었다. 직전 버전업
커밋(28c5261) 이후를 feat/fix로 나눠 봤다.
| 구분 | 커밋 |
|---|---|
| 새 기능 | 댓글 AI 번역, 모든 언어 작가용 글꼴 추가, 움직이는 배경화면 업로드 공개 |
| 버그 수정 | iPad 잠금화면 위젯(배포 타겟), 위젯 무제 제외, 안내 팝업 키보드, 번역 모델 교체 |
새 기능이 들어갔으니 두 번째 자리를 올려 1.4.0+29로 정했다. 규칙도 하나 세웠다. 버전업할 때마다 직전 버전업
이후 커밋을 보고, 새 기능이 하나라도 있으면 두 번째 자리를(1.3.2 → 1.4.0), 버그 수정만 있으면 세 번째 자리를
(1.4.0 → 1.4.1) 올린다. 이 판단은 Claude가 버전업 전에 먼저 알려주기로 했다. 버전이 바뀌었으니 일지 폴더·파일명·
제목도 v1.3.3에서 v1.4.0으로 바꿨다(git mv 뒤 git show :경로로 스테이징 내용이 최신인지 확인).
shorebird release android|ios --flutter-version=3.38.9로 두 플랫폼을 릴리스했다. Flutter 버전을 빼먹으면 최신
3.47.x로 빌드돼 버전 번호를 버리게 된다(1.3.1+23 때 겪음). iOS 빌드는 이번에도 project.pbxproj의 objectVersion을
70에서 54로 낮춰 놨다. 매번 나오니 이제 패턴이다. 기능 변경이 아니라 git checkout으로 되돌렸다.
작성: 2026-09-30
처음엔 커밋 목록으로 패치노트를 뽑았는데, 사용자가 준 목록과 하나가 달랐다. "움직이는 배경화면 업로드 공개"는
이미 1.3.2에 Shorebird 패치로 나가서 사용자들은 쓰고 있다. 스토어 입장에선 새 소식이 아니라 뺐다. 대신 위젯 "무제"
제외는 서버 RPC 수정이라 앱 코드와 무관하지만, 사용자가 체감하는 변화라 넣었다. 커밋 목록이 곧 패치노트는 아니다.
최종 4개 항목은 이렇다. 순서도 규칙으로 정했다. 새 기능을 위에, 버그 수정을 아래에.
통합본·Google Play용·App Store용 세 파일을 매번 손으로 맞추면 한 언어만 문장이 달라지기 쉽다. 그래서 언어별 항목
배열 하나를 Python dict로 두고, 파일마다 몇 번째 항목까지 쓸지만 달리 해서 생성했다. Android본은 iPad 항목을 빼고
3개만 쓴다. Play는 언어당 500자 제한이 있어서 생성하면서 assert len(body) <= 500으로 막았다. 제일 긴 독일어가
392자였다. 댓글 버튼 이름은 각 언어 ARB(commentTranslate/commentShowOriginal)에서 그대로 가져와서, 패치노트
문구와 앱 화면 문구가 어긋나지 않게 했다.
작성: 2026-09-30
aab를 Play Console에 올리니 거부됐다. 요구하는 인증서 지문은 FB:81:55…인데 올린 파일은 EF:76:AB…였다.
EF:76:AB…가 어디서 나왔는지부터 봤다. keytool -list -v -keystore ~/.android/debug.keystore의 SHA1과 정확히
같았다. 즉 debug 키로 서명된 aab였다.
| 키스토어 | SHA1 |
|---|---|
~/.android/debug.keystore | EF:76:AB:53… (올라간 aab) |
android/app/upload-keystore.jks | FB:81:55:D0… (Play가 요구) |
이번 빌드는 다른 작업과 섞이지 않게 git 워크트리에서 했다. 그런데 서명 파일 android/key.properties와
upload-keystore.jks는 .gitignore에 들어 있어서 메인 체크아웃에만 있다. 워크트리는 git에 있는 파일만 꺼내니
이 둘이 없다. 1.3.2 핫픽스 때는 복사해서 썼는데, 이번엔 그걸 빠뜨렸다.
여기에 build.gradle.kts가 한 번 더 숨겼다. 키가 없으면 빌드를 멈추지 않고 debug 키로 폴백한다. 다른 개발자가
clone해도 flutter run --release는 되게 하려는 의도된 설계고, 대신 로그에 한 줄을 찍는다.
[atelier316] release 빌드 서명: ⚠️ debug 키로 폴백
나는 빌드 출력의 마지막 25줄만 봤고, 이 줄은 그보다 위에 있었다. 경고는 찍혔는데 아무도 안 읽었다.
키를 복사해 1.4.0+29로 다시 릴리스하니 Shorebird가 막았다. 빌드(release 키 서명)까지는 되지만 등록 단계에서 멈춘다.
It looks like you have an existing android release for version 1.4.0+29.
Please bump your version number and try again.
막 만든 aab를 그냥 쓸 수도 있었다. 하지만 Shorebird에 등록된 +29 기록은 debug 빌드의 산출물 기준이라, 나중에 Android
패치를 만들 때 스토어 빌드와 기준이 어긋날 위험이 있다. 그래서 빌드 번호만 +30으로 올려 Android만 다시
릴리스했다. iOS는 서명 문제가 없어서 +29 ipa를 그대로 쓴다. 스토어마다 빌드 번호가 달라도 괜찮다. 대신 앞으로 패치할
땐 --release-version을 Android 1.4.0+30, iOS 1.4.0+29로 따로 줘야 한다.
재빌드 뒤엔 로그에서 release 키(upload-keystore.jks) 줄을 grep으로 찾았고, keytool -printcert -jarfile app-release.aab로
결과물 지문이 FB:81:55…인 것까지 확인했다. 교훈은 둘이다. 워크트리에서 Android를 릴리스할 땐 키 두 개부터 복사할
것. 그리고 조용한 폴백은 로그를 tail로만 보면 없는 것과 같다.
이번 버전에서 한 것
analysis_options.yaml에서 build/** 분석 제외 — Xcode SPM 플러그인 테스트의 가짜 mockito 에러 제거test/font_sets_test.dart)Row → 가로 ListView.separated로 가상화backend-design.md §4-10 갱신translate-comment(Gemini gemini-3.5-flash-lite 무료 티어), 캐시 테이블 comment_translations, 인스타그램식 "번역 보기/원문 보기" 토글, 16개 언어 문구 — 실기기에서 번역 동작 확인됨FocusScope.unfocus()로 키보드 내림 — 실기기 확인됨.claude/settings.local.json, UserPromptSubmit) — GCP 운영 프로젝트 오삭제 방지docs/store-patch-notes-v1.4.0+29*.md), 새 기능을 위에 배치남은 것
...TMQA 키 삭제(채팅에 노출됨), 필요하면 번역 키 ...Y7nA도 새 키로 교체supabase secrets set GEMINI_MODEL=<새 모델>만 바꾸면 됨dev → main 합치기--release-version을 플랫폼별로(Android 1.4.0+30, iOS 1.4.0+29)