WidgetKit을 위한 snapshot 저장 구조

앱 밖에서 실행되는 위젯이 마지막 표시값과 갱신 상태를 안전하게 읽게 했다

한강자리 위젯은 앱 화면을 작게 복사한 화면이 아니다. 앱과 다른 곳에서 실행되고, 갱신되는 주기도 다르며, 사용자는 훨씬 짧은 시간 안에 숫자를 믿고 움직인다.

그래서 위젯에는 따로 저장한 snapshot이 필요했다.

위젯을 붙이기 전에는 앱의 cache를 잘 만들면 충분할 것 같았다. 하지만 WidgetKit extension은 앱의 메모리를 그대로 보지 않고, timeline 갱신 시점도 사용자가 앱을 여는 시점과 다르다. 앱 화면에서는 자연스러운 로딩도 위젯에서는 너무 늦거나 애매하게 보일 수 있다. 그래서 위젯은 앱의 축소판이 아니라 앱 밖에서 따로 도는 화면으로 봐야 했다.

위젯에는 따로 저장한 표시값이 필요했다

WidgetKit extension은 메인 앱의 메모리를 공유하지 않는다. 메인 앱이 SwiftData에 저장한 데이터를 그대로 들고 있는 것도 아니고, 네트워크가 항상 즉시 성공한다고 볼 수도 없다.

한강자리 위젯은 다음을 해야 했다.

  • 앱을 열지 않아도 최근 값을 보여준다.
  • 네트워크가 실패해도 마지막으로 확인한 값을 버리지 않는다.
  • 주차 위젯과 일반 공원 위젯은 서로 다른 질문에 답한다.
  • 위젯을 탭하면 앱의 해당 공원과 화면으로 이어진다.
  • 오래된 정보는 최신처럼 보이지 않게 한다.

이 일을 메인 앱이 들고 있는 값에 기대면 extension 실행 방식 때문에 흔들린다. 그래서 앱과 위젯 사이에 App Group snapshot을 두었다.

flowchart LR
  App["메인 앱"] --> Repo["저장소"]
  Repo --> SwiftData["SwiftData 캐시"]
  App --> Builder["스냅샷 빌더"]
  Builder --> AppGroup["App Group 스냅샷"]
  Widget["WidgetKit 확장"] --> Loader["위젯 항목 로더"]
  Loader --> AppGroup
  Loader --> API["/v2 home-summary<br/>상태 대체"]
  Loader --> Entry["위젯 표시 항목"]

snapshot에는 위젯에 보여줄 값만 남겼다

snapshot은 DB row를 옮겨 담은 것이 아니다. 위젯이 실제로 표시해야 하는 값만 남긴 작은 묶음이다.

주차 snapshot에는 공원 ID, 짧은 공원명, 주차장 목록, 잔여 대수, 총면수, 여유 정도, 확인한 시각, stale로 볼 시간, 길찾기 URL 같은 값이 들어간다. 일반 공원 snapshot에는 혼잡도, 날씨, 미세먼지, 주요 신호, 예측 변화처럼 “오늘 이 공원에 가도 되는가”를 가늠하게 하는 값이 들어간다.

같은 공원을 보더라도 주차 위젯과 일반 위젯은 다른 질문을 가진다. 그래서 snapshot key도 용도별로 나뉜다.

Snapshot확인할 것저장 단위
Parking snapshot지금 차를 가져가도 되는가공원 + 표시 방식
General snapshot오늘 이 공원에 가도 되는가공원 + 일반 focus
Legacy snapshot이전 버전 호환단일 주차 snapshot

이렇게 나누면 위젯 설정을 바꿔도 다른 용도의 snapshot을 덮어쓰지 않는다.

위젯 snapshot에서 봐야 하는 것은 특정 Swift struct의 모든 필드가 아니라, snapshot이 어떤 확인에 쓰이느냐다. 주차 위젯은 “차를 가져가도 되는가”에 답하고, 일반 공원 위젯은 “오늘 이 공원에 가도 괜찮은가”에 답한다.

위젯은 저장값을 먼저 보고 필요할 때 API를 부른다

위젯 entry loader는 단순한 순서로 움직인다.

sequenceDiagram
  autonumber
  participant Widget as 위젯 프로바이더
  participant Loader as 위젯 항목 로더
  participant Snapshot as App Group 스냅샷
  participant API as 한강자리 API
  participant Builder as 스냅샷 빌더

  Widget->>Loader: 타임라인에 넣을 값 요청
  Loader->>Snapshot: 용도에 맞는 저장값 읽기
  alt 스냅샷이 최신이고 강제 갱신 아님
    Snapshot-->>Loader: 쓸 수 있는 저장값 반환
    Loader-->>Widget: 저장값으로 위젯 항목 반환
  else 오래됐거나 없음
    Loader->>API: 첫 화면 또는 상태 데이터 요청
    API-->>Loader: 화면용 읽기 모델 반환
    Loader->>Builder: 위젯에 보여줄 값 만들기
    Builder-->>Snapshot: 공유 영역에 표시값 저장
    Loader-->>Widget: 새 응답으로 위젯 항목 반환
  end

여기서는 “fresh”가 무엇을 뜻하는지부터 나눴다. snapshot이 저장된 지 얼마 되지 않았다는 것과, 내부 데이터의 observed_at이 오래되지 않았다는 것은 다르다. 한강자리는 둘을 분리한다.

  • snapshot 최신성: 위젯 entry를 다시 만들 필요가 있는가.
  • data 최신성: 표시 중인 데이터가 최신이라고 말할 만큼 충분한가.

위젯이 cache hit로 빠르게 열리더라도, 내부 주차 값이 stale이면 UI는 오래됐다고 보여줘야 한다.

SwiftData와 App Group은 맡은 일이 다르다

SwiftData는 앱 내부의 read cache다. 공원, 주차장, status, forecast, home summary를 앱 화면에서 재사용하게 해준다.

App Group snapshot은 위젯과 공유하는 작은 표시값 묶음이다. extension이 안정적으로 읽을 수 있어야 하므로, 화면에 필요한 값만 Codable payload로 저장한다.

이 구분이 없으면 위젯이 앱 내부 데이터 변경에 과하게 묶인다. 반대로 snapshot을 너무 풍부하게 만들면 앱 cache와 중복된 작은 DB가 된다.

한강자리에서는 snapshot을 “위젯 entry를 만들기 위한 최종 표시 재료”로 제한했다.

위젯 실패는 오래된 값과 빈 값을 구분한다

위젯이 실패를 어떻게 보여주는지는 사용자가 숫자를 믿어도 되는지와 직접 연결된다.

상황보여주는 방식
fresh snapshot 있음snapshot으로 entry 만들기
snapshot은 있지만 오래됨네트워크 시도 후 실패하면 stale 표현과 함께 fallback
snapshot 없음API 시도 후 실패하면 unavailable 또는 placeholder
가까운 공원 선택에서 위치 불가위치 불가 이유로 unavailable
API payload 공원 불일치빈 데이터로 다룸

실패를 숨기지 않는 것이 핵심이다. 위젯은 작은 화면이라 더더욱 잘못된 확신을 주기 쉽다.

작은 화면일수록 표현이 단순해지기 때문에, stale을 숨기면 더 위험하다. 사용자가 위젯의 숫자 하나만 보고 움직일 수 있기 때문이다. 그래서 위젯에서는 “정보 없음”, “마지막으로 확인한 정보”, “방금 갱신한 정보”를 내부에서 분리해 둔다.

작은 화면일수록 최신성을 더 분명히 했다

WidgetKit extension은 메인 앱 view가 아니라 앱 밖에서 따로 도는 화면이다. 그래서 App Group snapshot은 앱과 위젯 사이의 명시적인 공유 형식으로 다뤄야 했다.

snapshot 최신성과 data 최신성을 분리하고, 주차 위젯과 일반 위젯에는 같은 데이터라도 다른 값 묶음을 남겼다. fallback도 “마지막으로 확인한 값”과 “정보 없음”을 나눠야 했다. snapshot에는 위젯 표시와 deep link에 필요한 값만 남기는 편이 안전했다.

위젯을 만들며 가장 크게 바뀐 생각은 “작은 화면일수록 더 적게 설명해도 된다”가 아니라는 점이었다. 작은 화면일수록 사용자는 더 빨리 믿기 때문에, snapshot과 최신성 기준을 더 또렷하게 둬야 했다.

이미지 확대