1.4.1 업데이트

노영택·2026년 9월 30일

아틀리에316

목록 보기
27/27

v1.4.1 데이터 보안 선언, 계정 삭제 페이지, 결제 기록 5년 보관, 탈퇴 작가 소장 보존, 비트맵 다운샘플링, Flutter 3.47과 AGP 9, 광고 후 검은 화면, 1.4.1 릴리스

1.4.0을 제출하고 한숨 돌리려는데 Play Console에서 거부 메일이 왔다. "데이터 보안 — 이메일 주소 선언되지
않음". 선언 한 칸 고치면 끝날 줄 알았는데, 고치려고 보니 계정 삭제 안내 페이지가 없었고, 삭제 페이지를
쓰려고 보니 결제 기록이 탈퇴와 함께 통째로 사라지는 구조였고, 그걸 검증하다 보니 작가가 탈퇴하면 구매자
소장품까지 날아가는 버그가 나왔다. 거기에 Play 권장 조치 두 개(비트맵, AGP 9)를 처리하다 Flutter를 3.38에서
3.47로 올리게 됐고, 실기기 확인 중에 스토어 버전에도 있던 iOS 광고 검은 화면을 잡았다. 결국 iOS 1.4.0은
심사에 넣기 전이라 1.4.1로 바꿔 한 번에 내보냈다.

1. Play가 "이메일을 수집하면서 선언 안 했다"며 거부했다

작성: 2026-09-30

걸린 건 이메일 하나였지만 빠진 건 더 많았다

거부 사유는 이메일 하나였다. 그런데 이메일만 추가하면 다음 검토에서 다른 항목으로 또 거부될 게 뻔해서,
앱이 실제로 무엇을 보내는지 코드부터 다시 훑었다. pubspec.yaml 의존성과 lib에서 쓰는 컬럼명을 grep하니
생각보다 많았다.

데이터어디서공유(제3자)
이메일이메일 가입(signUp/signInWithPassword), Google·Apple 로그인, PayPal 정산-
이름닉네임(가입 필수), 국내 정산 본인인증 실명(identity_verified_name)-
전화번호·생년월일·성별PortOne 본인인증 WebView로 기기 밖으로 전송(우리 DB엔 실명만 저장)-
구매 기록, 정산 계좌인앱 결제, payout_account-
사진·동영상, 댓글·문의작가 업로드, 사용자 콘텐츠-
검색어피드·말씀 검색(서버로 전송, 저장 안 함 → "임시 처리")-
기기 ID, 대략적 위치, 앱 상호작용, 진단google_mobile_ads(AdMob), FCM 토큰AdMob

Play 안내문에 "WebView를 통해 전송되는 데이터"와 "라이브러리가 기기 밖으로 보내는 데이터"도 선언 대상이라고
적혀 있어서, 우리 DB엔 저장하지 않는 전화번호·생년월일까지 넣어야 했다. Supabase·Firebase·Gemini·PortOne은
우리 대신 처리하는 "서비스 제공업체"라 Google 기준으로 "공유"가 아니고, 공유는 AdMob 쪽 항목만 체크했다.
AdMob 항목의 목적은 Google이 공개한 AdMob 데이터 공개 가이드를 그대로 따랐다.

제출 직전에 개인정보처리방침 URL이 엉뚱한 곳이었다

미리보기 맨 아래를 보다가 개인정보처리방침 URL이 우리 사이트가 아니라 8월에 만든 flycricket 문서라는 걸
발견했다. 그 문서엔 AdMob도 광고도 없다. 거부 사유가 "방침과 선언이 일치해야 한다"였으니, 이대로 냈으면
선언을 아무리 잘 써도 또 거부됐을 거다. 앱 안의 링크(legalDocsUrl)는 이미 우리 사이트를 가리키고 있었는데
Play Console만 옛날 주소였다. #privacy 앵커로 바꿔서 제출했다.

2. 계정 삭제 안내 페이지가 없었다

작성: 2026-09-30

Play는 계정 삭제 요청 URL도 요구한다. 앱을 이미 지운 사용자도 요청할 수 있어야 해서다. 우리 안내 사이트
(atelier316-info, GitHub Pages)엔 방침 안에 "문의처로 연락하면 삭제한다" 두 줄뿐이었고, Google이 요구하는
"앱 이름, 삭제 단계, 지워지는 데이터와 남는 데이터·보관 기간"을 못 채웠다.

실제 탈퇴 흐름부터 코드로 확인했다. requestDeletion()이 deletion_requested_at만 찍고, 30일 안에 다시
로그인하면 복구, 30일이 지나면 purge-accounts Edge Function이 auth.users를 지우고 나머지는 전부
on delete cascade로 따라 지워진다. 이걸 그대로 #delete-account 섹션(한·영)으로 옮겼다 — 앱에서(마이 →
계정 삭제 → 삭제 요청), 앱 없이(가입 이메일로 메일), 삭제되는 데이터, 보관될 수 있는 데이터.

같이 방침도 보강했다. AdMob 수집 항목, 사용자 콘텐츠, 푸시 토큰, 검색어, 본인인증 휴대전화번호를 넣고,
처리 위탁 업체(Supabase·Firebase·Gemini·PortOne/KG이니시스)를 명시했다. 원래 있던 "제3자에게 제공하지
않습니다"는 AdMob 공유와 정면으로 모순이라 "위 경우 외에는"으로 바꿨다.

3. 탈퇴하면 결제 기록까지 사라지는 구조였다

작성: 2026-09-30

방침엔 "법령에 따라 보관"이라 써놓고 실제론 다 지웠다

삭제 페이지를 쓰다 보니 방침의 "거래 기록은 관계 법령에 따라 보관" 문장이 걸렸다. 실제로는 30일 뒤
purchases·payouts가 users cascade로 전부 지워진다. 통신판매업자라 전자상거래법 시행령 제6조(대금 결제·
청약철회 기록 5년)가 적용되고, 작가 정산은 세무 증빙으로도 5년이 필요하다.

더 찾아보니 결제 행이 사라지는 길이 셋이었다.

경로무엇이 지우나
계정 탈퇴purge-accounts → auth.users 삭제 → cascade
환불apple-webhook/google-webhook이 purchases 행을 delete
작가 탈퇴products cascade → 그 작품을 산 다른 사람의 purchases까지

purge 함수가 아니라 "지워지는 순간"에 복사한다

purge 함수만 고치면 환불 경로가 빠진다. 그래서 retained_transactions 테이블을 새로 만들고, 행이 지워지는
시점의 트리거에서 복사하게 했다(개인정보보호법 제21조의 분리 보관). 남기는 건 거래 ID·금액·일시·이메일
(정산은 본인인증 실명)뿐이고 닉네임·댓글·작품은 안 남긴다. retain_until 기본값을 now() + 5 years로 두고
pg_cron으로 매일 KST 04:30에 기한 지난 행을 실제로 지운다.

걸린 건 이메일이었다. 이메일은 auth.users에만 있는데, cascade가 purchases까지 내려왔을 땐 auth.users
행이 이미 지워진 뒤라 조회가 안 된다. 그래서 계정 삭제 경로는 auth.users BEFORE DELETE 트리거에서
cascade가 시작되기 전에 먼저 복사하고, 환불 경로는 purchases AFTER DELETE 트리거가 맡고, 멤버십 환불은
users의 unlimited_free_download_txn이 null로 바뀌는 AFTER UPDATE 트리거가 맡는다. 중복은
on conflict do nothing으로 막았다. RLS는 is_admin() select만 두고 쓰기 정책은 없다.

검증하다 멤버십 두 번째 결제가 사라지는 걸 찾았다

테스트 계정 두 개(구매자·판매자)로 환불 → 멤버십 환불 → 멤버십 재구매 → 정산 → 판매자 삭제 → 구매자 삭제를
실제로 돌렸다. 설계 단계에서 멤버십은 source_id가 users.id라 "환불 후 재구매"면 두 결제의 키가 같아
두 번째가 on conflict에 버려진다는 걸 발견해서, 키에 거래 ID를 넣고(unique nulls not distinct (kind, source_id, transaction_id)) 후속 마이그레이션으로 고쳤다. 다섯 건이 전부 맞는 사유·이메일·retain_until 2031-09-30으로 남았고, anon 키로는 조회 [], insert는 42501로 막혔다.

실수도 하나 했다. 유니크 키를 고치면서 이미 원격에 적용된 060000 파일까지 sed로 같이 바꿨다. 적용된
마이그레이션 파일이 DB와 달라지면 나중에 누가 봐도 헷갈리니, 원복하고 수정은 061000에만 남겼다.

4. 작가가 탈퇴하면 구매자 소장품이 사라졌다

작성: 2026-09-30

위 검증 중에 재현됐다

판매자 계정을 지운 뒤 구매자의 소장 목록을 조회했더니 비어 있었다. products.seller_id가 cascade라 작가
계정이 지워지면 작품 → 그 작품의 purchases까지 따라 지워진다. guard_product_hard_delete는 팔린 작품
삭제를 막지만 auth.uid()가 있는 호출만 막아서, 서버(service_role)의 purge는 그냥 통과했다. 돈을 낸
구매자가 작품을 잃는 버그였다.

seller_id를 null로 풀면 앱이 죽는다

제일 쉬운 건 FK를 on delete set null로 바꾸는 거지만, 앱이 row['seller_id'] as String으로 읽는다.
이미 배포된 1.4.0까지 그 작품을 여는 순간 크래시한다. 그래서 "탈퇴한 작가" 시스템 계정을 하나 두고 팔린
작품을 그리로 옮겼다.

  • auth.users에 고정 UUID 00000000-0000-0000-0000-000000000316 행을 마이그레이션으로 직접 insert
    (banned_until = infinity, 비밀번호 없음 → 로그인 불가). handle_new_user 트리거가 public.users를 만들고,
    닉네임은 10자 제한 + 15개 언어 공용이라 언어 중립적인 —.
  • 3번의 auth.users BEFORE DELETE 함수 맨 앞에서 팔린 작품의 seller_id를 시스템 계정으로, status를
    deleted(작가가 직접 지울 때 쓰는 기존 상태)로 바꾼다. 피드·프로필엔 안 나오고 구매자는 계속 소장·적용한다.
  • 시스템 계정 자체를 지우려 하면 system_account_cannot_be_deleted로 막는다.

재밌는 건 이 계정을 auth admin API로 지우려 하면 "User not found"가 뜬다는 거였다. instance_id가 null이라
GoTrue가 인식을 못 한다. 실수로 API로 지워질 일도 없어서 오히려 잘됐다.

5. Play 권장 조치: 비트맵을 원본 해상도로 읽고 있었다

작성: 2026-09-30

Play가 LockOverlayActivity에서 BitmapFactory.decodeFile을 옵션 없이 쓴다고 짚었다. 찾아보니
PopularVerseWidgetProvider의 작품위 위젯도 똑같았다. 위젯은 RemoteViews로 런처 프로세스에 비트맵을
넘기는데 IPC 크기 한도가 있어서, 원본 그대로면 큰 이미지가 조용히 안 뜰 수 있다.

공용 함수 decodeSampledBitmap을 만들었다. inJustDecodeBounds = true로 크기만 먼저 읽고, 목표 크기
이상을 유지하는 최대 2의 거듭제곱을 inSampleSize로 준다. 잠금 오버레이는 화면 크기, 위젯은 처음에
가로·세로 둘 다 화면 절반으로 잡았다가 — 사용자가 "화질 저하 안 돼?"라고 물어서 다시 보니 대형 위젯은
화면 너비를 꽉 채운다. 큰 원본이면 가로 540까지 줄어 늘려 그릴 때 흐려진다. 가로는 전체 너비로 고쳤다.
결과가 목표 이상일 때만 줄이니 지금 작품(1080×2304 전후)은 사실상 원본 그대로 읽힌다.

6. AGP 9로 가려면 Flutter부터 3.47이어야 했다

작성: 2026-09-30

전제 조건이 생각보다 컸다

Play의 두 번째 권장 조치는 "AGP 9.0 이상으로 올려 최적화된 리소스 축소를 켜라"였다. 알아보니 Flutter는
3.44부터 AGP 9를 지원하고, 3.47 템플릿 기준이 AGP 9.1.0 · Gradle 9.3.1 · Kotlin 2.4.0이었다. 우리는
Shorebird 때문에 3.38.9에 고정돼 있었고, 9/26에 실수로 3.47.4로 빌드했다가 iOS가 깨져 +23을 버린 전례까지
있었다. 사용자가 "안 되면 되돌리면 돼"라고 해서 시작했다. 전역 Flutter를 건드리지 않으려고 fvm으로
3.47.5만 따로 받았다(fvm spawn 3.47.5 ...).

플러그인 셋이 AGP 9를 못 따라왔다

settings.gradle.kts·wrapper·kotlinOptions → kotlin { compilerOptions }를 템플릿대로 바꾸고 빌드하니
하나씩 터졌다. 세 개 다 최신 버전에서도 그대로라 업데이트로는 안 됐다.

문제대응
flutter_inappwebview_android(PortOne 본인인증 WebView)가 금지된 proguard-android.txt 참조android.r8.proguardAndroidTxt.disallowed=false 임시 플래그
플러그인 13개가 kotlin-android를 직접 적용 → built-in Kotlin을 켜면 빌드 실패android.builtInKotlin=false(템플릿 기본값) 유지
screen_protector는 "AGP 9면 built-in Kotlin"이라 가정해 KGP를 안 붙임 → Kotlin 소스 미컴파일루트 build.gradle.kts에서 이 프로젝트에만 pluginManager.withPlugin으로 KGP 적용
portone_flutter가 compileSdk 34 고정, app_links는 36 요구 → AAR 메타데이터 검사 실패루트에서 afterEvaluate로 이 라이브러리의 compileSdk만 36
iOS에서 3.47이 SPM 연동을 자동으로 붙이다 바이너리 캐시 충돌pubspec.yaml의 enable-swift-package-manager: false, CocoaPods 유지

포크 대신 전부 Gradle 설정 몇 줄로 우회했고, 지울 조건을 ponytail: 주석으로 남겼다.

iOS에서 "hitTestTransform"이 없다고 했다

SPM을 끄고 나서도 iOS 빌드가 semantics.dart: Required named parameter 'hitTestTransform'으로 죽었다. 프레임워크
소스와 엔진 버전이 섞였다는 뜻이다. .dart_tool/package_config.json을 보니 generator가 3.10.4, 즉 전역
3.38
이 다시 만든 파일이었다. 같은 폴더를 열어둔 Android Studio가 내가 pubspec.yaml을 고치자마자 전역
Flutter로 pub get을 돌린 거였다. 앞서 통과했던 Android 빌드도 믿을 수 없게 됐다.

작업을 별도 워크트리(.claude/worktrees/flutter-347)로 옮기고 메인 폴더는 dev로 되돌렸다. 워크트리에서
analyze·테스트 117개·apk·aab(release 키 서명 확인)·iOS 빌드를 전부 다시 돌려 통과했다.

실기기 확인이 끝난 뒤 ~/flutter를 3.47.5 태그로 옮기고(git checkout -B stable 3.47.5, Android Studio는
경로를 그대로 쓰니 설정 변경 없음) dev에 합쳤다. 합칠 때도 IDE가 lock 파일을 끼워 넣어 병합이 한 번 막혔다.
IDE를 열어둔 폴더에서 Flutter 버전을 바꿀 땐 이게 계속 따라다닌다.

7. 광고를 보고 리워드를 받으면 화면이 까매졌다

작성: 2026-09-30

업그레이드 탓인 줄 알고 한참 엉뚱한 데를 팠다

3.47 실기기 확인 중 iPhone에서 "광고를 보고 리워드를 받으면 검은 화면에서 안 움직인다"는 리포트가 왔다.
Dart 코드는 하나도 안 바뀌었으니 엔진 문제라고 봤고, Flutter 3.41부터 기본이 된 UIScene 수명주기를 의심했다.
우리 AppDelegate는 커스텀 코드가 있어 자동 전환이 안 됐으니 그럴듯했다. 그런데 사용자가 확인해 준 사실이
판을 뒤집었다.

  • 릴리스 모드에서도 똑같다 → 디버그 전용 문제가 아니다.
  • 스토어 1.4.0(Flutter 3.38)에서도 똑같다 → 업그레이드와 무관한 원래 버그다.
  • 홈에 나갔다 와도 까맣고, 앱을 죽여야 돌아온다. 보상은 지급돼 있다.

Xcode 콘솔 한 줄이 답이었다

사용자가 붙여준 Xcode 콘솔에 이 줄이 있었다.

[SP003] disablePreventScreenshot
[SP001] disable result=true (suspended)

미구매 상세 화면은 캡처 차단(ScreenshotGuard → screen_protector)이 켜져 있다. iOS에서 이 패키지는 앱
화면을 보안 텍스트필드 레이어 안으로 옮겨서 캡처를 막는다. 문제는 순서였다. AdMobAdService.showRewarded는
onUserEarnedReward("리워드 받기")가 오자마자 true를 돌려줬는데, 그때 광고는 아직 떠 있다. 호출부가
바로 무료 획득 → 소장 상태 → ScreenshotGuard가 preventScreenshotOff() → 광고 창이 key window인 상태
("suspended")에서 해제가 처리되며 Flutter 화면이 보안 레이어에서 못 나왔다.

광고가 완전히 닫힌 뒤에 결과를 돌려주게

showRewarded 한 곳만 고쳤다. 보상 콜백은 rewarded = true만 찍고, 결과는
onAdDismissedFullScreenContent에서 돌려준다. 두 콜백 순서는 보장이 없어서, 닫힘이 먼저 오면 0.5초 동안
보상이 뒤따를 틈을 준다. 무료 다운로드와 언어 재적용 두 자리가 같은 함수를 쓰니 한 번에 둘 다 고쳐졌다.
사용자가 실기기에서 복귀를 확인했다.

돌아보면 "업그레이드 뒤에 보였으니 업그레이드 탓"이라는 가정이 제일 비쌌다. 스토어 버전 비교를 첫 질문으로
했어야 했다.

8. 1.4.1 릴리스, 그리고 1.4.0을 1.4.1로 바꾸기

작성: 2026-09-30

추출

pubspec.yaml을 1.4.1+31로 올리고 Shorebird로 릴리스했다. 이번부턴 --flutter-version=3.47.5다
(1.4.0 이하에 패치를 낼 땐 여전히 3.38.9). 지난번 debug 키 사고가 있어서 서명 줄과 지문부터 봤다.

플랫폼산출물확인
Androidapp-release.aab 74.8MBrelease 키, SHA1 FB:81:55…
iOSAtelier316.ipa 41.2MB1.4.1 (31), Apple Distribution 서명, pbxproj 변경 없음

1.4.0은 지울 필요가 없었다

사용자가 App Store Connect의 1.4.0을 지워야 하냐고 물었는데, 화면을 보니 상태가 "제출 준비 중"이었다.
심사 전이라 버전 번호를 1.4.1로 바꾸고 빌드만 1.4.1 (31)로 갈아 끼우면 된다. 스크린샷·설명은 그대로다.
그래서 패치노트도 1.4.0 내용(댓글 번역, 글꼴 확대, 위젯 무제, iPad 위젯)과 1.4.1을 합쳤다 — 스토어 기준으론
둘 다 처음 나가는 내용이다. 새 기능 → 개선 → 수정 순서로 16개 언어 × 3파일을 스크립트로 합쳤고,
Android본은 8개 언어가 Play 500자를 넘어서 "위젯 이미지 메모리 개선"을 "안정성·성능 개선" 한 줄로 묶어
최대 447자로 맞췄다.

로컬 StoreKit으로는 결제 검증이 안 된다

iOS 결제 확인을 안내하면서 Products.storekit을 쓰라고 했다가 receipt_invalid, apple_status_21002를 봤다.
로컬 StoreKit 영수증은 Xcode가 로컬에서 서명해서 애플 서버가 읽지 못한다. 결제창이 뜨는지만 볼 수 있고,
검증까지 보려면 스킴의 StoreKit Configuration을 None으로 두고 샌드박스 테스터 계정으로 결제해야 한다
(9/8에도 그렇게 검증했었다). 샌드박스로 다시 해서 구매가 정상 처리되는 걸 확인했다.

To-Do

이번 버전에서 한 것

  • Play 데이터 보안 선언: 코드 기준으로 수집 데이터 14종 재정리(WebView 전송분·임시 처리 검색어 포함), AdMob 항목만 공유, 계정 생성 방법(사용자 이름+비밀번호, OAuth) — 제출 완료
  • Play Console 개인정보처리방침 URL을 flycricket → atelier316-info/#privacy로 교체
  • atelier316-info: #delete-account 섹션(한·영) 추가, 방침에 AdMob·사용자 콘텐츠·푸시 토큰·검색어·본인인증 휴대전화·처리 위탁 업체 보강
  • retained_transactions 5년 분리 보관(마이그레이션 060000·061000): auth.users BEFORE DELETE / purchases AFTER DELETE / 멤버십 환불 트리거, pg_cron 일일 파기, 관리자만 읽기 — 실제 테스트 계정으로 5개 경로 검증 후 정리
  • 탈퇴 작가의 팔린 작품을 시스템 계정(…000316, 닉네임 —)으로 옮겨 구매자 소장 보존(마이그레이션 070000) — 재현·수정 검증 완료, CLAUDE.md·backend-design.md 갱신
  • Android 비트맵 다운샘플링(decodeSampledBitmap, inJustDecodeBounds + inSampleSize) — 잠금 오버레이·위젯, 위젯 가로 기준 전체 너비로 수정
  • Flutter 3.38 → 3.47.5, AGP 8.11 → 9.1.0, Gradle 8.14 → 9.3.1, Kotlin 2.2 → 2.4.0 — 플러그인 3개 Gradle 우회, iOS SPM 끄고 CocoaPods 유지, 실기기 확인 후 dev·main 병합, 전역 ~/flutter 3.47.5 전환
  • iOS 리워드 광고 후 검은 화면 수정(showRewarded가 광고가 닫힌 뒤에 결과 반환) — 스토어 1.4.0에도 있던 버그, 실기기 확인
  • 1.4.1+31 버전업, Shorebird 릴리스(Android·iOS, Flutter 3.47.5), 서명 확인, dev → main 병합
  • 패치노트 16개 언어 × 3파일(1.4.0 내용 합침, Android 500자 이내로 압축)

남은 것

  • 출시 전 필수: App Store Connect에서 1.4.0 → 1.4.1로 버전 번호 변경, ipa 업로드 후 빌드 1.4.1 (31) 선택, 패치노트 교체 후 심사 제출
  • 출시 전 필수: Play Console에 1.4.1 (31) aab로 새 버전 제출(1.4.0 (30) 대체), 데이터 보안 선언 재검토 결과 확인
  • AGP 9 임시 우회 걷어내기: flutter_inappwebview_android가 proguard 파일을 고치면 플래그 삭제, 플러그인들이 built-in Kotlin으로 옮겨가면 builtInKotlin=true + screen_protector 보정 삭제, portone_flutter가 compileSdk를 올리면 보정 삭제
  • iOS SPM 전환(Flutter가 나중엔 끄는 옵션을 없앤다고 예고), UIScene 전환(Xcode 27부터 필수)
  • upload.dart의 cacheExtent deprecated 안내 정리
  • 시스템 계정으로 옮긴 작품의 구매자가 전부 탈퇴하면 행·원본 파일이 고아로 남는 문제(정리 크론)
  • 세무사에게 retained_transactions 보관 항목 확인받기
profile
https://github.com/NohYeongtaek

0개의 댓글