ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • iOS Swift: 위젯(Widget) 넣기 (난이도: 어려움)
    공부/Swift(프로그래밍 언어) 2026. 2. 15. 23:09
    반응형

    실제 데이터가 반영된 위젯들

    iOS 앱에서 위젯을 추가하는 과정은 단순한 UI 구현이 아니라 데이터 구조, 앱과의 통신, 업데이트 전략까지 포함된 작업입니다.

    유독 iOS 프로그래밍에서는 애플 워치의 컴플레이케이션이나 아이폰의 위젯 같은 Extension들의 개발이 까다로운 편입니다. 이번에  DiffuserStick 앱을 업데이트 하면서 위젯을 추가하는 과정에서 학습한 위젯 추가 과정을 앱 스토어 심사를 통과한 프로젝트를 기준으로 위젯을 설계하고 구현하는 전체 흐름을 단계별로 자세히 설명하겠습니다.

     

    선요약

    위젯 개발은 다음 순서로 진행하는 것이 가장 효율적입니다.

    1. 위젯 UI 먼저 설계 (샘플 mock 데이터 활용)
    2. 데이터 전달 전략 수립
    3. 실제 데이터 통신 및 반영
    4. (옵션) 위젯과 앱에서 특정 작업을 수행하도록 연동

    이 순서로 진행하면 

     


    1. Xcode에서 위젯 프로젝트 생성

    먼저 앱에 위젯 Extension을 추가합니다.
    Apple의 Xcode에서 다음과 같이 진행합니다.

    • File → New → Target
    • Widget Extension 선택
    • SwiftUI 기반 생성 (최소 iOS 16 버전 이상을 요구하므로 SwiftUI 로 작업하는 것이 간편)

    File > Target
    Widget 선택

    Include Configuration App Intent 만 선택합니다.

    • Live Activity: 실시간 배달 현황, 실시간 스포츠 중계 등에 사용되는 특수 위젯입니다. 이번 예제에서는 다루지 않으므로 체크 해제합니다.
    • Control: 제어 센터에서 사용되는 특수 아이콘, 필요하지 않은 경우 해제합니다.

     

    실시간 현황 (Live Activity)

    위젯은 앱과 별도의 프로세스에서 실행되며, 독립된 번들을 갖습니다. 이 구조 때문에 데이터 공유 방식이 매우 중요합니다.

     


    2. 방법 1: UI 먼저 설계

    초기 단계에서는 데이터 구조를 고려하지 않고 UI부터 구현하는 것이 효율적입니다.

    프로젝트 내에서 ~~EntryView 라는 이름의 구조체를 찾아 UI 코드를 작성합니다.

    struct DSWidgetEntryView : View {
      var entry: Provider.Entry
      @Environment(\.widgetFamily) var family
    
      var body: some View {
        switch family {
        case .systemSmall:
          SmallView
        default:
          DefaultView
        }
      }
    
      @ViewBuilder private var SmallView: some View {
        // 생략
      }
    
      @ViewBuilder private var DefaultView: some View {
        HStack(spacing: 16) {
          if #available(iOS 18.0, *) {
            Image(.jelly1)
              .resizable()
              .widgetAccentedRenderingMode(.desaturated)
              .scaledToFill()
              .frame(width: 100)
              .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
          } else {
            Image(.jelly1)
              .resizable()
              .scaledToFill()
              .frame(width: 100)
              .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
          }
          VStack(alignment: .leading) {
            Text("창가의 디퓨저 스틱")
              .font(.title2)
              .bold()
            Divider()
              .opacity(0)
            Text("18일 후 교체 필요")
            Text("2026년 2월 9일 교체됨")
              .foregroundStyle(.secondary)
          }
          Spacer()
        }
      }
    }

    동작 설명:

    • 위젯 크기별 UI를 분기하여 다양한 레이아웃을 지원합니다.
    • iOS 버전에 따라 위젯 렌더링 모드를 다르게 처리합니다.
    • 현재는 실제 데이터가 아닌 더미 데이터를 사용합니다.
    • 이미지에 WidgetAccentedRenderingMode: iOS 18에 새로 추가된 틴트 모드, 투명 모드에 대응하려면 이것을 사용합니다.
    • 폰트 크기 및 요소의 색상은 SwiftUI에서 기본으로 제공하는 템플릿(.primary, .secondary) 을 사용할 것을 권장합니다.

     

    위젯 디자인 미리보기 화면

     


    3. 데이터 전달 전략

    위젯은 앱과 독립적으로 실행되므로 직접 앱의 로컬 데이터에 접근할 수 없습니다. 대표적인 데이터 전달 방식은 다음과 같습니다.

    1. 서버 API 호출
    2. App Group 기반 로컬 공유

    이번 예제는 오프라인 기반 공유 방법을 설명합니다. 서버 API 호출의 경우 위젯에서 서버와 통신해서 바로 받아 처리할 수 있습니다. 오프라인 환경과 성능을 고려하여 본체 앱의 데이터를 위젯으로 보내는 방법에 대해 설명하겠습니다. 

     


    4. App Group 설정

    앱과 위젯은 별도의 앱으로 취급되므로 App Group 설정이 필수입니다. 앱 그룹을 사용하면 위젯과 앱이 동일한 폴더를 공유할 수 있게 됩니다.

    설정 방법:

    1. 앱 타겟(TARGETS) → Signing & Capabilities
    2. App Groups 추가
    3. 동일 그룹을 위젯에도 추가

     

    동작 설명:

    • App Group은 앱과 위젯이 공유하는 공용 저장 공간입니다.
    • 파일, UserDefaults, 이미지 등을 공유할 수 있습니다.
    • 보안 샌드박스를 유지하면서 데이터 접근을 허용합니다.
    • 앱 그룹의 번들 아이디 (예: group.com.company.appName) 를 사용합니다.

     


    5. Widget 전용 DTO 작성

    위젯에서는 최소한의 데이터만 전달하는 것이 중요합니다.

    import Foundation
    
    struct DiffuserWidgetDTO: Codable {
      let id: UUID
      var title: String
      var lastStartDate: Date
      var usersDays: Int
    }
    
    extension DiffuserWidgetDTO {
      static let mock: DiffuserWidgetDTO = .init(
        id: UUID(),
        title: "loc.widget.dto.mock.title".localized,
        lastStartDate: Date(),
        usersDays: 30
      )
    }

    동작 설명:

    • DTO는 위젯에서 필요한 데이터만 포함합니다.
    • Codable을 통해 JSON 직렬화를 지원합니다.
    • mock 데이터는 로딩 상태나 미리보기에서 사용됩니다.

     

    [중요] 앱과 위젯이 동시에 사용하는 Swift 파일은 Target Membership 을 둘 다 설정

     

    이미지 역시 App Group에 저장하여 전달합니다. 위젯은 메모리 제한이 매우 강하며 일반적으로 수 MB 이상의 이미지는 실패할 수 있으므로 압축을 권장합니다.

    [참고] 위젯의 메모리 제한과 이미지 용량과의 관계 (더보기 클릭)

    더보기

    iOS 위젯의 이미지 크기는 공식적으로 “정확한 숫자”로 공개되어 있지는 않습니다. 하지만 WidgetKit의 메모리 제한과 실제 테스트 결과를 기준으로 현실적인 범위를 정리하면 다음과 같습니다.

     


    1. 위젯 이미지 크기 제한의 핵심 원리

    위젯은 일반 앱보다 매우 강한 메모리 제한을 받습니다.

    특히 이미지 관련 제한은 파일 크기보다 메모리 사용량 (RAM) 이 기준이 됩니다.

    • 디스크에 저장된 이미지 용량
    • 실제 렌더링 시 사용되는 메모리
    • 위젯 전체 메모리 사용량

    이 세 가지가 모두 영향을 줍니다.

    2. Widget 메모리 제한 (중요)

    공식 문서에는 명시되어 있지 않지만, 개발자 테스트 기준:

    📌 대략적인 메모리 제한

    • Small / Medium 위젯: 약 30~40MB
    • Large 위젯: 약 50MB 내외

    이 범위를 넘으면:

    • 이미지가 표시되지 않음
    • 위젯이 reload 실패
    • 로그 없이 UI가 깨짐

    3. 이미지가 문제 되는 이유

    JPEG 500KB 이미지라도 문제 발생 가능

    압축된 파일 크기는 작아도 렌더링 시 다음과 같이 메모리 사용량이 증가합니다.

    예시:

    • 1024 × 1024 이미지
    • 4 bytes per pixel (RGBA)

    → 약 4MB 메모리 사용

    여러 개의 이미지가 동시에 로딩되면 쉽게 메모리 초과가 발생합니다.

    4. 실무 기준 권장 이미지 크기

    현업에서 안정적으로 동작하는 범위:

    권장 해상도

    • Small 위젯:
      • 200~400 px 정도
    • Medium:
      • 400~800 px
    • Large:
      • 800~1200 px 이하

    특히 썸네일 위젯이라면:

    👉 300~500 px 정도가 가장 안정적입니다.

    권장 파일 용량

    100KB ~ 300KB 권장

    • 최대 500KB 이하 권장

    1MB 이상은 실패 가능성이 매우 높습니다.

    5. 실제 실패 사례 기준

    다음 상황에서 오류가 자주 발생합니다.

    • 원본 사진 (3~5MB) 그대로 저장
    • 고해상도 PNG 사용
    • 여러 위젯에서 동시에 이미지 로드
    • Large 위젯 + 이미지 여러 개

    6. 가장 안전한 전략

    1) 위젯 전용 썸네일 생성 (가장 중요)

    앱에서:

    • 원본 이미지를 저장
    • 위젯용 썸네일을 따로 생성
    • App Group에 저장

    예:

    • 원본: 3000px
    • 위젯: 400px

    2) JPEG 압축

    PNG보다 JPEG 사용 권장:

    • 메모리와 디스크 사용량 감소
    • 로딩 속도 개선

    압축률:

    • 0.6 ~ 0.8 추천

    3) Lazy 로딩 구조

    가능하다면:

    • 화면에 필요한 이미지만 로드
    • 불필요한 이미지 제거

    7. 정리

    실무에서 안정적인 기준은 다음과 같습니다.

    • 해상도: 300~500 px
    • 파일 크기: 100~300KB
    • 최대: 500KB 이하
    • 반드시 썸네일 생성
    • JPEG 압축 사용

    “파일 용량”보다 렌더링 메모리 사용량을 줄이는 것이 핵심입니다.

     


    6. App Group에 JSON 저장 및 로드

    import Foundation
    
    func loadDiffusers(from fileName: String = .shdDiffuserJSONFileName) -> [DiffuserWidgetDTO] {
      guard let containerURL = FileManager.default
        .containerURL(forSecurityApplicationGroupIdentifier: .shdAppGroupIdentifier) else {
        return []
      }
    
      let fileURL = containerURL.appendingPathComponent(fileName)
    
      guard FileManager.default.fileExists(atPath: fileURL.path) else {
        return []
      }
    
      do {
        let data = try Data(contentsOf: fileURL)
        let decoder = JSONDecoder()
        return try decoder.decode([DiffuserWidgetDTO].self, from: data)
      } catch {
        print("Decoding error:", error)
        return []
      }
    }
    
    func saveDiffusersToAppGroup(diffuserWidgetDTOs dtos: [DiffuserWidgetDTO]) {
      let encoder = JSONEncoder()
      encoder.outputFormatting = [.prettyPrinted]
    
      guard let gContainer = FileManager.default.containerURL(
        forSecurityApplicationGroupIdentifier: .shdAppGroupIdentifier
      ) else {
        return
      }
      let url = gContainer.appendingPathComponent("ds-widget-data.json")
    
      do {
        let data = try encoder.encode(dtos)
        try? data.write(to: url, options: .atomic)
      } catch {
        print("Encoding error: \(error)")
      }
    }

    동작 설명:

    • App Group 디렉토리에서 JSON 파일을 읽고 씁니다.
    • 앱에서 저장한 데이터를 위젯이 그대로 사용합니다.
    • 위젯 업데이트 시마다 최신 데이터를 불러옵니다.
    • 앱 내부에서 데이터가 갱신될 때 (예: Create, Update, Delete 등)에 saveToAppGroup을 사용해 앱으로 전송합니다

     

     


    7. TimelineEntry 구조 변경

    struct SimpleEntry: TimelineEntry {
      let date: Date
      let diffuser: DiffuserWidgetDTO
      let configuration: ConfigurationAppIntent
    }

    동작 설명:

    • TimelineEntry는 위젯 데이터의 단위입니다.
    • date는 해당 데이터가 표시되는 시점을 의미합니다.
    • diffuser를 통해 실제 데이터가 전달됩니다.

     


    8. Provider 역할

    WidgetKit의 Provider는 위젯 데이터 생명주기를 담당합니다. 위젯 프로젝트에서 Provider 클래스를 찾아 구체적인 내용을 구현합니다.

    import WidgetKit
    
    struct Provider: AppIntentTimelineProvider {
      func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(
          date: Date(),
          diffuser: .mock,
          configuration: .init()
        )
      }
    
      func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
        let diffuser: DiffuserWidgetDTO = loadDiffusers(from: .shdDiffuserJSONFileName).first ?? .mock
        return SimpleEntry(date: .now, diffuser: diffuser, configuration: configuration)
      }
    
      func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
        let diffusers = loadDiffusers(from: .shdDiffuserJSONFileName)
    
        guard !diffusers.isEmpty else {
          let entry = SimpleEntry(
            date: Date(),
            diffuser: .mock,
            configuration: configuration
          )
          return Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(60 * 15)))
        }
    
        let randomDiffuser = diffusers.randomElement()!
    
        let entry = SimpleEntry(
          date: Date(),
          diffuser: randomDiffuser,
          configuration: configuration
        )
    
        let nextUpdate = Date().addingTimeInterval(60 * 15)
    
        return Timeline(entries: [entry], policy: .after(nextUpdate))
      }
    }

    동작 설명:

    • placeholder는 로딩 중 UI를 제공합니다. (애플이 기본적으로 해당 내용에 맞춰 스켈레톤 UI 를 제공)
    • snapshot은 위젯 갤러리 미리보기에서 사용됩니다.
    • timeline은 실제 데이터와 갱신 시점을 결정합니다. (아래 접힌글 참고)
    • 정책을 통해 위젯 업데이트 주기를 설정합니다.

     

    [참고] Timeline의 구현 방법 및 전략

    더보기

     

    앞에서 작성한 코드에서는 다음과 같은 Timeline 정책을 사용했습니다.

    return Timeline(entries: [entry], policy: .after(nextUpdate))

    즉, .after(Date) 정책입니다.

     

    1. .after(Date) 정책이란?

    WidgetKit에서 .after(Date)는 특정 시점 이후에 위젯에게 다시 데이터를 요청하도록 시스템에 알려주는 방식입니다.

    위 코드에서는 다음과 같이 설정되어 있습니다.

    let nextUpdate = Date().addingTimeInterval(60 * 15)

    즉:

    • 현재 시점 기준 15분 후에
    • 시스템이 다시 timeline()을 호출
    • 새로운 데이터를 가져와 위젯을 업데이트

    동작 흐름은 다음과 같습니다.

    • 위젯이 화면에 표시됨
    • 15분 후 시스템이 다시 timeline 요청
    • 최신 데이터를 읽어 UI 갱신

    이 방식은 주기적으로 데이터가 바뀌는 경우에 가장 많이 사용되는 정책입니다.

     

    2. 왜 .atEnd .never가 아닌 .after를 선택했는가?

    위 코드에서는 위젯 데이터를 다음과 같이 처리했습니다.

    • 랜덤 디퓨저 선택
    • 일정 시간이 지나면 자연스럽게 다른 항목 표시
    • 자주 화면을 켤 경우 위젯도 변경

    이 구조에서는 일정 주기 갱신이 필요하기 때문에 .after가 가장 적절합니다.

    만약 .atEnd를 사용하면:

    • 다음 entry가 없을 경우 업데이트가 지연될 수 있음
    • 랜덤 갱신 같은 구조에 부적합

    .never는:

    • 앱에서 수동으로 reload하지 않으면 업데이트되지 않음
    • 현재 구조와 맞지 않음

    3. Timeline 정책 종류 정리

     .after(Date)

    가장 일반적인 방식입니다.

    특징:

    • 특정 시간 이후 다시 timeline 요청
    • 주기적인 데이터 갱신 가능
    • 배터리와 성능 균형이 좋음

    대표 사용 예:

    • 날씨
    • 캘린더
    • 할 일
    • 카운트다운
    • 디퓨저 교체 알림

     .atEnd

    현재 entry 배열이 끝났을 때 업데이트됩니다.

    예:

    Timeline(entries: entries, policy: .atEnd)

    특징:

    • 여러 entry를 미리 만들어두는 경우에 적합
    • 예측 가능한 데이터 흐름에 유리

    대표 사용 예:

    • 일정표
    • 이벤트 타임라인
    • 예약된 알림

    예:

    • 9시, 12시, 18시 데이터 미리 생성

     .never

    자동 업데이트가 없습니다.

    특징:

    • 앱이 직접 reload를 호출해야 갱신
    • 배터리 절약 가능
    • 데이터 변화가 적은 경우 적합

    대표 사용 예:

    • 사용자 설정 위젯
    • 고정 정보
    • 위젯이 거의 변하지 않는 경우

    4. Timeline 정책 수립 시 고려해야 할 핵심 요소

    ① 배터리 사용량

    가장 중요한 요소입니다.

    위젯은 시스템이 관리하며, 과도한 업데이트는 제한됩니다.

    예:

    • 1분 주기 → 시스템이 무시할 가능성 높음
    • 너무 잦은 업데이트 → 성능 저하

    일반적으로:

    • 15~60분 주기가 권장됩니다.

    ② 데이터 변화 빈도

    데이터가 얼마나 자주 바뀌는지 고려해야 합니다.

    예:

    빠른 변화:

    • 주식
    • 실시간 데이터

    느린 변화:

    • 일정
    • 할 일

    데이터 변화보다 업데이트 주기가 빠르면 낭비입니다.


    ③ 시스템 제한

    iOS는 다음을 제한합니다.

    • 네트워크 요청 빈도
    • 백그라운드 실행
    • CPU 사용량

    따라서:

    • 시스템이 timeline 호출 시점을 조정할 수 있음
    • 정확한 시간에 실행되지 않을 수도 있음

    ④ 사용자 경험

    위젯은 실시간 앱이 아닙니다. 다음 전략이 중요합니다.

    • 자연스럽게 변경되는 UI
    • 약간의 지연 허용
    • 불필요한 깜빡임 방지

    예:

    • 디퓨저 교체일 표시 → 실시간 필요 없음

    ⑤ 앱과 위젯의 데이터 동기화

    앱에서 데이터가 변경되면 다음을 고려해야 합니다.

    WidgetCenter.shared.reloadAllTimelines()

    즉:

    • timeline 정책 + 앱 reload 호출을 함께 사용해야 합니다.

    ⑥ 예측 가능한 데이터인가?

    데이터가 예측 가능하다면 .atEnd가 더 효율적입니다.

    예:

    • 예약된 이벤트
    • 타이머

    예측 불가능하면 .after가 좋습니다.


    5. 디퓨저 위젯 기준 최적 전략

    현재 구조 기준으로 가장 적절한 전략은 다음입니다.

    • 기본: .after(15~30분)
    • 앱 데이터 변경 시: reload 호출
    • 날짜 기반 변경이 많다면 .atEnd 고려

    예:

    • 교체일까지 남은 날짜 → 하루 단위 업데이트
    • 랜덤 표시 → 일정 주기 변경

    6. 정리

    이번 코드에서 .after(Date) 정책을 사용한 이유는 다음과 같습니다.

    • 데이터 변화가 예측 불가능
    • 랜덤 요소 존재
    • 주기적 갱신 필요
    • 사용자 경험 개선
    • 시스템 효율과 배터리 균형

    위젯 정책 설계의 핵심은 다음 한 줄로 정리할 수 있습니다.

     

    “데이터 변화 주기 + 배터리 + 사용자 경험의 균형”


    9. 실제 데이터 반영

    ~~~EntryView 를 실제 데이터를 반영하도록 변경합니다.

    import WidgetKit
    import SwiftUI
    
    struct DSWidgetEntryView : View {
      var entry: Provider.Entry
      @Environment(\.widgetFamily) var family
    
      var body: some View {
        Group {
          switch family {
          case .systemSmall:
            SmallView
          default:
            DefaultView
          }
        }
        .widgetURL(URL(string: "widgetscheme://detail?id=\(entry.diffuser.id.uuidString)"))
      }
    }
      @ViewBuilder private var DefaultView: some View {
        let diffuser = entry.diffuser // 👈 entry.diffuser 를 사용하도록 변경
        HStack(spacing: 16) {
          let uiImage = getImageFromAppGroupDir(diffuserId: entry.diffuser.id) ?? .sample
          ResizableRendered(imageView: Image(uiImage: uiImage))
            .scaledToFit()
            .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
            .frame(width: 100)
            
          VStack(alignment: .leading) {
            Text(verbatim: diffuser.title)
              .font(.title2)
              .bold()
            Divider()
              .opacity(0)
            HStack {
              Text(verbatim: betweenDaysText)
            }
            Text(verbatim: formatLastChanged)
              .foregroundStyle(.secondary)
              .font(.footnote)
          }
          Spacer()
        }
      }

    동작 설명:

    • 실제 데이터를 UI에 반영합니다.
    • widgetURL을 통해 앱과 연결합니다.
    • 사용자가 위젯을 탭하면 특정 화면으로 이동합니다.

    실제 데이터가 반영된 위젯들

     


    10. 위젯과 앱 연동

    위젯을 클릭하면 앱에서 특정 데이터를 보여줄 수 있습니다.

    Cold Launch:

    • 앱이 꺼진 상태에서 실행됩니다.
    • SceneDelegate에서 URL을 받아 저장합니다.

    Hot Launch:

    • 앱이 실행 중일 때 URL을 처리합니다.
    import UIKit
    
    @available(iOS 13.0, *)
    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
      static var pendingDiffuserId: UUID?
      var window: UIWindow?
    
      func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
      ) {
        if let url = connectionOptions.urlContexts.first?.url,
            let id = extractDiffuserID(url: url) {
          Self.pendingDiffuserId = id
        }
      }
    
      func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard let url = URLContexts.first?.url,
              let id = extractDiffuserID(url: url) else {
          return
        }
    
        NotificationCenter.default.post(
          name: .didReceiveDiffuserPush,
          object: nil,
          userInfo: ["diffuserId": id]
        )
      }
    }

    동작 설명:

    • URL Scheme으로 위젯과 앱을 연결합니다.
    • Cold Launch(willConnectTo)와 Hot Launch(openURLContexts)를 모두 처리합니다.
    • Notification을 통해 화면 이동을 구현합니다.

    SwiftUI에서는 onOpenURL을 사용하여 동일한 기능을 구현할 수 있습니다.

    반응형
Designed by Tistory.