SwiftUI 앱과 WidgetKit이 같은 상태를 읽는 법

SwiftData와 App Group snapshot으로 앱 밖의 화면까지 같은 값을 이어간 구조

한강자리의 iOS 구현은 API 응답을 화면에 보여주는 데서 끝나지 않는다. 앱, 위젯, SwiftData 캐시, App Group snapshot, 딥링크, 외부 지도 앱 연결이 같이 움직인다. 여기서 App Group snapshot은 앱과 위젯이 함께 읽는 작은 표시값 묶음이다.

이 앱은 한강에 가기 전과 한강 안에서 짧게 확인하는 앱이다. 사용자가 앱을 열 때만 정보가 보이면 부족했다. 자주 가는 공원은 위젯에서 바로 보여야 하고, 위젯을 눌렀을 때는 사용자가 보던 공원과 화면으로 이어져야 했다.

처음에는 클라이언트를 “API에서 받은 값을 화면에 배치하는 곳” 정도로 생각하기 쉽다. 실제로는 그렇지 않았다. 한강자리는 사용자가 집을 나서기 전, 지하철을 타기 전, 주차장 입구에 가까워졌을 때, 공원 안에서 시설을 찾을 때처럼 짧은 순간에 보는 앱이다.

네트워크가 잠깐 느리거나 위젯이 오래된 snapshot을 보고 있어도, 화면은 사용자가 오해하지 않게 상태를 설명해야 한다.

그래서 iOS 쪽에서 중요했던 것은 화면 자체보다 같은 상태를 어디에 남기고 어떻게 다시 읽을지였다. API 응답은 SwiftData에 저장되고, 앱 상태 저장소는 화면이 쓰기 좋은 모양으로 바꾼다.

위젯은 App Group snapshot을 읽고, 딥링크는 다시 앱의 특정 공원과 화면으로 돌아온다. 이 연결이 끊기면 기능은 각각 살아 있어도 사용자는 끊겼다고 느낀다.

앱과 위젯이 공유하는 코드

현재 클라이언트는 세 target으로 나뉜다.

  • Hangangjari: SwiftUI 기반 메인 앱.
  • HangangjariWidget: 주차 위젯과 일반 공원 위젯.
  • HangangjariTests: model, networking, cache, widget 로직 검증.

앱과 위젯은 모델, 네트워킹, repository, localization, widget snapshot 코드를 공유한다. WidgetKit extension은 앱과 같은 process가 아니므로, 함께 읽어야 할 값은 App Group 영역에 따로 써둔다.

flowchart LR
  View["SwiftUI 화면"] --> Store["앱 상태 스토어\n@Observable @MainActor"]
  Store --> ParkingRepo["주차 저장소"]
  Store --> ForecastRepo["예측 저장소"]
  ParkingRepo --> API["API 클라이언트"]
  ForecastRepo --> API
  ParkingRepo --> Cache["SwiftData 캐시"]
  ForecastRepo --> Cache
  Store --> Snapshot["위젯 스냅샷 스토어"]

  Widget["WidgetKit 프로바이더"] --> Loader["위젯 항목 로더"]
  Loader --> Snapshot
  Loader --> API
  Loader --> Location["위젯 위치 리졸버"]
  Settings["위젯 / 즐겨찾기 설정"] --> AppGroup["App Group UserDefaults"]
  Widget --> AppGroup
  API --> Backend["FastAPI /v2"]

화면 상태를 한곳에서 조율했다

앱 상태 저장소는 화면 상태를 모아두는 객체다. 구현명보다 이 객체가 화면의 확인 항목을 한곳에서 정리하고, 네트워크와 저장소 세부사항을 바깥으로 밀어낸다는 점에 더 집중했다.

현재 상태에는 다음 그룹이 들어 있다.

  • 공원 목록.
  • 공원별 주차장 목록과 주차장별 최신 상태.
  • 공원별 나들이 overview.
  • 첫 화면용 HomeSummary.
  • 주차 예측 overview와 timeline.
  • 공원 혼잡 예측 overview와 timeline.
  • 즐겨찾기, 위젯, 딥링크에서 파생되는 화면 상태.

중요한 점은 앱 상태 저장소가 HTTP 세부 구현을 직접 알지 않는다는 것이다. API 호출과 SwiftData 저장은 repository가 맡는다. Store는 화면에 필요한 상태 전환과 표시할 모양을 만드는 일에 집중한다.

여기서 Store를 크게 만든 것이 목표는 아니었다. 오히려 Store가 몰라도 되는 일을 계속 밖으로 뺐다. URL 구성, request proof, decoding, SwiftData row upsert는 Store 바깥으로 뺐다.

Store 안에는 “이 공원을 보고 있다”, “이 값은 로딩 중이다”, “이 snapshot은 화면에 보여도 된다”처럼 UI가 바로 써야 하는 상태만 남기려고 했다.

저장소는 캐시와 API 사이를 조율했다

주차 repository는 parks, lots, statuses, outing overview, home summary를 다룬다. 예측 repository는 forecast config, 주차 예측, 공원 혼잡 예측, timeline payload를 다룬다.

Repository는 API wrapper에서 끝나지 않았다. 로컬 캐시 최신성 판단, API 응답 저장, 위젯 snapshot 갱신까지 함께 맡았다.

sequenceDiagram
  autonumber
  participant View as SwiftUI 화면
  participant Store as 앱 상태 스토어
  participant Repo as 저장소
  participant Cache as SwiftData 캐시
  participant API as API 클라이언트
  participant Snapshot as 위젯 스냅샷 스토어

  View->>Store: 첫 화면에 보여줄 값 요청
  Store->>Repo: 저장된 첫 화면 값 확인
  Repo->>Cache: 캐시에 남은 응답 읽기
  alt 캐시 최신
    Cache-->>Repo: 쓸 수 있는 첫 화면 값
    Repo-->>Store: 캐시에서 만든 화면 모델
  else 오래됐거나 없음
    Repo->>API: 첫 화면 응답 요청
    API-->>Repo: 해석된 첫 화면 응답
    Repo->>Cache: 첫 화면/주차/예측 값 저장
    Repo-->>Store: 새로 받은 화면 모델
  end
  Store->>Snapshot: 위젯용 주차/일반 표시값 갱신
  Store-->>View: 화면 상태 갱신

이 덕분에 첫 화면은 여러 API를 따로 기다리지 않는다. 서버가 조합한 화면용 응답을 받고, 앱은 SwiftData에 보존한 뒤 화면과 위젯에 맞게 나눈다.

이 방식은 코드를 조금 더 장황하게 만든다. 단순히 api.homeSummary()를 호출하고 화면에 꽂는 것보다 repository, cache, snapshot처럼 거치는 곳이 많다.

대신 앱을 다시 열었을 때, 위젯이 네트워크에 실패했을 때, 서버가 새 필드를 추가했을 때 어디서 방어해야 하는지 찾기 쉽다. 모바일 앱에서는 이런 지점이 생각보다 자주 온다.

WidgetKit은 앱 밖에서 실행된다

한강자리에는 두 종류의 위젯이 있다.

  • 주차 위젯: 추천 주차장, 잔여 대수, 상태, 근처 정렬.
  • 일반 공원 위젯: 혼잡도, 날씨, 행사, 시설 신호, 오늘 가도 될지 보는 정보.

위젯은 저장된 snapshot을 먼저 본다. 신선하면 그대로 사용한다. 없거나 오래되었으면 API를 호출하고, 실패하면 stale snapshot이나 placeholder로 내려간다.

flowchart TB
  Provider["위젯 타임라인 프로바이더"] --> Request["위젯 로드 요청"]
  Request --> Loader["위젯 항목 로더"]
  Loader --> Cached["App Group 스냅샷"]
  Loader --> API["home-summary / 예측 / 상태 API"]
  Loader --> Location["선택한 위젯 위치"]
  API --> Builder["스냅샷 빌더"]
  Cached --> Entry["위젯 표시 항목"]
  Builder --> Entry
  Entry --> Timeline["타임라인\n새로고침 정책"]

주차 위젯과 일반 위젯은 snapshot key도 나뉜다. 같은 공원을 보더라도 먼저 보여줘야 할 말이 다르기 때문이다.

앱과 위젯은 같은 API 구조를 쓴다

API client는 /v2 endpoint를 호출한다. 쓰기성 요청이나 민감한 요청에는 앱 접근 토큰과 request proof를 붙이는 길이 있다.

앱 접근 검증에서는 이 선을 지켰다.

  • 앱과 위젯은 같은 API client 구조를 공유한다.
  • bootstrap endpoint와 보호된 /v2 endpoint를 구분한다.
  • 실패 시 unsigned fallback을 허용할지 여부는 실행 정책으로 분리한다.
  • telemetry와 push subscription 같은 쓰기 요청은 읽기 요청보다 강한 검증을 거친다.

끊기지 않는 상태가 제품 경험이었다

WidgetKit은 앱의 작은 view가 아니라 별도 실행 환경이다. 그래서 App Group snapshot, repository, 서버 화면용 응답, 딥링크가 각자 따로 맞으면 충분하지 않았다. 사용자가 방금 보던 공원과 상태를 다시 이어받을 수 있어야 했다.

fresh, stale, unavailable도 서버와 앱이 함께 만든 UI 상태에 가까웠다. 값이 오래됐는지, 다시 읽을 수 있는지, 앱을 열면 같은 공원으로 돌아가는지가 맞아야 이동 전후의 짧은 확인이 끊기지 않았다.

돌아보면 iOS 쪽에서 어려웠던 일은 화면을 나누는 것이 아니라 상태가 끊기지 않게 하는 것이었다. 앱, 위젯, 캐시, 딥링크가 같은 공원과 같은 값을 이어받아야 사용자는 “아까 보던 정보”가 이어진다고 느낀다.

이미지 확대