Flutter와 iOS WidgetKit 사이에 데이터 공유하기

Flutter와 iOS WidgetKit 사이에 데이터 공유하기

한눈에 보기

Flutter 앱과 WidgetKit extension은 같은 화면 트리나 메모리를 공유하지 않는다. 앱이 위젯 렌더링에 필요한 작은 스냅샷을 App Group container에 완성된 단위로 저장하고, 위젯은 이를 읽기 전용으로 소비하게 만든다. WidgetCenter의 reload는 즉시 다시 그리기 명령이 아니라 새 timeline을 요청하라는 신호이므로 오래된 값, 손상된 값, 첫 실행 상태도 정상적인 입력으로 다뤄야 한다.

목차

앱 화면을 그대로 위젯에 보여 줄 수 없는 이유

Flutter 앱에 이미 카드 화면이 있다고 해서 그 widget tree를 iOS 홈 화면 위젯에서 재사용할 수 있는 것은 아니다. Flutter의 Widget과 Apple의 WidgetKit은 이름만 비슷할 뿐 실행 모델이 다르다.

Flutter 앱은 Flutter engine 위에서 Dart 코드를 실행한다. WidgetKit extension은 시스템이 필요할 때 별도 수명주기로 실행하는 SwiftUI 기반 extension이다. 사용자가 앱에서 값을 바꾸는 순간 extension이 살아 있다는 보장도 없고, 위젯을 그릴 때 Flutter 앱 프로세스가 실행 중이라는 보장도 없다.

flowchart LR
    subgraph App["Flutter 앱 프로세스"]
        A[도메인 상태]
        B[Dart UI]
        C[Native bridge]
    end

    subgraph Shared["App Group container"]
        D[(snapshot.json)]
        E[(metadata defaults)]
        F[(thumbnail.webp)]
    end

    subgraph Extension["Widget extension 프로세스"]
        G[TimelineProvider]
        H[TimelineEntry]
        I[SwiftUI widget view]
    end

    A --> B
    A --> C
    C --> D
    C --> E
    C --> F
    D --> G
    E --> G
    F --> G
    G --> H --> I

두 실행 환경 사이에 싱글턴, provider, Riverpod container, in-memory cache를 두어도 extension에서는 볼 수 없다. 앱의 내부 데이터베이스 경로를 문자열로 넘기는 방법도 sandbox와 schema 결합 때문에 안전하지 않다.

이 문제를 풀 때 핵심은 위젯을 앱의 작은 원격 화면으로 보지 않는 것이다. 위젯은 시스템이 보관한 timeline을 바탕으로 제한된 시간 안에 렌더링하는 독립 소비자다.

경계의 방향

앱이 자기 전체 상태를 공유하는 것이 아니라, 위젯이 렌더링하는 데 필요한 최소한의 읽기 모델을 만들어 공유한다.

이 글의 예제는 특정 서비스의 실제 코드를 사용하지 않는다. “프로필의 오늘 기록 수와 최근 썸네일을 홈 화면에 표시한다”는 가상의 요구사항으로 재구성했다.

공유해야 하는 것은 상태가 아니라 스냅샷이다

앱의 도메인 모델을 그대로 직렬화하면 처음에는 빠르지만 위젯이 앱 내부 구조에 강하게 묶인다.

// 좋지 않은 예: 앱 내부 엔티티 전체를 extension 계약으로 사용한다.
struct ProfileEntity: Codable {
    let id: String
    let ownerAccountId: String
    let remoteSyncToken: String
    let records: [RecordEntity]
    let featureFlags: [String: Bool]
    let internalUpdatedAt: Date
}

위젯에 필요한 것이 이름, 기록 수, 업데이트 시각, 썸네일뿐이라면 별도의 projection을 만든다.

struct WidgetSnapshot: Codable, Equatable {
    let schemaVersion: Int
    let revision: Int
    let profile: ProfileSummary
    let generatedAt: Date
    let image: SharedImageReference?
}

struct ProfileSummary: Codable, Equatable {
    let displayName: String
    let todayCount: Int
    let statusText: String
}

struct SharedImageReference: Codable, Equatable {
    let relativePath: String
    let checksum: String
}

이 스냅샷에는 다음 성질이 필요하다.

statusText처럼 이미 표현된 문자열을 저장할지, enum과 값을 저장해 extension에서 문구를 만들지는 요구사항에 따라 다르다. 다국어가 중요하다면 locale이 바뀌었을 때 오래된 번역문이 남지 않도록 의미 값과 localization key를 공유하는 편이 낫다. 반대로 포맷 규칙이 복잡하고 앱과 위젯이 늘 같은 표현을 보여 줘야 한다면 앱이 표시 문자열을 계산할 수도 있다.

전체 데이터 흐름 설계

앱에서 값이 바뀌었다고 무조건 reload부터 호출하면 위젯이 이전 데이터를 다시 읽을 수 있다. 순서를 명시적으로 정한다.

sequenceDiagram
    participant Flutter as Flutter domain
    participant Bridge as MethodChannel bridge
    participant Writer as iOS SnapshotWriter
    participant Group as App Group
    participant Center as WidgetCenter
    participant Provider as TimelineProvider

    Flutter->>Flutter: 위젯용 projection 생성
    Flutter->>Bridge: saveSnapshot(message)
    Bridge->>Writer: payload 전달
    Writer->>Writer: 입력·버전 검증
    Writer->>Group: 이미지 임시 파일 기록
    Writer->>Group: JSON 임시 파일 기록
    Writer->>Group: 완성 파일로 원자적 교체
    Group-->>Writer: 저장 성공
    Writer->>Center: reloadTimelines(kind)
    Writer-->>Flutter: revision 반환
    Note over Center,Provider: 시스템이 적절한 시점에 요청
    Center-->>Provider: getTimeline()
    Provider->>Group: snapshot 읽기
    Provider-->>Center: Timeline 반환

이 흐름에서 세 가지 책임을 분리한다.

  1. Flutter는 언제 어떤 스냅샷을 만들지 결정한다.
  2. iOS writer는 App Group에 안전하게 저장한다.
  3. Widget extension은 저장된 결과를 읽어 timeline entry로 바꾼다.

위젯이 앱의 데이터베이스를 직접 열거나 로그인을 재현하지 않게 한다. 앱과 extension의 배포 버전이 잠시 엇갈릴 수 있다는 점도 계약에 반영한다.

App Group을 두 target에 정확히 연결하기

App Group은 같은 개발 팀의 앱과 extension이 공유 container에 접근하도록 허용하는 entitlement다. 단순히 코드에 group.dev.example.widgets 문자열을 적는다고 접근 권한이 생기지 않는다.

Xcode에서 다음 두 target 모두에 같은 App Group capability를 추가해야 한다.

예시 식별자는 다음처럼 group. prefix를 사용한다.

group.dev.example.widgets

코드에서는 문자열을 여기저기 반복하지 않고 공통 설정으로 둔다. 앱 target과 extension target이 직접 같은 Swift package 또는 source file을 공유할 수 있다면 상수를 한곳에 둔다.

enum WidgetSharedConfiguration {
    static let appGroupIdentifier = "group.dev.example.widgets"
    static let widgetKind = "dev.example.summary-widget"
    static let snapshotFileName = "snapshot-v1.json"
    static let metadataSuiteKey = "widget.snapshot.metadata.v1"
}

공통 파일을 공유하기 어렵다면 build configuration에서 값을 주입하더라도 최종 산출물의 entitlement가 같은지 확인해야 한다.

guard let containerURL = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier:
        WidgetSharedConfiguration.appGroupIdentifier
) else {
    throw SharedContainerError.containerUnavailable
}

containerURLnil이면 임의의 Documents 경로로 fallback하지 않는다. 대개 capability, provisioning, signing 또는 식별자 불일치 문제이기 때문이다. 조용한 fallback은 저장은 성공한 듯 보이지만 extension에서는 영원히 읽지 못하는 더 어려운 장애를 만든다.

자주 놓치는 설정

Debug 앱 target에는 App Group이 있는데 Release provisioning profile에는 없거나, extension target에서 checkbox를 빠뜨리는 경우가 있다. simulator 성공만으로 끝내지 말고 archive한 앱을 실제 기기에서도 확인한다.

공유 데이터 계약부터 정의하기

Flutter가 JSON을 직접 만들고 Swift가 그대로 파일에 쓰게 할 수도 있다. 하지만 플랫폼 쪽에서 검증 없이 opaque 문자열을 저장하면 위젯이 오류를 뒤늦게 발견한다. 이 예제에서는 MethodChannel payload를 native DTO로 변환한 뒤 JSONEncoder가 공유 파일을 만든다.

struct SaveWidgetSnapshotCommand {
    let schemaVersion: Int
    let revision: Int
    let displayName: String
    let todayCount: Int
    let statusText: String
    let generatedAt: Date
    let imageBytes: Data?
}

계약 문서는 코드 옆에 표로 남겨 두면 양쪽 구현을 검토하기 쉽다.

필드 타입 필수 제약 의미
schemaVersion integer O 현재 1 payload 형식
revision integer O 1 이상, 단조 증가 오래된 쓰기 판별
displayName string O 1~40자 위젯 표시 이름
todayCount integer O 0~9999 오늘 기록 수
statusText string O 최대 80자 보조 문구
generatedAtEpochMs integer O Unix epoch ms 스냅샷 생성 시각
thumbnailBytes bytes X 크기 제한 선택 썸네일 원본

이미지 바이트는 채널로 큰 원본을 반복 전달하는 설계를 권한다는 뜻이 아니다. 작은 최종 썸네일을 드물게 갱신하는 경우의 예시다. 원본 이미지나 빈번한 미디어는 앱에서 공유 container 파일로 직접 내보내고 native에는 경로와 메타데이터만 전달하는 편이 낫다.

schema 변경에는 compatibility 정책도 필요하다.

enum WidgetSnapshotDecoder {
    static func decode(_ data: Data) throws -> WidgetSnapshot {
        let envelope = try JSONDecoder().decode(
            SnapshotVersionEnvelope.self,
            from: data
        )

        guard envelope.schemaVersion == 1 else {
            throw SnapshotReadError.unsupportedVersion(
                envelope.schemaVersion
            )
        }

        return try configuredDecoder.decode(
            WidgetSnapshot.self,
            from: data
        )
    }
}

새 앱이 v2를 쓴 직후 구버전 extension이 v1만 읽는 상황이 생길 수 있다. 앱과 extension은 보통 함께 배포되지만 설치·업데이트·시스템 cache 시점을 하나의 원자적 배포처럼 가정하면 안 된다. 큰 형식 변경이라면 v1과 v2 파일을 잠시 함께 기록하거나, 새 extension이 v1도 읽을 수 있게 migration window를 둔다.

UserDefaults와 파일을 나누어 사용하기

App Group은 하나의 API가 아니라 공유 container에 접근할 권한이다. 그 안에서 작은 preference는 UserDefaults(suiteName:), 구조화된 큰 데이터나 이미지는 파일로 나눌 수 있다.

데이터 권장 저장소 이유
현재 snapshot revision App Group UserDefaults 작고 자주 확인하는 scalar
마지막 성공 시각 App Group UserDefaults 진단용 metadata
렌더링 데이터 JSON 파일 한 단위로 encode·replace 가능
썸네일 파일 바이너리를 defaults에 중복 보관하지 않음
인증 token Keychain App Group defaults는 비밀 저장소가 아님
앱 전체 DB 보통 공유하지 않음 schema·lock·migration 결합이 큼

모든 값을 여러 UserDefaults key에 따로 저장하면 중간 상태가 보일 수 있다.

// 좋지 않은 예: extension이 세 번째 set 전에 읽을 수 있다.
defaults.set("Demo", forKey: "displayName")
defaults.set(4, forKey: "todayCount")
defaults.set(Date(), forKey: "generatedAt")

하나의 작은 Data로 encode해 key 하나에 저장하면 필드 간 일관성은 좋아진다.

let data = try JSONEncoder.widgetSnapshotEncoder.encode(snapshot)
defaults.set(data, forKey: "widget.snapshot.v1")

하지만 이미지까지 큰 Data로 넣거나 history를 계속 쌓는 데 UserDefaults를 사용하지 않는다. 이 글에서는 확장 가능성과 파일 교체의 원자성을 보여 주기 위해 JSON과 이미지는 파일로, 진단 metadata만 defaults로 저장한다. UserDefaults와 Keychain의 보안 경계는 App Group UserDefaults와 Keychain의 역할 차이에서 별도로 다룬다.

앱에서 스냅샷을 원자적으로 기록하기

Widget extension이 파일을 읽는 순간 앱이 같은 파일을 덮어쓰면 일부만 기록된 JSON을 읽을 수 있다. 완성된 임시 파일을 만든 뒤 목적 파일로 교체한다.

actor WidgetSnapshotWriter {
    private let fileManager: FileManager
    private let encoder: JSONEncoder
    private let containerURL: URL

    init(fileManager: FileManager = .default) throws {
        guard let containerURL = fileManager.containerURL(
            forSecurityApplicationGroupIdentifier:
                WidgetSharedConfiguration.appGroupIdentifier
        ) else {
            throw SharedContainerError.containerUnavailable
        }

        self.fileManager = fileManager
        self.encoder = .widgetSnapshotEncoder
        self.containerURL = containerURL
    }

    func save(_ command: SaveWidgetSnapshotCommand) throws -> Int {
        try validate(command)

        let currentRevision = readCurrentRevision()
        guard command.revision > currentRevision else {
            throw SnapshotWriteError.staleRevision
        }

        let imageReference = try writeImageIfPresent(command)
        let snapshot = makeSnapshot(
            command: command,
            imageReference: imageReference
        )
        let data = try encoder.encode(snapshot)

        try writeAtomically(
            data,
            fileName: WidgetSharedConfiguration.snapshotFileName
        )
        writeMetadata(for: snapshot)

        return snapshot.revision
    }
}

Swift의 Data.write(to:options:)에는 atomic 옵션이 있다.

private func writeAtomically(
    _ data: Data,
    fileName: String
) throws {
    let destination = containerURL.appendingPathComponent(fileName)
    try data.write(to: destination, options: [.atomic])
}

파일과 이미지 두 개를 동시에 완전한 transaction으로 교체하는 것은 더 어렵다. 이때 immutable한 revision 파일을 먼저 쓰고, 마지막에 JSON manifest가 새 이미지 경로를 가리키게 한다.

SharedContainer/
├── snapshots/
│   ├── snapshot-41.json
│   └── snapshot-42.json
├── images/
│   ├── thumbnail-41.webp
│   └── thumbnail-42.webp
└── current.json

저장 순서는 다음과 같다.

  1. thumbnail-42.webp를 완성한다.
  2. 그 파일을 가리키는 snapshot-42.json을 완성한다.
  3. 가장 마지막에 current.json을 revision 42로 교체한다.
  4. 일정 시간이 지난 revision 40 이하의 고아 파일을 정리한다.

위젯은 current.json이 가리키는 완성본만 읽는다. 앱이 2번에서 종료되면 새 파일이 고아로 남을 뿐, 기존 revision 41은 계속 유효하다.

Flutter에서는 좁은 bridge만 호출하기

Flutter 영역은 App Group 경로나 WidgetKit 타입을 알 필요가 없다. MethodChannel로 Flutter와 네이티브 코드 연결하기에서 만든 것처럼 기능 단위 interface를 둔다.

final class WidgetSnapshotDraft {
  const WidgetSnapshotDraft({
    required this.revision,
    required this.displayName,
    required this.todayCount,
    required this.statusText,
    required this.generatedAt,
    this.thumbnailBytes,
  });

  final int revision;
  final String displayName;
  final int todayCount;
  final String statusText;
  final DateTime generatedAt;
  final Uint8List? thumbnailBytes;

  Map<String, Object?> toMessage() => {
        'schemaVersion': 1,
        'revision': revision,
        'displayName': displayName,
        'todayCount': todayCount,
        'statusText': statusText,
        'generatedAtEpochMs':
            generatedAt.millisecondsSinceEpoch,
        'thumbnailBytes': thumbnailBytes,
      };
}

abstract interface class HomeWidgetBridge {
  Future<int> saveAndRequestReload(WidgetSnapshotDraft draft);
}

도메인 모델에서 스냅샷을 만드는 projection도 한곳에 둔다.

final class WidgetSnapshotProjector {
  const WidgetSnapshotProjector(this.clock);

  final Clock clock;

  WidgetSnapshotDraft project(
    Profile profile,
    DailyRecords records,
    int nextRevision,
  ) {
    return WidgetSnapshotDraft(
      revision: nextRevision,
      displayName: profile.displayName,
      todayCount: records.items.length,
      statusText: records.items.isEmpty
          ? '아직 기록이 없어요'
          : '오늘 ${records.items.length}개를 기록했어요',
      generatedAt: clock.now(),
    );
  }
}

상태가 변할 때마다 무조건 호출하면 같은 스냅샷을 반복해서 저장하고 reload를 남발할 수 있다. 위젯에 영향을 주는 projection이 실제로 달라졌는지 비교한다.

Future<void> synchronizeWidget(ProfileState state) async {
  final next = projector.project(
    state.profile,
    state.dailyRecords,
    revision.next(),
  );

  if (snapshotCache.hasSameContent(next)) {
    return;
  }

  final savedRevision =
      await homeWidgetBridge.saveAndRequestReload(next);
  snapshotCache.markSaved(next, savedRevision);
}

revision은 “시도 횟수”보다 “확정된 콘텐츠 버전” 의미가 명확해야 한다. 저장 실패 후 재시도할 때 같은 revision을 쓸지 새 revision을 쓸지도 writer의 idempotency 규칙과 맞춘다.

Widget extension은 읽기 전용 소비자로 만들기

TimelineProvider에서 앱 내부 데이터베이스를 열고 복잡한 migration이나 네트워크 동기화를 실행하면 실행 시간과 실패 지점이 늘어난다. provider는 현재 스냅샷을 빠르게 읽고 entry로 변환한다.

struct SummaryTimelineEntry: TimelineEntry {
    let date: Date
    let state: SummaryWidgetState
}

enum SummaryWidgetState {
    case content(WidgetSnapshot, image: Data?)
    case empty
    case stale(WidgetSnapshot, image: Data?)
    case unavailable
}

reader는 container 밖의 경로를 허용하지 않는다.

struct WidgetSnapshotReader {
    private let containerURL: URL
    private let decoder: JSONDecoder

    init(fileManager: FileManager = .default) throws {
        guard let url = fileManager.containerURL(
            forSecurityApplicationGroupIdentifier:
                WidgetSharedConfiguration.appGroupIdentifier
        ) else {
            throw SnapshotReadError.containerUnavailable
        }

        self.containerURL = url.standardizedFileURL
        self.decoder = .widgetSnapshotDecoder
    }

    func readCurrent() throws -> WidgetSnapshot {
        let url = containerURL.appendingPathComponent(
            WidgetSharedConfiguration.snapshotFileName
        )
        let data = try Data(contentsOf: url)
        let snapshot = try decoder.decode(
            WidgetSnapshot.self,
            from: data
        )

        guard snapshot.schemaVersion == 1 else {
            throw SnapshotReadError.unsupportedVersion(
                snapshot.schemaVersion
            )
        }

        return snapshot
    }
}

provider는 읽기 결과를 위젯 상태로 매핑한다.

struct SummaryProvider: TimelineProvider {
    let reader: WidgetSnapshotReader
    let clock: () -> Date

    func placeholder(
        in context: Context
    ) -> SummaryTimelineEntry {
        SummaryTimelineEntry(
            date: clock(),
            state: .empty
        )
    }

    func getSnapshot(
        in context: Context,
        completion: @escaping (SummaryTimelineEntry) -> Void
    ) {
        completion(makeEntry(isPreview: context.isPreview))
    }

    func getTimeline(
        in context: Context,
        completion: @escaping (Timeline<SummaryTimelineEntry>) -> Void
    ) {
        let entry = makeEntry(isPreview: false)
        let nextCheck = clock().addingTimeInterval(60 * 30)

        completion(
            Timeline(
                entries: [entry],
                policy: .after(nextCheck)
            )
        )
    }
}

timeline 정책과 갱신 빈도는 다음 글인 WidgetKit Timeline을 갱신하는 방법에서 자세히 다룬다. 여기서 중요한 점은 provider가 앱 프로세스에 callback을 요청하지 않고 현재 공유 snapshot만으로 entry를 완성한다는 것이다.

빈 상태와 손상된 데이터에 대응하기

앱 설치 직후에는 스냅샷 파일이 없다. 앱을 오래 열지 않았다면 스냅샷이 낡았을 수 있다. 업데이트 도중 schema가 달라지거나 저장 공간 문제로 파일을 읽지 못할 수도 있다. 모두 실제 환경에서 발생 가능한 상태다.

private func makeEntry(
    isPreview: Bool
) -> SummaryTimelineEntry {
    if isPreview {
        return SummaryTimelineEntry(
            date: clock(),
            state: .content(
                WidgetSnapshot.preview,
                image: nil
            )
        )
    }

    do {
        let snapshot = try reader.readCurrent()
        let image = try? reader.readImage(for: snapshot)
        let age = clock().timeIntervalSince(snapshot.generatedAt)

        if age > 60 * 60 * 24 {
            return SummaryTimelineEntry(
                date: clock(),
                state: .stale(snapshot, image: image)
            )
        }

        return SummaryTimelineEntry(
            date: clock(),
            state: .content(snapshot, image: image)
        )
    } catch SnapshotReadError.fileNotFound {
        return SummaryTimelineEntry(
            date: clock(),
            state: .empty
        )
    } catch {
        return SummaryTimelineEntry(
            date: clock(),
            state: .unavailable
        )
    }
}

상태별 UI 의미도 미리 정한다.

상태 사용자에게 보여 줄 것 피해야 할 것
첫 스냅샷 없음 앱을 열어 설정하라는 짧은 안내 무한 spinner
오래된 스냅샷 마지막 값과 갱신 시각 오래된 값을 최신처럼 표시
이미지 없음 기본 배경과 텍스트 전체 위젯 실패
지원하지 않는 schema 안전한 placeholder crash 또는 빈 화면
container 접근 실패 일반 오류 상태 entitlement 경로 노출

위젯은 짧게 보고 지나가는 UI다. 복구할 수 없는 내부 오류를 자세히 설명하기보다 앱을 열 수 있는 명확한 상태를 제공한다. 다만 deep link가 필요한 경우에도 URL에 민감한 원본 데이터를 넣지 않는다.

stale은 실패와 다르다

마지막으로 성공한 데이터가 있다면 일시적인 읽기 실패 때 즉시 빈 화면으로 바꾸는 것보다, 생성 시각을 표시한 오래된 스냅샷이 더 유용할 수 있다.

reload 요청을 저장 완료 뒤에 보내기

앱 상태 변경이 기존 timeline에 영향을 준다면 WidgetCenter에 특정 widget kind의 timeline reload를 요청할 수 있다.

import WidgetKit

func saveAndReload(
    _ command: SaveWidgetSnapshotCommand
) async throws -> Int {
    let revision = try await writer.save(command)

    WidgetCenter.shared.reloadTimelines(
        ofKind: WidgetSharedConfiguration.widgetKind
    )

    return revision
}

반드시 저장 성공 뒤에 reload를 호출한다. 순서가 반대면 system이 provider를 빠르게 요청했을 때 이전 snapshot을 다시 timeline으로 만들 수 있다.

reloadTimelines(ofKind:)는 해당 kind의 timeline을 다시 요청하라는 API다. 호출 즉시 화면이 다시 그려졌다는 acknowledgment를 반환하지 않는다. 따라서 다음과 같은 UX를 만들면 안 된다.

await homeWidgetBridge.saveAndRequestReload(draft);

// 좋지 않은 문구: 홈 화면 렌더 완료까지 보장한 것이 아니다.
showToast('홈 화면 위젯 업데이트 완료');

대신 앱이 보장한 범위만 표현한다.

showToast('위젯에 표시할 내용을 저장했어요');

여러 widget kind가 있어도 매번 reloadAllTimelines()를 호출하지 않는다. 변경된 데이터에 영향을 받는 kind만 요청한다. 사용자 설정에 따라 특정 위젯만 관련된다면 WidgetCenter의 현재 구성 정보를 확인해 불필요한 reload를 줄일 수도 있다.

동시 접근과 revision 경쟁 처리하기

스냅샷 저장은 여러 경로에서 호출될 수 있다.

두 저장이 겹치면 늦게 시작한 새 데이터보다 먼저 시작한 오래된 데이터가 나중에 파일을 덮어쓸 수 있다.

sequenceDiagram
    participant Old as revision 41
    participant New as revision 42
    participant Store as Shared writer

    Old->>Store: 이미지 처리 시작
    New->>Store: 이미지 처리 시작
    New->>Store: snapshot 42 저장 완료
    Old->>Store: snapshot 41 저장 시도
    Store-->>Old: staleRevision 거절

Swift actor로 writer 진입을 직렬화하고 현재 revision보다 작은 요청을 거절하면 이 경쟁을 제어할 수 있다. 하지만 revision을 UserDefaults에서 읽고 파일을 쓴 뒤 다시 defaults를 갱신하는 과정에서 앱이 종료될 수도 있다. 최종 진실은 current.json 같은 manifest에 두고 defaults revision은 진단용 cache로 취급하는 편이 단순하다.

같은 revision의 재시도 정책도 정한다.

이 규칙이 있어야 MethodChannel timeout 후 재시도하더라도 중복 파일과 모순된 상태를 줄일 수 있다.

공유 컨테이너에 넣지 말아야 할 것

App Group container에 접근 권한이 있다는 사실은 그곳이 암호화된 비밀 저장소라는 뜻이 아니다. 앱과 같은 group entitlement를 가진 extension이 데이터를 읽을 수 있다. 위젯 프로세스에 필요 없는 인증 정보까지 공유하면 공격 표면만 넓어진다.

공유 snapshot에서 제외할 항목은 다음과 같다.

위젯에 개인 정보가 보이는 것 자체도 잠금 화면과 주변 사람 노출을 고려해야 한다. “앱에 이미 있는 정보”와 “홈 화면에 항상 표시해도 되는 정보”는 같은 범주가 아니다.

파일 경로는 container 기준 상대 경로만 snapshot에 넣는다.

func imageURL(relativePath: String) throws -> URL {
    let candidate = containerURL
        .appendingPathComponent(relativePath)
        .standardizedFileURL

    guard candidate.path.hasPrefix(containerURL.path + "/") else {
        throw SnapshotReadError.invalidRelativePath
    }

    return candidate
}

이 검사는 외부 입력이 아니더라도 손상된 파일이나 미래 구현 오류가 container 밖 파일을 읽는 일을 막는다.

테스트와 장애 진단 방법

App Group 연동은 unit test만 통과해도 실제 기기에서 entitlement 때문에 실패할 수 있다. 계층별로 확인한다.

1. projection 단위 테스트

Flutter 도메인 상태가 표시 가능한 최소 snapshot으로 변환되는지 검증한다.

test('기록이 없으면 빈 상태 문구를 만든다', () {
  final draft = projector.project(
    Profile(displayName: 'Demo'),
    const DailyRecords([]),
    12,
  );

  expect(draft.todayCount, 0);
  expect(draft.statusText, '아직 기록이 없어요');
  expect(draft.revision, 12);
});

2. writer와 reader round-trip 테스트

임시 directory를 주입해 encode한 결과를 같은 reader가 읽을 수 있는지 확인한다.

func testWriterAndReaderShareTheSameSchema() async throws {
    let directory = try makeTemporaryDirectory()
    let writer = WidgetSnapshotWriter(containerURL: directory)
    let reader = WidgetSnapshotReader(containerURL: directory)

    try await writer.save(.fixture(revision: 7))
    let snapshot = try reader.readCurrent()

    XCTAssertEqual(snapshot.schemaVersion, 1)
    XCTAssertEqual(snapshot.revision, 7)
    XCTAssertEqual(snapshot.profile.todayCount, 3)
}

3. 실패 주입 테스트

이미지 실패 때문에 텍스트 snapshot까지 버릴지, 이미지 없이 계속 표시할지 정책도 테스트에 드러나야 한다.

4. 실제 기기 통합 테스트

다음 절차를 한 번에 기록해 두면 회귀 확인이 쉽다.

  1. 앱과 extension을 포함해 새로 설치한다.
  2. 앱을 열기 전 위젯의 empty 상태를 확인한다.
  3. 앱에서 데이터를 만든 뒤 snapshot 저장을 확인한다.
  4. 앱을 background로 보내고 위젯이 새 timeline을 받는지 확인한다.
  5. 앱을 강제 종료한 뒤에도 마지막 snapshot이 보이는지 확인한다.
  6. 앱 업데이트 후 구 schema snapshot을 읽는지 확인한다.
  7. Release signing으로 App Group 접근을 확인한다.

장애 로그에는 민감한 snapshot 내용을 남기지 않고 단계와 결과만 남긴다.

widget_snapshot_write revision=42 bytes=284 result=success
widget_reload_request kind=summary-widget result=requested
widget_snapshot_read revision=42 age_seconds=18 result=success

앱 로그에는 저장 성공이 있는데 extension 로그에서 container 접근 실패가 보이면 entitlement를 먼저 확인한다. 저장과 읽기는 성공하지만 화면이 바로 바뀌지 않았다면 reload를 렌더 완료 보장으로 오해했는지와 timeline 정책을 확인한다.

구현 체크리스트

App Group 설정

데이터 계약

저장과 읽기

갱신과 운영

마무리

Flutter 앱과 WidgetKit 사이의 연결에서 가장 중요한 것은 bridge 코드 한 줄이 아니다. 서로 다른 프로세스와 수명주기를 가진 두 소비자가 어떤 데이터를 언제까지 이해할 수 있는지 정하는 일이다.

앱의 전체 상태를 공유하지 않고 위젯 전용 snapshot을 만든다. snapshot에는 schema version과 revision을 넣고, App Group container에 완성된 단위로 기록한다. 큰 바이너리는 파일로, 작은 진단 metadata는 shared defaults로 나눈다. extension은 이 저장소를 빠르게 읽는 소비자로 유지하고, 데이터가 없거나 오래됐거나 손상된 경우에도 렌더링 가능한 상태를 반환한다.

마지막으로 reloadTimelines는 저장소와 화면 사이의 동기 호출이 아니다. 저장을 먼저 확정한 뒤 갱신을 요청하고, 실제 실행 시점은 WidgetKit이 결정한다는 전제에서 UI와 오류 처리를 설계해야 한다.

이 구조를 사용하면 Flutter와 SwiftUI를 억지로 직접 연결하지 않아도 된다. 둘 사이에는 작고 버전이 있는 파일 계약만 남고, 앱이 실행되지 않는 순간에도 위젯은 마지막으로 확정된 데이터를 독립적으로 표시할 수 있다.

관련 노트

참고 자료