아틀리에316 (1.0.1+16) 업데이트

노영택·2026년 9월 13일

아틀리에316

목록 보기
20/28

2026-09-12 인기 말씀 홈 화면 위젯, objective_c.framework 크래시, WidgetKit 이미지 아카이브 한도, 워터마크 정책

오늘은 하루 종일 인기 말씀 홈/잠금화면 위젯(§4-10) 하나만 팠다. 시안(316 위젯 시안.dc.html)대로 종이·작품위·코랄·밤 4스타일을 iOS(WidgetKit)·Android(RemoteViews) 양쪽에 구현하는 것 자체는 크게 어렵지 않았는데, "새로고침 버튼이 안 보인다"·"이미지가 안 뜬다"는 리포트가 계속 이어지면서 결국 두 개의 완전히 다른 실제 버그(하나는 Flutter 플러그인 의존성 문제, 하나는 iOS 시스템 레벨 제약)를 각각 실기기 로그로 끝까지 추적해서 찾아냈다. 마지막엔 "판매자 워터마크가 위젯에 그대로 보이는데 위젯이 작으니 원본을 공개해도 되지 않냐"는 제안이 나와서, 그건 화질 문제가 아니라 보안 정책 문제라는 걸 짚고 대신 시각적으로 절충했다.

1. 4스타일 위젯 구현 (종이·작품위·코랄·밤)



home_widget: ^0.7.0 플러그인으로 Flutter → 네이티브 데이터 전달만 하고, 실제 레이아웃·랜덤 선택·타이머는 양쪽 네이티브가 각자 갖는 구조로 잡았다.

  • iOS: PopularVerseWidget.swift 하나에 WidgetStyle(색만 다른 값 묶음) 4개를 각각 끼워 StaticConfiguration으로 위젯 4개(PopularVerseWidgetPaper/Coral/Night/Artwork)를 등록. TimelineProvider가 App Group(group.com.ruach.threeonesix.threeonesix)의 UserDefaults에서 JSON을 읽어 랜덤으로 하나를 고른다.
  • Android: PopularVerseWidgetProvider.kt에 BasePopularVerseWidgetProvider(공통 로직) + 구체 클래스 4개(AndroidManifest.xml에 리시버로 각각 등록). RemoteViews는 허용된 View 클래스만 써야 해서(<View>는 InflateException으로 죽는다) 레이아웃 XML의 모든 <View>를 <FrameLayout>으로 바꿔야 했다.
  • 자정 동기화: iOS는 Timeline(entries:, policy: .after(nextMidnight)), Android는 Intent.ACTION_DATE_CHANGED 브로드캐스트 + updatePeriodMillis="0"(주기 갱신 대신 자정 트리거에만 의존)로, 두 플랫폼이 정확히 00:00에 같이 바뀌게 맞췄다.
  • 작품위 배경 이미지: Flutter가 Supabase Storage에서 이미지를 내려받아 iOS는 App Group 컨테이너, Android는 앱 지원 폴더에 직접 파일로 써넣고(HomeWidget.renderFlutterWidget 대신 원본 바이트를 그대로 write — Flutter 위젯 렌더 파이프라인에 기대지 않아서 더 안정적), 그 파일 경로를 JSON에 같이 실어 보낸다.

중간에 실제로 겪은 자잘한 버그들:

  • HomeWidget.updateWidget을 androidName/iOSName 따로 두 번 부르면 PlatformException — 이 메서드 채널은 지금 실행 중인 플랫폼과 무관하게 자기 필드가 비어 있으면 무조건 에러를 던진다. 한 호출에 androidName+iOSName을 같이 넘기는 걸로 고쳤는데, 이 버그 때문에 예외가 syncPopularVerses를 끊어버려서 그 뒤에 있던 작품위 동기화 자체가 며칠째 호출된 적이 없었다 — 나중에 "이미지가 안 뜬다" 리포트의 숨은 배경 중 하나였다.
  • Kotlin 'public' function exposes its 'private-in-file' parameter type — WidgetColors를 파일-프라이빗으로 두고 그걸 받는 BasePopularVerseWidgetProvider 생성자를 internal constructor로 낮춰서 해결. 클래스 자체는 public을 유지해야 했다(Android가 매니페스트 기반으로 구체 클래스를 리플렉션으로 생성하기 때문).

2. 새로고침 버튼 + 탭-상세 이동

2x2/4x2/4x4 전부에 "다른 말씀으로" 새로고침 아이콘을 추가했다. iOS는 스와이프 제스처가 위젯에서 아예 지원되지 않아서(정적 타임라인 스냅샷이라 자체 팬 인식기가 없다) iOS 17+ AppIntent(ShuffleVerseIntent → WidgetCenter.shared.reloadTimelines(ofKind:)) 기반 버튼으로, Android는 ACTION_SHUFFLE 브로드캐스트로 구현했다.

작품위 스타일은 위젯 전체를 탭하면 해당 작품 상세 화면으로 이동하게 했다.

private var tapURL: URL {
  if style.flatColor == nil, let id = entry.id {
    return URL(string: "atelier316://product?id=\(id)&homeWidget=true")!
  }
  return URL(string: "atelier316://open")!
}

homeWidget 쿼리 파라미터가 있어야 home_widget 플러그인이 "위젯에서 온 탭"으로 인식해 HomeWidget.widgetClicked로 Dart에 넘겨준다(플러그인 소스로 직접 확인). Android는 HomeWidgetLaunchIntent.getActivity(context, MainActivity::class.java, targetUri)로 같은 역할을 한다. main.dart에 _handleWidgetTap(Uri?)을 만들어 host == 'product'면 /product/$id로 라우팅한다.

screenshots/03-widget-refresh-tap.png
screenshots/04-widget-tap-to-detail.png

3. "새로고침 버튼이 안 보인다" — 실제로는 세 가지가 겹쳐 있었다

작품위 스타일에서만, 크기별로 리포트가 여러 번 왔다. 순서대로:

  1. 대비 문제로 오인 — 밝은 사진 위에서 아이콘이 안 보이나 싶어 흰색+반투명 원 배경을 입혔다. 효과 없었음.

  2. 레이아웃 오버플로우로 오인 — 대형(4x4) 위젯의 긴 한국어 날짜가 HStack을 밀어서 아이콘이 화면 밖으로 나가는 거라 보고 layoutPriority, 그다음 .overlay() 포지셔닝으로 바꿔봤다. 그래도 안 됨.

  3. 사용자가 보고 있던 게 실제 배치된 위젯이 아니라 "위젯 추가" 갤러리 미리보기(캐시된 스냅샷)였던 경우가 섞여 있었다 — 이건 스크린샷을 다시 요청해서 구분해냈다.

  4. 진짜 원인은 대형(4x4) 레이아웃의 .overlay() 방식 자체가 깨져 있었던 것 — 이 시점에서 스스로 크게 반성한 게 있다. 여태 Android는 매번 ./gradlew로 실제 컴파일 검증을 했는데, iOS Swift는 괄호 개수만 세면서 "문법상 문제없음"으로 넘어가고 있었다. 사용자가 실기기에서 완전 삭제 후 재설치까지 해서 "그래도 안 보인다"고 재확인해줬을 때, 처음으로

    xcodebuild -workspace Runner.xcworkspace -scheme PopularVerseWidgetExtension \
      -sdk iphonesimulator -configuration Debug CODE_SIGNING_ALLOWED=NO build

    를 돌려서 실제 컴파일 결과를 봤다. 대형 레이아웃을 중형(이미 정상 동작 확인된)과 똑같은 인라인 HStack 패턴으로 되돌리자 그제야 해결됐다. 이후로 Swift도 항상 실제 xcodebuild로 검증하는 걸로 원칙을 바꿨다.

4. "이미지가 안 보인다"의 진짜 원인 — objective_c.framework

새로고침 버튼과 별개로, 작품위 배경 사진 자체가 몇 시간째 안 떴다. 캐싱 코드·App Group 엔타이틀먼트·imagePath JSON 필드까지 전부 봤는데 코드상 문제가 없었다. 사용자가 결국 전체 Xcode 콘솔 로그를 그대로 붙여줘서 거기서 실마리가 나왔다.

[ERROR] Unhandled Exception: Invalid argument(s): Couldn't resolve native function
'DOBJC_initializeApi' ... Failed to load dynamic library 'objective_c.framework/objective_c'

path_provider_foundation이 2.5.0부터 objective_c라는 dart:ffi 기반 패키지(Objective-C 런타임을 직접 호출)로 다시 쓰였다. 이 새 프레임워크가 앱 번들에 제대로 임베드돼야 하는데, 이 프로젝트는 네이티브 에셋 프레임워크를 우리가 직접 재서명하는 커스텀 빌드 단계(Resign Native Asset Frameworks, CLAUDE.md "iOS 빌드" 항목)가 있어서 이 새 프레임워크는 그 대상에 안 들어가 있었다 — 그래서 실기기에서 _cacheImage()가 컨테이너 경로를 구하려 할 때마다(=이미지 캐싱을 시도할 때마다) 매번 조용히 크래시했다.

curl https://pub.dev/api/packages/path_provider_foundation/versions/2.5.0

으로 objective_c 의존성이 정확히 이 버전부터 생겼다는 걸 확인하고, pubspec.yaml에 옛날 메서드 채널 방식인 2.4.4로 고정했다.

path_provider_foundation: 2.4.4  # 2.5.0 미만 고정 — objective_c.framework 임베드 문제 회피

flutter pub get 후 pubspec.lock에서 objective_c 패키지가 완전히 사라진 것, pod install 후 objective_c pod가 안 깔리는 것, 빌드 산출물 어디에도 objective_c.framework가 없는 것까지 확인했다.

screenshots/07-objective-c-crash-log.png

5. 화질을 높였더니 새 버그가 생겼다 — WidgetKit 이미지 아카이브 크기 상한

objective_c 문제를 고치고 나니 텍스트 위젯(종이·코랄·밤)은 바로 정상화됐는데, 작품위만 여전히 안 됐다. 캐싱 로그는 매번 성공이고, App Group 경로도 맞고, Swift 디코딩 코드도 정상 — 원인을 좁히려고 위젯 화면에 entry.imagePath 값을 직접 찍는 임시 디버그 텍스트를 넣었는데, 처음엔 그 텍스트조차 안 보였다. 배경 레이어에 넣었더니 그 위를 덮는 말씀 본문 텍스트에 가려서 안 보인 거였다(.overlay()로 최상단에 올리자 최소한 뭘 찍는지는 보였을 텐데, 그마저도 계속 안 보였다).

결국 Console.app으로 위젯 익스텐션 프로세스 로그를 직접 보고서야 진짜 원인이 나왔다.

Widget archival failed due to image being too large [1] - (800, 1422), totalArea: 1137600 > max[1069415.600000].

며칠 전 "화질 높여달라"는 요청으로 작품위 배경을 300px → 800px(16:9 기준 800×1422 = 113.7만 px²)로 올렸는데, WidgetKit은 위젯 안에 넣는 이미지의 총 픽셀 수에 내부 상한(약 106.9만 px²)을 둔다. 이 상한을 넘기면 시스템이 그 이미지를 위젯에 아예 못 실어서(archival 실패) 계속 대체(fallback) 화면만 보여준다 — 캐싱도, 경로도, Swift 코드도 전부 정상이었던 게 맞았다. 순수하게 픽셀 수 하나가 원인이었다.

// 700×1244(16:9) = 87.1만 px² — 한도(106.9만) 아래 여유를 두고 낮췄다.
// 300px보다는 확실히 선명하면서 안전.
plainPreviewUrl: _db.storage.from('previews').getPublicUrl(
  '${r['id']}/plain-preview.jpg',
  transform: TransformOptions(width: 700, height: (700 * 16 / 9).round(), quality: 90),
),

이 폭 상한(775px 전후, 16:9 기준)을 넘기면 안 된다는 걸 소스 코드 주석과 PopularVerseWidget.swift의 logArtworkFallback 주석에 같이 남겨서, 나중에 "화질 더 높여달라"는 요청이 다시 와도 바로 이 제약을 참조할 수 있게 했다.

screenshots/08-widget-archival-error.png

6. 판매자 워터마크 노출 — "위젯이 작으니 원본 공개해도 되지 않냐"는 제안 검토

위젯 이미지가 정상적으로 뜨고 나니, 작품위 배경 우측 하단에 판매자 워터마크(_paintWatermark, 흰 18% 불투명도)가 옅게 보이는 게 눈에 띄었다. "위젯은 어차피 작으니 원본(워터마크 없음)을 공개해도 되지 않냐"는 제안이 나왔는데, 이건 화질/크기 문제가 아니라 버킷 접근 정책 문제라서 짚었다.

원본(originals)은 지금 .download() + RLS로 "구매자 본인 또는 판매자만" 접근 가능하다. 위젯에 원본을 쓰려면 이 RPC(popular_products_for_widget)가 뽑는 인기 상품에 한해 정책을 "누구나 읽기 가능"으로 풀어야 하는데, 그러면 위젯 화면 크기와 무관하게 API 자체가 구매 없이 원본 전체 해상도를 내주는 통로가 생긴다. 게다가 "인기 상품"은 정의상 가장 잘 팔리는(=판매자가 가장 지키고 싶어할) 작품들이라, 무작위 상품이 아니라 하필 최상위권 작품들이 표적이 되는 셈이다. 이 트레이드오프를 설명하고 사용자가 직접 판단하도록 물었고, 정책은 그대로 유지하는 쪽으로 결정됐다.

대신 워터마크가 덜 도드라지게 보이도록 절충했다 — 작품위 배경 위에 이미 깔고 있던 어두운 스크림 그라디언트의 하단 불투명도를 78% → 90%로 올렸다(상단 42%는 "316"/날짜 가독성용이라 그대로). 워터마크 자체는 못 지우지만 그 위를 더 진하게 덮어서 잔상을 최대한 줄이는 방식.

// iOS: PopularVerseWidget.swift, backgroundView
LinearGradient(colors: [Color.black.opacity(0.42), Color.black.opacity(0.90)],
               startPoint: .top, endPoint: .bottom)
<!-- Android: widget_scrim.xml -->
<gradient android:type="linear" android:angle="270"
    android:startColor="#6B141311" android:endColor="#E6141311" />

두 플랫폼 다 같은 색(#141311)·같은 상단 불투명도(42%=0x6B)를 유지하면서 하단만 78%(0xC7)→90%(0xE6)로 맞췄다.

screenshots/05-widget-watermark-before-after.png

오늘 한 것 정리

  • 인기 말씀 홈/잠금화면 위젯 4스타일(종이·작품위·코랄·밤) iOS(WidgetKit)·Android(RemoteViews) 구현
  • 자정(00:00) 동기화 — iOS Timeline(policy: .after(nextMidnight)), Android ACTION_DATE_CHANGED
  • 2x2/4x2/4x4 새로고침 버튼(iOS AppIntent, Android ACTION_SHUFFLE), 작품위 위젯 탭 → 상품 상세 이동(atelier316://product?id=)
  • Android RemoteViews <View> → <FrameLayout> 치환(InflateException 수정), Kotlin internal constructor 가시성 수정
  • HomeWidget.updateWidget androidName/iOSName 동시 전달로 PlatformException 수정(작품위 동기화가 아예 호출 안 되던 숨은 원인)
  • 대형(4x4) 위젯 새로고침 버튼 미표시 — .overlay() 레이아웃 버그, 중형과 같은 인라인 HStack 패턴으로 복귀해 해결. 이 과정에서 Swift도 실제 xcodebuild로 검증하는 습관으로 전환
  • objective_c.framework dlopen 크래시(작품위 이미지가 며칠째 안 뜨던 진짜 원인) — path_provider_foundation을 2.4.4로 고정해 해결
  • WidgetKit 이미지 아카이브 픽셀 상한(약 106.9만 px²) 발견 — 위젯 배경 이미지 폭을 800px → 700px로 낮춰 해결
  • 판매자 워터마크 노출 — 원본 공개 정책 변경은 보안상 거부, 대신 스크림 하단 불투명도 78%→90%로 완화

남은 것

출시 전 필수

  • 없음(오늘 작업은 실기기 확인까지 완료)

그 외

  • 위젯 배경 이미지 폭 상한(약 775px, 16:9 기준)을 넘기면 WidgetKit 아카이브가 조용히 실패한다는 제약이 코드 주석에 남아있음 — 나중에 화질을 더 올려달라는 요청이 오면 이 상한부터 참조할 것
  • iOS Swift 위젯 익스텐션 코드는 앞으로도 브레이스 카운팅이 아니라 실제 xcodebuild로 검증할 것(오늘 자체 반성 후 확정한 원칙)
profile
https://github.com/NohYeongtaek

0개의 댓글