1.2.0 업데이트

노영택·2026년 9월 23일

아틀리에316

목록 보기
23/28

2026-09-19 한국어 성경을 개역한글에서 개역개정으로 교체

앱이 쓰던 한국어 성경 원문을 개역한글(KorRV)에서 개역개정(NKRV)으로 바꿨다. 사용자가 프로젝트
폴더에 넣어둔 korean_bible(개역개정).json을 소스로 새 번역본을 시딩하고, 기존 개역한글 데이터는
지우지 않고 비활성 상태로 보관했다.

1. 현재 상태 파악

korean_bible.json(프로젝트 루트, git 추적됨)이 직전 커밋에서 "개역한글판" 시드 데이터라고
표기돼 있었는데, 실제 내용을 새 파일과 바이트 단위로 비교해보니 완전히 동일했다 — 즉 이 루트
JSON 파일은 처음부터 개역개정이었고 커밋 메시지 표기가 잘못됐던 것. 다만 이 파일은 앱 코드
어디서도 참조되지 않는 원본 소스 데이터일 뿐이고, 실제로 앱이 쓰는 건 별도로 존재하는
supabase/migrations/20260818060500_bible_verses_seed_korrv.sql이 시딩한 DB 데이터다. 이
마이그레이션 파일의 주석("시편 23:1 '내가 부족함이 없으리로다' 표현으로 개역한글판 확인함")과
실제 값을 대조해서, DB에 지금 들어있는 게 진짜 개역한글(KorRV, "내가")이고 사용자가 준 JSON은
개역개정(NKRV, "내게")이라는 걸 확인했다.

SupabaseBibleRepository의 search/versesInRange/searchWithin을 다 확인한 결과 조회는
전부 lang 컬럼 일치로만 걸리고 translation_id(PK의 일부)는 필터에 전혀 안 쓰인다 — 그래서
새 번역본을 그냥 나란히 추가하면 lang='ko'로 두 번역본이 중복 매칭되는 문제가 생긴다. 대신
기존 개역한글 행의 lang을 'ko'에서 'ko-krv'로 바꿔서 조회 대상에서 빼고(데이터는 보존),
그 자리에 개역개정을 lang='ko'로 새로 넣는 방식을 골랐다 — 앱 코드는 한 줄도 안 건드리고
데이터만으로 완전히 교체된다.

2. JSON → SQL 변환

korean_bible(개역개정).json은 "창1:1" 같은 한국어 약어 키(66권 약어 + 장:절)로 돼 있어서,
lib/content.dart의 koreanBookCode(66권 한국어 전체 이름 → USFM 코드, 정경 순서대로 정의됨)와
JSON 키에 처음 등장하는 순서로 뽑은 66개 약어를 순서대로 1:1 매칭시켜 책 코드·정경 순서·전체
이름을 얻었다(파이썬 스크립트, 일회성이라 커밋 안 함).

변환 중 원본 JSON 자체의 문제 3가지를 발견해서 고쳤다:

  1. NUL 패딩 바이트: 각 책의 마지막 절 3곳(삿21:25, 습3:20, 벧전5:14)에 \x00이 여러 개
    섞여 있었다(고정폭 버퍼로 원문을 만들었던 흔적으로 추정). 이걸 모른 채 그대로
    supabase db push를 돌렸더니 invalid message format (SQLSTATE 08P01)로 실패 — postgres
    와이어 프로토콜이 NUL을 문자열 종료로 해석해서 메시지가 깨진 것. 제어문자를 걸러내는
    clean() 함수를 넣어 해결.
  2. 병합 절 11곳: 개역개정 표기 자체가 두 절을 하나로 합친 키(예: "신6:18-19")가 11곳
    있었다. bible_verses.verse는 정수 하나만 받으므로, 두 절 번호(18과 19) 모두에 같은
    합쳐진 본문을 넣어서 사용자가 어느 절 번호로 선택하든 찾아지게 했다.
  3. 깨진 키 하나: "요18:이"라는 키가 있었는데, 실제로는 요18:38의 뒷부분 문장이 "이
    말을 하고..."로 시작하다가 첫 글자 "이"가 절 번호 자리에 잘못 들어가면서 나머지("말을
    하고...")만 별도 키로 떨어져 나간 것. 처음엔 이걸 요18:38 뒤에 단순히 이어붙이면서 그
    "이" 한 글자를 실수로 빠뜨렸다가, 실사용 검증(curl로 문구 대조) 중 눈치채고 후속
    마이그레이션(20260919060100_fix_nkrv_john_18_38.sql)으로 바로잡았다.

3. 적용 및 검증

supabase/migrations/20260919060000_bible_verses_seed_nkrv.sql: 맨 위에
update ... set lang = 'ko-krv' where translation_id = 'KorRV' and lang = 'ko'로 기존 데이터를
비활성화한 뒤, translation_id='NKRV', lang='ko'로 31,099건을 500개씩 끊어 insert. supabase db push --use-api 없이 supabase db push로 적용(순수 SQL 마이그레이션이라 Edge Function처럼 Docker
문제 없음).

curl로 REST 왕복 검증:

  • lang=ko 정확히 31,099건, 전부 translation_id=NKRV, 시23:1이 "내게 부족함이 없으리로다"(개역개정)
  • lang=ko-krv 그대로 31,104건, 전부 translation_id=KorRV, 같은 절이 "내가 부족함이
    없으리로다"(개역한글) — 데이터 보존 확인
  • translation_id=KorRV and lang=ko로 남아있는 행 0건 — 교체 누락 없음 확인
  • 신6:18/19 둘 다 같은 병합 본문으로 조회됨, 요18:38 수정 후 정상 문구로 조회됨

4. 파일 정리

프로젝트 루트의 korean_bible.json을 korean_bible(개역개정).json으로 rename만 하고 내용은
그대로 뒀다(원래도 개역개정이었으므로 실제 변경 없음, 이름만 정정). 앱 코드가 참조하지 않는
원본 소스 데이터 파일이라 이후 다른 번역본을 추가할 때 참고용으로만 쓰인다.

커밋 70f660e, membership-refund-ux 브랜치에 push 완료.

5. 대만어(번체자)추가

동기 중에 대만에서 유학하신 사람이 있는데 그 분이 대만어를 넣는게 어떻겠냐는 의견에 동의해서 추가했다.
대만에서는 번체자라는 것을 사용하는지 처음 알았다. 정말 도움이 되는 의견이였다.

https://bible.fhl.net/api/
대만어 성경 api를 무료로 공유하고 있는 사이트를 발견했다!



6. 잠금화면, 홈화면 위젯이서 말씀을 고정하는 기능을 추가했다.




이제 말씀을 고정할 수 있다.
유저들이 말씀을 고정하는 방법과 위젯 자체의 기능이 있는지 모르는 것 같아서 홈 화면 안내 팝업으로 안내하려고 수정했다.



2026-09-21 업데이트 안내 다이얼로그 디버깅, iOS 시스템 알림 스타일 리디자인, 위젯 재설정 메뉴 노출 수정, FCM 발송 실패(4KB 초과) 수정, ShoreBird 도입, 홈 팝업 다이얼로그 버그, 댓글 카운트·상세 당겨서 새로고침, 댓글 신고/차단

어제 1.2.0을 스토어에 올렸는데, 오늘 실기기에서 업데이트 안내 팝업이 안 뜬다는 제보가
들어왔다. upgrader 패키지를 붙여놓고 실제로 뜨는 걸 본 적이 없었던 터라(2026.09.11
연동, 2026.09.13 실기기 미확인으로 남겨둔 상태) 오늘은 이거 하나를 제대로 끝까지
추적했다. 원인을 찾는 과정에서 정작 문제는 내가 진단용으로 끼워넣은 코드에 있었다는
걸 뒤늦게 알게 됐고, 확인이 끝난 김에 다이얼로그 디자인도 iOS 시스템 팝업처럼 새로
그렸다. 그러고 나서 안드로이드 홈 화면 위젯의 "말씀 고정" 기능도 다시 살펴봤는데,
이미 배치된 위젯에서는 그 설정 화면을 다시 열 방법이 없다는 걸 발견해서 마저 고쳤다.
마지막으로 관리자가 공지를 보낼 때 FCM이 400 에러로 통째로 실패하는 로그를 발견해서
같이 고쳤다.

1. "업데이트 안내가 안 뜬다" — 범인은 내가 넣은 디버그 코드였다

스토어 쪽부터 지운다

가장 먼저 스토어 응답 자체가 이상한 건 아닌지 curl로 직접 확인했다.

curl "https://itunes.apple.com/lookup?bundleId=com.ruach.threeonesix.threeonesix&country=KR"
# → version: 1.2.0, releaseDate: 2026-09-20T22:22:09Z (정상)

curl "https://play.google.com/store/apps/details?id=com.ruach.threeonesix.threeonesix"
# → HTTP 404

iOS는 스토어 메타데이터가 이미 정상적으로 1.2.0을 반영하고 있었다. Android는 아예
공개 상세 페이지가 없었는데, upgrader의 Android 쪽 구현이 이 공개 페이지를
그대로 스크레이핑하는 방식이라 — 확인해보니 Android는 아직 Play Console 비공개
테스트 단계였다. 이건 버그가 아니라 원래 그렇게 될 수밖에 없는 상태였다(프로덕션
공개 전까지는 애초에 감지가 안 됨).

실기기 로그를 찍어보다가 내가 만든 버그를 발견했다

iOS는 스토어 쪽이 멀쩡하니 기기에서 뭐가 잘못됐는지 봐야 했다. Upgrader에
debugLogging: true를 임시로 넣고, pubspec.yaml 버전을 스토어보다 낮게(1.1.0)
내려서 재현 테스트를 시작했다.

첫 시도는 packageInfo version이 여전히 1.2.0으로 찍혀서 당황했다. 원인은
ios/Flutter/Generated.xcconfig가 갱신이 안 된 것 — 이 파일은 Xcode가 아니라
flutter CLI가 빌드 시작 시점에 pubspec.yaml을 읽어서 새로 쓰는데, Xcode의 Run
버튼으로 바로 빌드하면 이 재생성 스크립트가 스킵될 때가 있다. flutter clean으로
지웠더니 이번엔 아예 "Generated.xcconfig를 못 찾겠다"는 에러로 Xcode 빌드 자체가
깨졌고, flutter run으로 재생성을 시도하니 "Installing and launching..."에서
무한 대기(알고 보니 기기의 "이 컴퓨터를 신뢰하시겠습니까" 팝업을 놓친 상태였다).

결국 기기 설치는 Xcode가, 설정 파일 재생성은 flutter build ios --config-only가
하도록 역할을 나누는 걸로 정리했다. 이러면 flutter run의 설치 단계를 아예 안 거쳐서
그 대기 문제 자체를 안 만난다.

flutter build ios --config-only
# → Generated.xcconfig의 FLUTTER_BUILD_NAME/NUMBER 갱신, 기기 설치는 안 함

설정 파일은 고쳤는데도 다이얼로그가 안 떴다. 로그를 자세히 보니 upgrader: instantiated가
두 번 찍혀 있었다. 원인은 내가 넣은 디버그 코드 UpgradeAlert(upgrader: Upgrader(debugLogging: true), ...)가 화면(AppShell)이 리빌드될 때마다 새
Upgrader 인스턴스를 만드는 구조였던 것 — 원래 코드는 파라미터 없이
Upgrader.sharedInstance(앱 전체에 하나뿐인 싱글턴)를 썼는데, 내 임시 코드가 그
구조를 깼다. 스토어 조회는 첫 번째 인스턴스가 백그라운드에서 끝냈지만, 화면이 실제로
구독하고 있는 스트림은 초기화가 아예 안 된 두 번째 인스턴스 거라서 checkVersion()까지
도달을 못 했다.

// 잘못됨 — build()가 실행될 때마다 새 인스턴스 생성
UpgradeAlert(upgrader: Upgrader(debugLogging: true), child: ...)

// 고침 — 모듈 레벨에서 딱 한 번만 생성
final _debugUpgrader = Upgrader(debugLogging: true);
UpgradeAlert(upgrader: _debugUpgrader, child: ...)

이 한 줄을 고치고 나서야 로그에 isUpdateAvailable: true, shouldDisplayUpgrade: true가
찍히고 다이얼로그가 실제로 떴다.

중요한 건 이 버그가 내가 진단용으로 끼워넣은 코드에만 있었고, 원본 main.dart에는
처음부터 없었다
는 점이다. 원본은 항상 Upgrader.sharedInstance를 썼기 때문에 이
문제 자체가 발생할 수 없는 구조다. 그래서 확인이 끝난 뒤 디버그 코드와 pubspec 버전을
전부 원상복구해도(git diff 빈 결과로 확인) 실제 배포 코드는 그대로다. 다만 —
맨 처음 "오늘 안 떴다"고 관찰된 순간의 원본 앱 로그는 못 봤기 때문에, 그때 정확히
왜 안 떴는지는 끝내 못 밝혔다.
코드 로직 자체와 스토어 응답은 둘 다 정상이라는 것만
확인됐다.

재검증하다가 또 헷갈린 것: "이미 알린 버전"은 3일간 다시 안 뜬다

버전을 원래대로 되돌렸다가 디자인 확인 때문에 다시 낮춰서 테스트하는데, 이번엔 기기를
그대로 둔 채 재실행했더니 다이얼로그가 안 떴다. upgrader가 한 번 알린 버전은
shared_preferences에 기록해두고 기본 3일(durationUntilAlertAgain)간 재알림을
막는데, 직전 테스트에서 1.2.0 버전을 이미 알린 기록이 남아있던 거였다. 기기에서
앱을 완전히 삭제
하면 이 기록도 같이 날아가서(iOS는 앱 삭제 시 샌드박스 컨테이너
전체를 지운다) 깨끗하게 재현할 수 있었다.

덤: flutter build가 project.pbxproj를 또 건드렸다

flutter build ios --config-only를 여러 번 돌리는 과정에서 project.pbxproj의
objectVersion이 70 → 54로 또 낮춰졌다(2026.09.20 일지에 이미 기록된 같은 증상 —
Xcode 26.5가 예상과 반대로 더 오래된 스키마로 재작성한다). 요청받지 않은 포맷 변경이라
git checkout으로 되돌렸다. 이 프로젝트에서 iOS 빌드를 돌릴 때마다 반복되는
부작용이라 다음에도 커밋 전에 꼭 diff를 봐야 한다.

2. 업데이트 다이얼로그를 iOS 시스템 알림 스타일로 새로 그렸다

기능 확인이 끝난 김에 디자인 요청도 받았다: 제목을 "새로운 업데이트가 있어요"로,
릴리즈 노트 섹션 삭제, "무시" 버튼 삭제, iOS 느낌으로.

1차: 패키지가 제공하는 Cupertino 스타일로

UpgradeAlert는 dialogStyle: UpgradeDialogStyle.cupertino를 주면
CupertinoAlertDialog(옛날 UIAlertController처럼 버튼을 구분선으로 쌓는 모양)로
바꿔준다. showIgnore: false, showReleaseNotes: false로 버튼·섹션을 지우고,
cupertinoButtonTextStyle로 버튼 색을 시스템 블루로 맞췄다.

2차: "이거 말고 이런 스타일" — 완전히 새로 그리기

실제로 확인해보니 원하던 건 그 옛날 스타일이 아니라, iOS의 "USB 기기 연결 허용" 같은
최신 시스템 팝업(둥근 카드, 좌상단 아이콘 사각형, 아래 알약 모양 버튼 두 개 나란히)
쪽이었다. 이건 CupertinoAlertDialog 기본 모양과 완전히 다르게 생겨서, 패키지가
공식 문서에서 안내하는 확장 방식(createState() 오버라이드)으로 다이얼로그를 통째로
새로 그렸다.
이렇게 하면 ios 내부에서 업데이트 하라고 하는 것 같아서 업데이트 하는 유저의 수가 늘어날 것으로 예상해서 디자인을 ios 시스템 테마에 맞게 디자인했다.

class _AppUpdateAlert extends UpgradeAlert {
  _AppUpdateAlert({super.key, Widget? child})
      : super(showIgnore: false, showReleaseNotes: false, child: child);

  
  UpgradeAlertState createState() => _AppUpdateAlertState();
}

class _AppUpdateAlertState extends UpgradeAlertState {
  
  Widget alertDialog(...) {
    // 버전 체크·스토어 이동 등 나머지 로직은 부모(UpgradeAlertState) 걸 그대로 쓰고,
    // 다이얼로그 위젯 트리만 완전히 새로 그린다.
  }
}

버전 비교·스토어 이동·"나중에" 처리 같은 로직은 부모 클래스(UpgradeAlertState)
걸 그대로 재사용하고, alertDialog() 메서드만 오버라이드해서 겉모습만 바꿨다 —
onUserLater/onUserUpdated도 부모의 메서드를 그대로 호출한다. 버튼은
CupertinoButton + borderRadius로 알약 모양을 만들고, 배경·텍스트 색은
CupertinoDynamicColor.resolve()로 라이트/다크 모드를 자동으로 따라가게 했다.

항목1차(Cupertino 기본)2차(완전 커스텀)
버튼 배치세로로 쌓임, 구분선가로로 나란히, 알약 모양
아이콘없음좌상단 파란 사각형 아이콘
배경표준 알림 배경둥근 카드(radius 20)

제목은 앱 자체 로케일 시스템(AppLocalizations)에 새 키(updateAvailableTitle)를
추가해서 16개 언어 전부 번역해뒀다 — flutter gen-l10n으로 코드 생성까지 확인.
본문·버튼 문구는 upgrader 패키지에 내장된 번역을 그대로 쓴다(제목만 바꾸고
나머지는 건드리지 않는 게 가장 적은 변경이라 그렇게 했다).

3. 위젯을 길게 눌러도 "설정"이 안 보였다

"안드로이드 위젯에서 말씀 고정 어떻게 해?"라는 질문에서 시작했다. 찾아보니 위젯
설정 화면(WidgetConfigureActivity, 성경 전체 검색 후 특정 구절을 위젯에 고정하는
기능)은 이미 구현돼 있었다. 그런데 실제로 확인해보니 "위젯을 길게 눌렀을 때
'위젯 스택 만들기'·'홈에서 삭제'만 뜨고 편집 항목이 없다"는 걸로 드러났다 — 기능은
있는데 다시 열 방법이 없었던 것.

원인: android:configure만으로는 최초 배치 시에만 뜬다

android:configure는 위젯을 처음 홈 화면에 추가하는 순간에만 자동으로 설정
액티비티를 띄운다. 이미 배치된 위젯의 롱프레스 메뉴에 "편집"을 노출하려면 Android
12(API 31)부터 별도로 android:widgetFeatures="reconfigurable"을 appwidget-provider
XML에 선언해야 한다. 종이·코랄·밤 3개 스타일의 위젯 정보 XML에 이 속성이 빠져 있었다.

해결: 속성 한 줄 추가

android:configure="com.ruach.threeonesix.threeonesix.WidgetConfigureActivity"
android:widgetFeatures="reconfigurable"

popular_verse_widget_paper_info.xml, _coral_info.xml, _night_info.xml 세 파일에
각각 추가했다. minSdk보다 낮은 기기에서는 이 속성이 그냥 무시되니 하위 호환 문제는
없다. 작품위(artwork) 스타일은 애초에 고정 기능 자체가 없는 구조(작품 이미지를
그대로 보여주는 스타일)라 대상에서 뺐다.


커밋 354e4b5로 membership-refund-ux 브랜치에 푸시 완료.

4. 관리자 공지를 보내면 FCM이 "메시지가 너무 크다"고 거절했다

관리자가 붙여준 실제 에러 로그로 시작했다.

{
  "error": {
    "code": 400,
    "message": "Message is too large. The maximum is 4K (4096 bytes).",
    "status": "INVALID_ARGUMENT"
  }
}

원인 추적: 어떤 필드가 커지는지부터 찾는다

FCM v1은 메시지 전체(JSON) 크기가 4096바이트를 넘으면 통째로 거부한다. 발송 함수
(supabase/functions/send-push/index.ts)를 처음부터 훑었다 — data에는 알림 종류·ID
같은 짧은 문자열만 담기고, 성경 구절도 본문 전체가 아니라 짧은 참조 표기("요한복음
3:16")만 쓰여서 대부분의 알림 타입(신작 게시·구독·좋아요·댓글 등)은 애초에 4KB에
도달할 여지가 없었다.

문제는 announcement(공지)와 admin_push(관리자 발송) 두 타입이었다.

if (type === 'announcement' || type === 'admin_push') {
  return {
    title: (payload.title as string) ?? APP_NAME,
    body: (payload.body as string) ?? '',
  };
}

이 두 타입은 관리자가 입력한 title/body를 길이 제한 없이 그대로 FCM
notification.body에 넣는다. 클라이언트(admin_actions.dart)를 보니 본문 글자 수
제한은 2026.09.17에 의도적으로 뺀 정책이었다(주석에 남아있음) — 제목만 60자로
막고 본문은 여러 줄 입력 시 maxLength: null. 관리자가 긴 공지를 입력하면(한글은
UTF-8에서 한 글자가 3바이트라 바이트 수가 더 빨리 늘어난다) 그대로 FCM으로 직행해서
4KB를 넘긴 것이다.

해결: 클라이언트가 아니라 발송 함수에서 자른다

본문 무제한 정책은 사용자가 최근에 일부러 정한 것이라 되돌리지 않았다. 대신 모든
알림 타입이 결국 거쳐 가는 단 하나의 지점 — FCM에 실제로 보내기 직전 — 에서
바이트 단위로 안전하게 잘라내는 쪽을 택했다. 여러 곳(클라이언트 입력창, DB 함수)에
길이 체크를 흩어놓는 것보다 발송 함수 한 곳만 고치는 게 더 확실하고 더 적은 변경이다.

function truncateUtf8(str: string, maxBytes: number): string {
  const bytes = new TextEncoder().encode(str);
  if (bytes.length <= maxBytes) return str;
  let end = maxBytes;
  while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
  return new TextDecoder().decode(bytes.slice(0, end));
}

단순히 바이트 수로 자르면 한글 같은 멀티바이트 문자 중간이 깨질 수 있어서, 자르는
지점 뒤 바이트가 UTF-8 연속 바이트(10xxxxxx)이면 한 글자가 끝날 때까지 경계를
뒤로 물린다. message.title은 200바이트, message.body는 3000바이트로 제한했다 —
FCM 토큰 길이·data 필드들의 JSON 오버헤드까지 감안해도 4096바이트에 여유 있게
못 미친다.

message.title = truncateUtf8(message.title, 200);
message.body = truncateUtf8(message.body, 3000);

supabase functions deploy send-push --use-api로 배포하고 커밋 b937c5a 푸시 완료.

검증: 실기기 없이도 확인되는 부분은 바로 확인한다

실제 기기에 푸시가 도착하는지는 사용자가 직접 확인할 부분이지만, "잘라내기가 멀티바이트
문자를 안 깨뜨리는지"와 "최악의 경우에도 4096바이트 안에 들어오는지"는 순수 로직
검증이라 deno run으로 바로 확인했다. truncateUtf8을 그대로 복제해서 한글
문자열(3바이트/글자)을 애매한 지점에서 여러 번 잘라봤는데 깨진 문자(치환문자
U+FFFD)는 한 번도 안 나왔다. 그 다음 실제 FCM 요청 모양대로 — 16,400바이트짜리
긴 본문(수정 전이면 확실히 실패했을 크기), 163자 토큰, data 필드 6개(uuid)까지
전부 채워서 JSON.stringify한 최종 크기를 쟀다.

원본 body 바이트 수: 16400
잘라낸 title 바이트: 198 잘라낸 body 바이트: 3000
최종 FCM 요청 body 총 바이트: 3767 / 4096
PASS: 4096바이트 이내

최악의 경우로 구성해도 4096바이트에 329바이트 여유가 있어서, 실제 운영 환경에서는
이보다 더 안전하다.

5. ShoreBird(OTA 코드 푸시) 도입

9042bcb에서 한 번 시도했다가 로그인이 막혀서 보류됐던 걸 마저 끝냈다. CLI 자체는
~/.shorebird/bin에 이미 설치돼 있었는데(2026.09.20), shorebird login이 브라우저
콜백(localhost:포트/callback) 방식이라 백그라운드로 띄워둔 세션에서는 대신
로그인해줄 수가 없었다 — 사용자가 직접 ! shorebird login으로 이 세션 안에서
명령을 실행해 뜬 URL(https://auth.shorebird.dev/login?...)을 브라우저로 열어
로그인을 마쳤다.

! export PATH="$HOME/.shorebird/bin:$PATH" && shorebird login
# → 브라우저 자동 실행은 안 되고 로그인 URL만 출력, localhost 콜백 대기
# → 사용자가 URL을 직접 열어서 로그인 완료

로그인 후 shorebird init --display-name="threeonesix"로 앱을 등록했다(--display-name
없이 실행하면 비대화형 세션이라 프롬프트에서 그대로 멈춘다). shorebird doctor가 지적한
문제 2개 중 하나는 shorebird.yaml이 없다는 것(=init으로 자동 해결), 다른 하나는
AndroidManifest.xml에 INTERNET 권한이 명시돼 있지 않다는 것 — 지금까지는 다른
플러그인이 매니페스트 병합 과정에서 암묵적으로 넣어줘서 문제가 없었지만, ShoreBird는
자기 매니페스트만 따로 검사하므로 명시 선언을 추가했다.

<uses-permission android:name="android.permission.INTERNET"/>

init이 만든 shorebird.yaml(app_id 발급됨)은 pubspec.yaml assets에 자동으로
등록됐다. auto_update는 기본값(true)을 그대로 뒀다 — 별도 패키지(shorebird_code_push)
없이도 앱이 백그라운드에서 자동으로 패치를 받는다.

shorebird doctor는 이제 이슈 0개다. 다만 실제 shorebird release(기준 릴리스 등록)는
지금 만들지 않았다 — 이건 그대로 스토어에 올라갈 빌드여야 이후 shorebird patch가
실기기에 먹히는데, 지금은 기능 브랜치 중간 상태라 다음 정식 스토어 빌드(버전업 때)에
같이 진행하기로 했다.

6. 홈 화면 안내 팝업 — 이미지 2장 이상이면 다이얼로그 자체가 안 그려졌다

"홈 화면 안내 팝업 사진이 잘린다"는 리포트로 시작했는데, 고치고 나니 완전히 다른
증상("팝업이 아예 안 뜨고 뒤에 홈 피드만 딤 처리된 채로 보인다")으로 이어졌다 —
둘은 서로 다른 버그였다.

1차: 이미지가 위아래로 잘려 보였다

_PopupImageCarousel(여러 장인 경우)이 SizedBox(height: 180) + BoxFit.cover를
써서, 이미지 비율이 180px 박스와 안 맞으면 잘렸다. BoxFit.contain으로 바꾸고 남는
여백은 카드 배경(tk.card)으로 채웠다. 곁들여 이미지를 탭하면 InteractiveViewer로
핀치 확대/축소되는 전체화면 뷰어도 추가했다(PageRouteBuilder(opaque: false)로
새 라우트를 띄우는 방식, 새 패키지 없이 Flutter 표준 위젯만 사용).

2차: 이미지가 2장 이상이면 다이얼로그 자체가 안 보였다

이걸 고친 뒤 관리자 계정(Ruach)으로 확인해달라고 했더니 "팝업 창이 안 뜨고 뒤에
피드만 불투명으로 보인다"는 완전히 다른 리포트가 왔다. 실기기 콘솔 로그에는 예외가
전혀 안 찍혔다 — 그래서 임시 진단 코드(다이얼로그 배경을 빨간색으로, showDialog
호출 전후에 print)를 심어 단계별로 좁혀갔다.

  1. showDialog는 정상 호출됐고 Future가 끝내 resolve되지 않았다(=다이얼로그가
    "떠 있긴 한데" 아무것도 안 그려짐) — 콘솔에 calling showDialog까지만 찍히고
    returned가 안 찍힘.
  2. AlertDialog를 통째로 걷어내고 화면 전체를 덮는 단순한 빨간 Container로
    바꾸니 정상적으로 떴다 → showDialog 메커니즘 자체는 멀쩡, 문제는
    AlertDialog 내부.
  3. AlertDialog는 복원하되 캐러셀(_PopupImageCarousel, PageView 포함)만 빼고
    텍스트만 넣으니 정상적으로 떴다 → 범인은 PageView로 좁혀짐.

원인: AlertDialog는 actions와 폭을 맞추려고 content를 내부적으로
IntrinsicWidth로 감싼다. PageView(뷰포트 기반 스크롤 위젯)는 "고유 너비"를
계산하는 기능 자체를 지원하지 않아서, IntrinsicWidth가 이걸 물어보는 순간
레이아웃이 조용히 깨졌다 — 예외가 콘솔에 안 찍히는 유형이라 원인 추적이 오래
걸렸다. 이미지가 1장 이하일 때는 애초에 PageView가 안 쓰여서 지금까지 문제가
없었던 것.

해결: content를 SizedBox(width: double.maxFinite)로 감싸서, IntrinsicWidth가
PageView한테 너비를 물어볼 필요 자체를 없앴다 — "AlertDialog 안에
ListView/PageView"의 표준적인 회피법.

content: ConstrainedBox(
  constraints: BoxConstraints(maxHeight: ...),
  child: SizedBox(
    width: double.maxFinite,  // IntrinsicWidth가 PageView에 너비를 안 묻게 함
    child: SingleChildScrollView(...),
  ),
),

검증 방식

로그인 계정을 지웠다 만들었다 하며 재현하는 대신, welcome_popup_acks 테이블에서
Ruach 계정의 확인 기록만 지웠다 켰다 하면서 재현했다(다른 사용자는 안 건드림).
본문이 길 때 스크롤도 실제로 되는지 확인하려고, docs/store-patch-notes-v1.2.0+20.md
(9개 언어 패치노트 전체, 9,993자)를 임시로 팝업 본문에 넣어 테스트했는데 — 이때
다른 사용자한테 안 보이게 하려고, welcome_popup을 업데이트해서 버전을 올린 직후
같은 트랜잭션(CTE) 안에서 Ruach를 제외한 모든 welcome_popup_acks 행을 새 버전으로
"이미 확인함" 처리했다. 확인이 끝난 뒤엔 본문을 원래 짧은 인트로로 되돌리고 전
계정(Ruach 포함)을 새 버전으로 확인 처리해서 마무리했다.

같은 자리에서 본문이 길 때 스크롤 가능하다는 걸 알려주는 Scrollbar
(thumbVisibility: true)도 추가했다 — 단서 없이 스크롤해야 하면 "다 봤다"고
착각하고 닫기 쉽다는 요청.

커밋 0de192f로 membership-refund-ux 브랜치에 푸시 완료(일지 관련 커밋
ac6b30d은 별개 — 직전 커밋이 git mv를 새 파일 add로 인식해 편집 전 내용으로
커밋되는 바람에 섹션 5가 빠져 있던 걸 복구한 것).

7. 댓글 카운트 미갱신 버그 + 작품 상세 당겨서 새로고침 추가

댓글을 달아도 상세 화면 댓글 수가 안 올라갔다

comments_section.dart의 작성/답글/삭제 로직이 commentsProvider(댓글 목록)만
무효화하고, Product.commentCount를 들고 있는 productProvider는 그대로 뒀다 —
그래서 댓글을 달아도 목록엔 바로 나오는데 상세 화면 상단의 숫자(p.commentCount)는
그 자리에서 새로고침되지 않는 이상 안 바뀌었다. 작성(_post)·답글(_postReply)·
삭제(_confirmDelete) 세 곳 모두에 productProvider(widget.productId) 무효화를
추가했다(comment_count는 DB 트리거 bump_comment_count가 관리하는 컬럼이라
서버 값 자체는 이미 정확했다 — 클라이언트가 다시 읽어오기만 하면 됐다).

작품 상세에 당겨서 새로고침 추가

_DetailBody의 본문 ListView를 RefreshIndicator로 감쌌다 — 홈 피드와 같은
패턴(AlwaysScrollableScrollPhysics로 내용이 짧아도 당겨지게 함). onRefresh는
ref.refresh(productProvider(p.id).future)로 상품 데이터(가격·좋아요·댓글 수·설명
등)를 통째로 다시 읽어온다. 댓글 목록·좋아요한 사람 목록은 별도 바텀시트
(DraggableScrollableSheet)라 이 당겨서 새로고침 범위 밖이다.

커밋 13c25a5로 membership-refund-ux 브랜치에 푸시 완료.

8. 댓글 신고/차단 기능 추가 + 좋아요/댓글 탭 영역·터치 피드백 다듬기

이어서 "댓글 신고/차단 기능 추가"와 "좋아요 누른 유저 리스트 탭 영역이 너무 좁다"는
요청이 왔다.

좋아요/댓글 카운트 탭 영역이 실제로는 숫자 글자 크기만큼만 히트됐다

_CountText(좋아요·댓글 숫자)가 HitTestBehavior.opaque를 쓰고 있었지만, opaque는
"이 박스 안이면 그림이 없어도 히트로 친다"는 뜻이지 박스 자체를 키워주진 않는다 —
박스가 숫자 글자 크기(Text('$count')) 그대로라 실제 탭 영역이 손가락보다 훨씬
작았다. Text를 Padding(EdgeInsets.symmetric(horizontal: 4, vertical: 10))으로
감싸서 실제 히트 영역을 넓혔다.

댓글 신고/차단 — 기존 showModerationMenu에 새 대상만 추가

작품·유저 신고는 이미 있었으니, ReportTarget에 comment를 추가하고 그 위에
얹었다.

  1. reports.target_type check 제약에 'comment' 추가(마이그레이션
    20260921080000_reports_comment_target.sql, supabase db push로 반영).
    RLS는 손 안 댔다 — reports insert own 정책이 target_type을 안 가리는
    범용 정책이라 그대로 통과.
  2. 댓글 목록(comments_section.dart)에서 내 댓글이 아니면 showModerationMenu (target: ReportTarget.comment, targetId: c.id, blockUserId: c.userId)를
    불러 신고/차단 메뉴를 그대로 재사용. MoreButton처럼 버튼을 따로 두지 않고
    "댓글 행을 꾹 누르면 뜨게" 해달라는 요청으로 바뀌었다(아래 참고).
  3. 관리자 "신고 관리"(supabase_admin_repository.dart)는 target_type별로
    product/user만 분기하던 구조라, 댓글 신고를 추가해도 그 목록엔 안 보였을
    것 — comments 테이블에서 신고된 댓글 본문·작성자를 같이 읽어와
    ReportItem.targetTitle(댓글 본문)/targetSubtitle(작성자 닉네임)로
    채우는 분기를 추가했다. 작품이 이미 내려가 댓글을 못 읽으면(comments의
    select 정책이 "작품이 published일 때만"이라 관리자도 예외 없음) 다른
    신고 대상들과 같은 관용으로 조용히 목록에서 뺀다. 신고 행을 탭하면
    댓글 작성자 프로필로 이동(banUserId 재사용 — 유저 신고 케이스와
    자연스럽게 겹침).
  4. ReportTarget.values가 DB check 제약과 정확히 같은지 잠가두는
    테스트(moderation_test.dart)도 'comment' 추가해서 갱신.

screenshots/11-admin-comment-report.png

메뉴 노출 방식이 세 번 바뀌었다 — 버튼 → 행 전체 꾹 누르기 → 터치 피드백

  1. 처음엔 MoreButton("···") 아이콘을 댓글마다 붙였는데, "UI 말고 꾹 누르면
    뜨게" 요청으로 버튼을 없애고 GestureDetector.onLongPress로 바꿨다.
  2. 처음엔 닉네임+본문 영역만 꾹 누르기 대상이었는데 "탭 영역을 전체 행으로"
    요청으로 아바타·좋아요·답글·삭제 버튼까지 포함한 행 전체로 넓혔다 — 안의
    버튼들은 각자 onTap이라 짧게 탭하는 동작과는 안 겹친다.
  3. "터치 피드백이 보였으면 좋겠다"는 요청으로 _CommentRow를
    StatefulWidget으로 바꿔 pressed 상태를 들고, 눌려 있는 동안
    AnimatedContainer로 배경색을 바꿨다. 이후 세 번 더 손을 봤다:
    • "밝게 말고 어둡게" — 처음엔 tk.card(밝은 카드 배경)를 썼는데, 라이트
      모드에서 배경보다 오히려 더 밝아 보였다. 반투명 검정(Colors.black. withValues(alpha: ...))으로 바꿔서 라이트/다크 어느 테마든 항상
      배경보다 어둡게 눌리게 했다.
    • "그냥 아예 안 보여" — alpha .06이 너무 옅어서 .15로 올렸다.
    • "누르는 동안 피드백이 있어야지" — 진짜 원인은 alpha가 아니라 트리거
      시점이었다. onLongPressStart는 롱프레스가 인식된 뒤(기본
      500ms)에만 불려서, 누르고 있는 동안(인식 전까지)은 계속 아무 반응이
      없었다. onTapDown(손가락이 닿는 즉시)으로 켜고 onTapUp/
      onTapCancel로 끄는 방식으로 바꿨다 — onTapCancel은 롱프레스가
      아레나에서 이겨 Tap 인식기가 질 때도 불리는데, 그 타이밍엔 신고 시트가
      바로 뜨니 자연스럽게 넘어간다.

커밋 363797c(신고/차단+탭 영역), 2d7bf86(버튼→꾹 누르기),
7277071(영역 확대), 1e441f4(터치 피드백 추가), d4af739(어둡게),
a191883(alpha 상향), 387acec(onTapDown 전환)로 모두 membership-refund-ux
브랜치에 푸시 완료.

오늘 한 것 정리

  • iOS/Android 업데이트 감지 미표시 문제 원인 추적: 스토어 응답 자체는 정상(curl로
    확인), Android는 비공개 테스트 단계라 원래 감지 불가, iOS는 진단용으로 넣은 임시
    코드가 Upgrader 싱글턴을 깨서 생긴 문제였음을 실기기 로그로 확정
  • Generated.xcconfig 갱신 문제 우회 절차 정리: 기기 설치는 Xcode Run, 설정
    재생성은 flutter build ios --config-only로 역할 분리
  • 업데이트 안내 다이얼로그를 iOS 시스템 팝업 스타일로 완전히 새로 그림
    (UpgradeAlert.createState() 오버라이드 패턴, CupertinoButton +
    CupertinoDynamicColor)
  • updateAvailableTitle 로컬라이즈 키 16개 언어 전체 추가, flutter gen-l10n 실행
  • 커밋 0516fb3으로 membership-refund-ux 브랜치에 푸시 완료
  • 안드로이드 위젯(종이/코랄/밤)에 widgetFeatures="reconfigurable" 추가해 이미
    배치된 위젯도 롱프레스로 재설정(말씀 고정) 화면을 다시 열 수 있게 수정, 커밋
    354e4b5 푸시 완료
  • 관리자 공지/푸시 발송 시 FCM이 "Message is too large(4096바이트 초과)" 400으로
    실패하던 문제 수정: send-push 함수에 UTF-8 바이트 경계로 자르는
    truncateUtf8 추가(title 200바이트/body 3000바이트 제한), 클라이언트 입력
    제한은 의도적 정책이라 그대로 둠. supabase functions deploy로 배포, 커밋
    b937c5a 푸시 완료 — deno run으로 최악의 경우(16,400바이트 원본)까지
    4096바이트 이내로 들어옴을 로직 검증까지 완료
  • ShoreBird(OTA 코드 푸시) 로그인·init 완료: shorebird.yaml 생성(app_id 발급),
    pubspec.yaml assets 등록, AndroidManifest.xml에 INTERNET 권한 명시 추가,
    shorebird doctor 이슈 0개. 첫 shorebird release/patch는 다음 정식 스토어
    빌드 때 같이 진행하기로 결정. 커밋(ShoreBird 도입) 푸시 완료
  • 홈 화면 안내 팝업 이미지 잘림(BoxFit.cover→contain) 수정, 탭하면
    InteractiveViewer 전체화면 뷰어 추가
  • 홈 화면 안내 팝업이 이미지 2장 이상이면 다이얼로그 자체가 안 그려지던 버그 수정:
    AlertDialog의 IntrinsicWidth가 PageView의 고유 너비를 못 구해 조용히
    레이아웃이 깨졌던 것 — SizedBox(width: double.maxFinite)로 회피. 본문이 길 때
    스크롤 가능함을 알려주는 Scrollbar(thumbVisibility: true)도 추가. 커밋
    0de192f 푸시 완료
  • 일지 파일에서 git mv가 새 파일 add로 잘못 인식돼 빠졌던 섹션 5(ShoreBird 도입)
    내용을 커밋 ac6b30d로 복구
  • 댓글 작성/답글/삭제 시 productProvider도 같이 무효화해 상세 화면 댓글 카운트가
    실시간으로 반영되게 수정
  • 작품 상세 화면에 당겨서 새로고침(RefreshIndicator) 추가 — 홈 피드와 같은 패턴.
    커밋 13c25a5 푸시 완료
  • 좋아요/댓글 숫자(_CountText) 탭 영역이 숫자 글자 크기만큼만 히트되던 걸
    패딩으로 넓힘
  • 댓글 신고/차단 기능 추가: ReportTarget.comment + DB check 제약 마이그레이션,
    댓글 행을 꾹 누르면(버튼 없이) showModerationMenu 재사용, 관리자 신고 목록도
    댓글 본문·작성자를 보여주도록 확장. moderation_test.dart의 enum-DB 제약
    일치 테스트도 갱신
  • 댓글 꾹 누르기에 터치 피드백(반투명 검정 배경) 추가 — 범위를 행 전체로
    넓히고, 색을 밝은 카드색→반투명 검정으로, alpha .06→.15로, 트리거를
    onLongPressStart(인식 후 500ms)→onTapDown(닿는 즉시)으로 바꿔가며
    네 번 다듬음. 커밋 363797c/2d7bf86/7277071/1e441f4/d4af739/
    a191883/387acec 전부 푸시 완료

남은 것

  • 맨 처음 "오늘 안 떴다"고 관찰됐던 원본 앱의 정확한 원인은 로그를 못 잡아서 여전히
    미해결 — 재현되면 이번처럼 디버그 로그부터 다시 볼 것
  • Android는 Play Console 프로덕션(공개) 등록 전까지 업데이트 감지 자체가 구조적으로
    불가능 — CLAUDE.md에 이미 남아있는 "Android: Play Console 등록 후 결제 확인"과 동일
  • flutter build가 project.pbxproj를 다시 쓰면서 포맷을 다운그레이드하는 부작용이
    반복 재현됨 — 근본 해결책은 없고, iOS 빌드할 때마다 커밋 전 diff 확인이 유일한 대응
  • 위젯 롱프레스 메뉴에 "편집"이 실제로 노출되는지는 실기기 재설치 후 확인 필요
    (XML 속성만 추가한 상태, 시뮬레이터/실기기 검증은 사용자 몫)
  • FCM 잘라내기 로직·최악의 경우 페이로드 크기는 deno run으로 검증 완료
    (16,400바이트 원본 → 최종 요청 3,767/4096바이트, 멀티바이트 문자 깨짐 없음).
    실제 기기에 공지 푸시가 정상 도착하는지만 사용자 확인 필요
  • ShoreBird 첫 release/patch는 다음 정식 스토어 빌드(버전업) 때 같이 진행
  • 홈 화면 안내 팝업(이미지 잘림 수정·전체화면 보기·다이얼로그 렌더링 버그·스크롤바)은
    Ruach 계정 실기기로 전부 확인 완료
  • 댓글 카운트 수정·상세 화면 당겨서 새로고침은 코드 검증(dart analyze+
    flutter test)만 끝났고, 실기기 확인은 사용자 몫
  • 좋아요/댓글 탭 영역 확대, 댓글 신고/차단, 터치 피드백은 전부 실기기로
    확인 완료("잘된다")
profile
https://github.com/NohYeongtaek

0개의 댓글