← 모든 글

Live Activities는 배지가 아닌 상태 머신입니다

장르: 출시된 코드. 이 글은 제 아내, 어머니, 그리고 수천 명의 낯선 사람들이 사용하는 SwiftUI 명상 타이머인 Return에 구현한 Live Activity를 문서화합니다.1 여기 소개되는 패턴들은 프로덕션에서 살아남은 것들입니다. 가차 없이 솔직한 마무리 섹션에서는 제가 아직 모르는 것들을 적습니다.

Return의 Live Activity는 잠금 화면과 Dynamic Island에 표시되는 카운트다운 숫자처럼 보입니다.2 그것은 단순한 숫자가 아닙니다. 세 가지 외부 해제 경로와 자기 자신으로부터 자신을 방어해야 하는 하나의 재진입 시작 경로를 가진 다섯 가지 라이프사이클 상태 머신입니다.

저는 Live Activity를 배지처럼 취급한 v1을 출시했습니다. “현재 남은 시간”은 데이터였고, 나머지는 장식이었습니다. 그 버전에는 TestFlight에서 발견한 세 가지 버그와 프로덕션에서 발견한 한 가지 버그가 있었습니다.

  1. 시작이 이미 진행 중일 때 시작 버튼을 누르면 두 번째 액티비티가 생성되어 첫 번째 액티비티가 고아 상태가 되었습니다.
  2. 카운트다운은 Dynamic Island에서는 올바르게 렌더링되었지만, 잠금 화면 뷰는 일시 정지된 타이머에 대해 endTime <= Date()에 걸려 사용자가 재개할 때까지 0:00을 표시했습니다.
  3. 해제 정책이 .default였기 때문에 사용자가 타이머를 리셋한 후에도 Live Activity가 오랫동안 표시되었습니다. Apple은 이 정책에서 최대 4시간까지 액티비티를 표시할 수 있습니다.
  4. (프로덕션.) 오른쪽에서 왼쪽으로 쓰는 언어 로케일(아랍어, 히브리어)에서 Dynamic Island의 compact-trailing 영역에서 숫자가 거꾸로 렌더링되었습니다. 라틴 숫자, RTL 레이아웃. 수정은 한 줄이면 충분했습니다.

이들 각각은 상태 머신 버그였습니다. 카운트다운 숫자는 문제가 없었습니다. 숫자는 제품이 아닙니다. 제품은 상태입니다.

아래의 상태 머신은 그 버그들에서 살아남은 것입니다.

TL;DR

  • 출시된 LiveActivityManager는 5개의 전환 메서드(startActivity, updateActivity, showCycleComplete, showFinalCompletion, endActivity)와 1개의 읽기 메서드(hasActiveActivity)를 노출합니다. 224줄의 프로덕션 코드는 startActivity 내부의 한 가지 특정 위험을 방어합니다. 즉, 동시 시작 호출과 해당 메서드의 각 await 경계를 가로지르는 취소 검사입니다.3
  • ContentState는 6개의 필드를 가집니다: endTime, currentCycle, totalCycles, isPaused, isCompleted, remainingSeconds. 처음 다섯 개는 상태 머신의 라벨입니다. 여섯 번째(remainingSeconds)는 ActivityKit의 라이브 timerInterval이 처리할 수 없는 정적 표시용 폴백입니다.
  • 해제 정책 결정은 진정한 제품 결정입니다. 사용자 리셋에는 .immediate, 완료에는 .after(Date().addingTimeInterval(3))을 사용하고, 시스템 기본값은 절대 사용하지 않습니다.
  • Dynamic Island compact-trailing 영역의 타이머 텍스트에는 RTL 시스템 로케일에서 라틴 숫자를 LTR로 유지하기 위해 .environment(\.layoutDirection, .leftToRight)이 필요합니다.

상태 머신

출시된 Live Activity는 하나의 유휴 상태, 사용자가 관찰할 수 있는 세 가지 라이브 상태, 하나의 종료 상태, 그리고 개발자가 관찰해야 하는 하나의 재진입 게이트를 가집니다.

┌──────────────────────────────────────────────────────────────────┐
│                  Lifecycle states                                 │
├──────────────────────────────────────────────────────────────────┤
│  IDLE          currentActivity == nil; no Live Activity present   │
│  RUNNING       isPaused=false, endTime > Date()                   │
│  PAUSED        isPaused=true, remainingSeconds=N                  │
│  CYCLE_END     isPaused=false, endTime <= Date(), isCompleted=false│
│  COMPLETE      isCompleted=true (terminal; transitions to IDLE)   │
└──────────────────────────────────────────────────────────────────┘
              │
              ↓
┌──────────────────────────────────────────────────────────────────┐
│             Dismissal policies (Apple)                            │
├──────────────────────────────────────────────────────────────────┤
│  .immediate            user reset                                  │
│  .after(now + 3s)      completion display window                   │
│  .default              system decides; can stay up to 4 hours      │
└──────────────────────────────────────────────────────────────────┘

Reentrancy gate inside startActivity():
  isStartingActivity flag + cancellable startActivityTask
  prevents two concurrent startActivity() calls from creating
  two Live Activities for one timer. Cancellation checks across
  each await keep the in-flight task safe to abort.

렌더링 경로는 isPaused를 먼저 확인합니다. 이 순서가 벽시계 시간이 endTime을 지났을 때 일시 정지된 타이머가 CYCLE_END로 렌더링되지 않도록 합니다.7

상태 이름은 숫자에 붙이는 라벨이 아닙니다. 상태 이름은 LiveActivityManager(SwiftUI 뷰가 있는 앱 측)와 ReturnLiveActivity(Apple의 프로세스가 표면을 렌더링하는 위젯 익스텐션) 사이의 계약입니다.

이 계약은 TimerActivityAttributes.ContentState이며, 6개 필드 모두입니다.3

public struct ContentState: Codable, Hashable {
    var endTime: Date
    var currentCycle: Int
    var totalCycles: Int?
    var isPaused: Bool
    var isCompleted: Bool = false
    var remainingSeconds: Int = 0
}

모든 상태 전환은 이 구조체를 변경하고 ActivityKit에 위젯 익스텐션으로 프로세스 경계를 넘어 전달하도록 요청합니다. 그러면 위젯이 다시 렌더링됩니다. 공유 메모리는 없습니다. 콜백도 없습니다. 모든 전환마다 프로세스 경계를 넘는 Codable 구조체가 있을 뿐입니다.

이 사실은 클로저, 뷰 모델, 옵저버블 객체, 또는 계산 프로퍼티로 하고 싶을 수 있는 것들을 모두 배제합니다. 상태는 직렬화 가능한 데이터로 표현되어야 합니다. 인코딩할 수 없으면 전환할 수 없습니다.

재진입 시작

Live Activity는 동시 액티비티 수에 대한 하드 리미트와 진행 중인 Activity.request를 두 번 호출했을 때 어떻게 되는지에 대한 소프트 리미트가 있습니다. 하드 리미트는 잘 문서화되어 있습니다.4 소프트 리미트는 “두 번째 호출이 성공하여 고아를 만들 수 있다”입니다. 고아는 매니저의 currentActivity와 더 이상 연관되지 않은 Live Activity입니다. 그것은 살아남습니다. 코드로 돌아가는 경로가 없습니다. 결국 자체 노후화 타이머에 의해 해제됩니다. 사용자는 그때까지 중복된 타이머를 봅니다.

고아는 Return의 v1 출시 버그였습니다. 수정 사항은 재진입 게이트와 LiveActivityManager.swift의 취소 가능한 Task입니다.3

private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?

func startActivity(...) {
    #if os(iOS)
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
    guard !isStartingActivity else { return }
    isStartingActivity = true

    startActivityTask?.cancel()

    startActivityTask = Task {
        defer {
            isStartingActivity = false
            startActivityTask = nil
        }
        guard !Task.isCancelled else { return }

        await endActivity()  // explicit cleanup of any prior state

        guard !Task.isCancelled else { return }

        // ... build attributes + contentState ...

        do {
            let activity = try Activity.request(...)
            guard !Task.isCancelled else { return }
            currentActivity = activity
        } catch {
            // log; flag clears via defer
        }
    }
    #endif
}

이 패턴에 대해 문서가 언급하지 않는 세 가지 사항이 있습니다.

isStartingActivity 플래그가 능동적인 보호 장치이고, startActivityTask?.cancel()은 방어적인 정리입니다. 플래그는 첫 번째 호출이 진행 중일 때 두 번째 startActivity 호출을 단락시키므로, 실제로 공개 경로에서 경쟁이 발생하지 않습니다. 그럼에도 cancel-then-replace 절차가 중요한 이유는 진행 중인 Task가 비동기이며 단명한 호출자보다 더 오래 살 수 있기 때문입니다. 취소는 호출자가 떠난 후에도 오래된 Task가 계속 실행되는 것을 방지합니다.

await 경계를 가로지르는 guard !Task.isCancelled 검사. Swift에서 취소는 협력적입니다. cancel이 호출되더라도 Task는 명시적으로 확인할 때까지 계속 실행됩니다. 각 await는 확인할 기회입니다. await 이후의 검사가 없으면, 취소된 Task는 액티비티 상태를 계속 빌드하고, Activity.request를 호출하며, 성공 시 조용히 고아를 생성합니다.

defer는 Task 본문이 완료되기 전에 플래그를 지웁니다. defer가 없으면 (취소 검사로 인한) 조기 returnisStartingActivity = true를 영구적으로 남기고 앱을 다시 시작할 때까지 액티비티가 다시 시작되지 않습니다. 플래그는 잠금이며, 잠금은 모든 종료 경로에서 해제되어야 합니다.

pushType: nil 인수. Return은 APNs 푸시 기반 Live Activity 업데이트를 사용하지 않습니다. 앱은 activity.update를 통해 액티비티를 로컬로 업데이트합니다. 푸시 기반 업데이트(배송 추적, 스포츠 점수, 실시간 데이터)가 필요한 경우 타입은 pushType: .token이며 계약이 극적으로 더 복잡해집니다.5 로컬 업데이트는 더 간단하며 모든 타이머 / 카운터 / 단일 앱 워크플로를 다룹니다.

일시 정지 문제

ActivityKit은 앱의 업데이트 없이 라이브 카운트다운을 렌더링하는 아름다운 Text(timerInterval: Date()...endTime, countsDown: true) 뷰를 제공합니다.6 종료 시간을 설정하면 시스템이 라이브 타이머를 렌더링합니다. Timer.publish도, 위젯 새로고침도, 배터리 소모도 없습니다.

이는 타이머가 실행 중일 때는 환상적입니다. 타이머가 일시 정지되면 잘못됩니다.

timerInterval 텍스트는 상태의 “일시 정지” 신호와 관계없이 endTime을 향해 카운트다운합니다. Apple의 API에는 “10:23에 멈춤” 모드가 없습니다. endTime = Date().addingTimeInterval(623)을 전달하고 사용자가 10:23 지점에서 일시 정지하면, 위젯의 타이머 텍스트는 계속 0까지 카운트다운합니다. 상태 필드는 일시 정지라고 말합니다. 위젯은 실행 중으로 렌더링합니다.

해결책은 동일한 상태에서 두 가지 다른 뷰를 렌더링하는 것입니다.7

if context.state.isPaused {
    // static text
    Text(formatTime(context.state.remainingSeconds))
        .monospacedDigit()
} else if context.state.endTime > Date() {
    // live countdown
    Text(timerInterval: Date()...context.state.endTime, countsDown: true)
        .monospacedDigit()
} else {
    // post-end static
    Text("0:00")
        .monospacedDigit()
}

투 트랙 렌더링은 ContentStateremainingSeconds를 별도의 필드로 가지는 이유입니다. 타이머가 실행 중일 때는 중복됩니다(시스템이 endTime에서 계산합니다). 타이머가 일시 정지된 경우에는 유일한 진실의 원천입니다. 구조체의 두 절반은 두 가지 다른 렌더링 모드를 제공합니다. isPaused 불리언이 그 사이에서 선택합니다.

해제 정책

activity.end(_:dismissalPolicy:)는 세 가지 ActivityUIDismissalPolicy 값 중 하나를 받으며, 잘못된 선택은 v1이 리셋 후 사용자의 잠금 화면에 영원히 머무는 것처럼 느껴지게 만든 것입니다.13

정책 사용 시점 결과
.immediate 사용자 리셋, 오류, 추적할 액티비티 없이 앱이 백그라운드로 전환된 경우 액티비티가 즉시 사라짐. 유예 기간 없음
.after(date) 완료 표시: “명상이 완료되었습니다”는 잠시 동안 읽을 수 있어야 함. 날짜는 Apple이 허용하는 4시간 이내여야 함 액티비티가 최종 상태를 표시한 다음 date에 해제됨
.default Apple의 휴리스틱이 결정하기를 진정으로 원할 때 시스템이 “어느 정도 시간 동안”(Apple의 표현) 표시 유지, end 호출 후 최대 4시간

Return은 자연스러운 완료 경로에 .after(Date().addingTimeInterval(3))을 사용합니다.3

await activity.end(
    .init(state: contentState, staleDate: nil),
    dismissalPolicy: .after(Date().addingTimeInterval(3))
)

3초는 사용자가 잠금 화면을 흘끗 보고 타이머가 끝났음을 인식하며 체크마크의 만족감을 느끼는 데 필요한 시간입니다. 3초 미만은 너무 빠르게 느껴집니다. 3초보다 길면 액티비티가 자신이 끝났음을 모르는 것처럼 느껴집니다.

사용자가 트리거한 리셋의 경우 호출은 dismissalPolicy: .immediate입니다. 유예 기간이 없습니다. 사용자는 이미 알고 있습니다.

v1의 잘못된 선택은 .default였습니다. 완료된 명상 타이머의 경우 시스템이 액티비티를 충분히 오래 표시 유지하여 사용자들이 앱이 완료를 전혀 등록하지 않았다고 생각했습니다. Apple의 문서는 .default가 종료된 액티비티를 “어느 정도 시간 동안” 최대 4시간까지 표시 유지한다고 명시합니다.13 타이머에 대한 올바른 자세는 해제를 명시적으로 만드는 것입니다.

Dynamic Island Compact 영역

Dynamic Island에는 세 가지 렌더링 모드가 있으며, 단순한 타이머에서도 세 가지 모두가 필요합니다.2

  • Compact(기본 Dynamic Island 모양): leading 아이콘 + trailing 타이머
  • Minimal(다른 Live Activity가 동일한 Dynamic Island를 두고 경쟁할 때): leading 아이콘만
  • Expanded(롱 프레스): 네 개의 명명된 영역(leading, trailing, center, bottom)

Return에서 자리를 차지한 패턴은 expanded 뷰를 compact와 거의 동일하게 만드는 것입니다.8

DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        Image("AppIconSmall")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 16, height: 16)
            .clipShape(RoundedRectangle(cornerRadius: 4))
    }
    DynamicIslandExpandedRegion(.trailing) {
        TimerText(...)
    }
    DynamicIslandExpandedRegion(.center) { EmptyView() }
    DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
    Image("AppIconSmall")...
} compactTrailing: {
    TimerText(...)
} minimal: {
    Image("AppIconSmall")...
}

대부분의 Live Activity 튜토리얼은 expanded 뷰를 “진정한” 디자인으로 강조하며 bottom 영역에 풍부한 콘텐츠를 넣습니다. 명상 타이머의 경우 expansion은 군더더기입니다. 사용자는 롱 프레스로 expanded 뷰를 열고, 롱 프레스는 이미 무언가가 일어났다는 햅틱 피드백을 줍니다. 콘텐츠를 추가하면 expansion이 사용자가 요청하지 않은 것을 말하게 됩니다. expanded 모드의 빈 영역은 디자인의 실패가 아닙니다. 그것이 디자인입니다.

RTL 버그

프로덕션 버그입니다. iOS의 아랍어와 히브리어 사용자들이 Dynamic Island compact-trailing 타이머가 숫자를 거꾸로 렌더링한다고 보고했습니다. 라틴 숫자 문자열 5:2332:5로 렌더링되는 이유는 compact-trailing 레이아웃 방향이 시스템 로케일의 RTL 설정을 상속받기 때문이었습니다.

SwiftUI는 위젯 프로세스 내에서 시스템 레이아웃 방향을 상속받으므로, 사용자의 폰이 아랍어나 히브리어로 설정되어 있으면 Dynamic Island 타이머 텍스트가 RTL을 받습니다. 라틴 숫자는 그 외에는 RTL UI 내에서도 LTR로 렌더링되어야 합니다. 수정 사항은 숫자 텍스트 뷰에 레이아웃 방향을 고정하는 것입니다.7

.environment(\.layoutDirection, .leftToRight)

오버라이드는 TimerText(Dynamic Island compact / expanded) 내부와 잠금 화면 뷰 내부의 숫자 Text 뷰에 적용되며, 전체 뷰에는 적용되지 않습니다. 라틴 숫자는 사용자의 시스템 로케일에 관계없이 왼쪽에서 오른쪽으로 읽힙니다. “Cycle 2 of 3”과 같은 사이클 라벨은 시스템 레이아웃 방향을 따르도록 로컬라이즈된 상태로 유지됩니다.

이 버그는 국내 로케일 TestFlight에서는 나타나지 않습니다. 실제 RTL 사용자가 타이머를 여는 순간 나타납니다. 교훈: RTL 로케일에서 실행될 수 있는 모든 Live Activity의 모든 라틴 숫자 텍스트 뷰에 LTR 고정 environment 오버라이드를 출시하세요.

로컬라이제이션 이야기

TimerActivityAttributes는 액티비티 생성 시 앱이 설정하는 languageCode: String 필드를 가집니다.9

let attributes = TimerActivityAttributes(
    timerDuration: duration,
    languageCode: settings.appLanguage  // app's selected language, not system's
)

위젯 익스텐션은 이를 읽어 로컬라이즈된 문자열을 렌더링합니다.

private var locale: Locale {
    let code = context.attributes.languageCode
    return code.isEmpty ? .current : Locale(identifier: code)
}

private func localized(_ key: String.LocalizationValue) -> String {
    String(localized: key, locale: locale)
}

위젯이 Locale.current를 읽도록 두지 않고 앱이 자체 언어 코드를 전달하는 이유: 위젯 익스텐션은 자체 프로세스에서 실행됩니다. 그 Locale.current는 앱의 선택된 로케일이 아닌 시스템 로케일입니다. 사용자가 iPhone을 영어로 설정한 상태에서 Return을 한국어로 설정한 경우, 이 오버라이드 없이는 위젯이 영어로 표시됩니다. 앱의 언어 기본 설정은 액티비티 속성을 통해 전달됩니다. 위젯은 그것을 존중합니다.

Localizable.xcstrings는 위젯 타깃에서 앱과 함께 존재하지만, 별도의 파일입니다. 위젯에서 사용되는 문자열은 Return/Localizable.xcstrings에 동일한 문자열이 존재하더라도 ReturnWidgets/Localizable.xcstrings에 있어야 합니다. 이를 잊으면 앱이 한국어로 표시되는 동안 위젯은 개발 언어로 폴백합니다.

다르게 만들고 싶은 것

ContentState를 더 작게 만들기. 6개 필드는 너무 많습니다. endTimeremainingSeconds 사이의 중복은 timerInterval의 일시 정지 모드 부재를 우회하는 비용입니다. 다시 시작한다면 단일 displayMode 열거형(running, paused(remainingSeconds: Int), cycleEnd, complete)을 사용하고 렌더링 코드가 케이스에 따라 디스패치하도록 할 것입니다. 6개 필드를 5개의 전환 메서드에서 올바르게 변경되도록 유지하는 것은 4개 케이스보다 더 어렵습니다.

대화형 Live Activity 버튼 추가(iOS 17+). Return은 현재 Dynamic Island에서 일시 정지/재개 컨트롤을 노출하지 않습니다. 사용자는 일시 정지하려면 앱을 열어야 합니다. iOS 17은 Live Activity 내에 App Intent를 위한 Button(intent:)을 추가했습니다.10 대화형 일시 정지 컨트롤은 명백한 확장이며 Return을 위해 다음에 출시할 것입니다.

크로스 디바이스 타이머 동기화를 위한 푸시 업데이트 Live Activity. Return은 NSUbiquitousKeyValueStore를 통해 iPhone, iPad, Watch, Apple TV 간에 세션을 동기화합니다(Five Apple Platforms, Three Shared Files에서 다룸). 오늘날 액티비티는 iPhone 또는 iPad 앱에서 로컬로 시작되고 로컬로 업데이트됩니다. Apple Watch에서 타이머를 시작하는 사용자는 iPhone에서 실시간으로 Live Activity가 이를 반영하는 것을 이상적으로 볼 수 있습니다. Live Activity로의 APNs 푸시가 그 경로입니다.5 아직 구축하지 않았습니다.

Live Activity를 사용하지 않을 때

일회성 일시적 상태. “저장됨!” 토스트는 Live Activity를 받을 자격이 없습니다. 시스템에는 배너가 있습니다. 그것을 사용하세요.

타이머 차원 없이 자주 변경되는 데이터. Live Activity는 명확한 시간적 앵커가 있는 것에 가장 잘 작동합니다(타이머, 배송 ETA, 게임 시계, 통화 시간). 주식 시세와 스포츠 점수는 세션 윈도우가 있기 때문에 작동합니다. 범용 대시보드는 그렇지 않습니다.

잠금 화면 / 스탠바이 사용 사례가 없는 앱. Live Activity는 실제 엔지니어링 투자가 필요합니다(타깃 설정, ContentState 설계, 해제 정책 결정, RTL 처리, 로컬라이제이션 배관). 사용 중에 잠금 화면을 참조하지 않고 사용자가 직접 여는 앱은 적합한 형태가 아닙니다. 사진 편집기는 필요하지 않습니다. 운동 트래커는 필요합니다.

iOS가 아닌 표면에서, 주의 사항과 함께. Return의 LiveActivityManager는 타이머가 iPhone 또는 iPad 앱에서 시작되기 때문에 #if os(iOS) 뒤에 구현을 출시합니다. ActivityKit 자체는 잠금 화면 배너, Dynamic Island, Apple Watch Smart Stack, Mac, CarPlay를 프레젠테이션 표면으로 설명하며, iOS 26은 이들 중 일부를 확장했습니다.4 watchOS는 여전히 풀스크린 렌더링을 위한 자체 컴플리케이션 API를 가지고 있습니다. macOS에는 메뉴 바 앱이 있습니다. iPadOS는 iPadOS 17부터 Live Activity를 지원하며 Dynamic Island 영역은 없습니다. Return의 매니저는 224줄 파일 하나에 8개의 #if os(iOS) 가드를 가집니다.

이 패턴이 iOS 26+에서 출시되는 앱에 의미하는 것

두 가지 시사점.

  1. Live Activity를 숫자가 아닌 상태 머신으로 취급하세요. 상태 머신에는 명확한 상태, 명확한 전환, 명확한 해제 규칙이 있습니다. 화면의 숫자는 한 상태의 한 가지 렌더링입니다. 먼저 상태를 올바르게 만드세요.

  2. 재진입 가드는 아직 마주치지 않은 버그입니다. isStartingActivity + 취소 가능한 Task를 구현하지 않은 모든 Live Activity 매니저는 적어도 하나의 고아 액티비티 버그를 출시했습니다. 가드는 6줄입니다. 한 번 작성하세요.

이 글을 같은 앱 패밀리에 대한 이전 글과 함께 보세요: Apple Intelligence를 위한 타입드 App Intents; 크로스 LLM 에이전트를 위한 MCP 서버; 비주얼 레이어를 위한 Liquid Glass 패턴; 크로스 디바이스 도달을 위한 멀티 플랫폼 출시. Live Activity는 동일한 스택의 iOS 잠금 화면과 Dynamic Island 레이어입니다. 전체 세트는 Apple Ecosystem Series 허브에 있습니다. 더 넓은 iOS와 AI 에이전트 컨텍스트는 iOS Agent Development guide를 참고하세요.

FAQ

Live Activity와 WidgetKit 위젯의 차이는 무엇인가요?

WidgetKit 위젯은 TimelineProvider가 정의한 간격으로 렌더링됩니다. 시스템이 새로 고침할 시점을 결정하고 위젯은 정적 타임라인에서 다시 렌더링됩니다.11 Live Activity는 특정 앱 주도 activity.update(...) 호출에 응답하여 렌더링되며 기본 액티비티(타이머, 배송, 운동)의 지속 시간 동안 살아 있습니다. 둘 다 위젯 익스텐션 타깃에서 출시됩니다. 차이점은 트리거 모델입니다.

Live Activity는 iPad에서 작동하나요?

예, iPadOS 17+에서 작동합니다. 잠금 화면 배너가 주요 렌더링 표면입니다. iPad에는 Dynamic Island가 없습니다. 동일한 ActivityConfiguration 코드가 작동합니다. 단지 Dynamic Island 영역이 iPad에서는 절대 렌더링되지 않을 것을 예상하세요.

Live Activity가 내 앱 프로세스보다 오래 살 수 있나요?

예. Activity.request가 성공하면 ActivityKit이 액티비티를 소유합니다. 앱 프로세스는 시스템에 의해 종료될 수 있습니다. 액티비티는 명시적으로 종료할 때까지(또는 시스템 노후화 규칙이 해제할 때까지) 잠금 화면과 Dynamic Island에서 계속 렌더링됩니다. 명시적인 endActivity() 호출이 그래서 중요합니다. 앱 리셋 시 명시적인 종료가 없으면 액티비티가 타이머보다 오래 살아남습니다.

이 글이 푸시 업데이트 Live Activity를 다루지 않는 이유는 무엇인가요?

Return에서 푸시 업데이트 Live Activity를 출시하지 않았습니다. 이 클러스터의 장르 규칙에 따라: 출시된 코드 글은 프로덕션 코드가 하는 것만 문서화합니다. 푸시 업데이트는 “다르게 만들고 싶은 것”에 나열되어 있습니다. 향후 글에서 출시한 후에 다룰 것입니다.

SwiftUI 앱에서 Live Activity의 실제 파일 레이아웃은 어떻게 되나요?

세 가지 부분:3712

  • 메인 앱 타깃에서: LiveActivityManager.swift(액티비티 라이프사이클 관리), TimerActivityAttributes.swift(위젯과 공유되는 ActivityAttributes 구조체. 두 타깃 모두 이 파일을 컴파일).
  • 위젯 익스텐션 타깃에서: ReturnLiveActivity.swift(ActivityConfiguration 본문이 있는 Widget 준수), ReturnWidgetsBundle.swift(@main WidgetBundle).
  • 구성: 앱 타깃에 NSSupportsLiveActivities = YES가 있는 Info.plist.

위젯 익스텐션 타깃은 ActivityKit과 WidgetKit 임포트가 필요합니다. TimerActivityAttributes는 두 타깃 간에 공유되는 유일한 파일입니다. 그 외 모든 것은 타깃 격리됩니다.


Live Activity는 잠금 화면의 숫자가 아닙니다. 그것은 모든 전환마다 프로세스 경계를 넘는 상태 머신입니다. 상태를 올바르게 만들고, 재진입을 가드하고, 해제 정책을 의도적으로 선택하고, 레이아웃 방향을 고정하세요. 숫자는 알아서 처리됩니다.

참고 자료


  1. 저자의 Return, 2026년 4월 21일 App Store에 출시된 SwiftUI 명상 타이머. iPhone, iPad, Mac, Apple Watch, Apple TV에서 사용 가능. Live Activity는 iOS 타깃에서만 출시. 

  2. Apple Developer, “ActivityKit framework”. 잠금 화면 배너, Dynamic Island compact / minimal / expanded 모드, 액티비티 라이프사이클. iOS 16.1+에서 사용 가능. Dynamic Island는 iPhone 14 Pro 이상에서 사용 가능. 

  3. 프로덕션 코드는 Return/Return/LiveActivityManager.swift(224줄, 8개 #if os(iOS) 블록)와 Return/Return/TimerActivityAttributes.swift(43줄). 타깃 멤버십을 통해 앱 타깃과 위젯 익스텐션 타깃 사이에 공유됨. 

  4. Apple Developer, “Displaying live data with Live Activities”. 동시성 한계, 지원 플랫폼(iOS 16.1+, iPadOS 17+), NSSupportsLiveActivities Info.plist 키. 

  5. Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. pushType: .token 경로는 별도의 APNs 인증 키, 서버 측 푸시 토큰 등록, 그리고 로컬 activity.update(...) 호출과 다른 업데이트 프로토콜이 필요. 

  6. Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. 라이브 시스템 렌더링 카운트다운 타이머. 액티비티가 실행 중일 때 앱 업데이트 없이 렌더링됨. 

  7. 프로덕션 코드는 Return/ReturnWidgets/ReturnLiveActivity.swift(232줄). ActivityConfiguration<TimerActivityAttributes> 본문을 가진 위젯 익스텐션의 Widget 준수. 61-102줄의 TimerText 뷰가 일시 정지 / 실행 / 종료 후 세 가지 상태 렌더링을 처리. 

  8. Apple Developer, “DynamicIsland”. 네 개의 명명된 expanded 영역(leading, trailing, center, bottom)과 세 개의 compact 모드 뷰(compactLeading, compactTrailing, minimal). 

  9. 위젯 익스텐션은 자체 프로세스에서 실행되며 앱의 선택된 로케일이 아닌 시스템 로케일을 상속. 인앱 언어 전환을 지원하는 앱(Return은 27개 언어 지원)은 위젯이 사용자가 선택한 언어로 렌더링할 수 있도록 ActivityAttributes를 통해 언어 코드를 전달해야 함. 패턴: Locale.current가 아닌 Locale(identifier: context.attributes.languageCode)

  10. Apple Developer, “Button(intent:)”. iOS 17+의 위젯 및 Live Activity 뷰에서 사용 가능. 앱 포그라운딩 없이 App Intent를 잠금 화면 / Dynamic Island 컨트롤로 연결. 

  11. Apple Developer, “TimelineProvider”. Live Activity 이전의 위젯 새로 고침 모델. 시스템 관리 새로 고침 윈도우가 있는 사전 계산된 엔트리. 

  12. 프로덕션 코드는 Return/ReturnWidgets/ReturnWidgetsBundle.swift(16줄). ReturnLiveActivity를 위젯 익스텐션의 유일한 위젯으로 등록하는 @main WidgetBundle. 위젯 익스텐션의 필수 패턴. 번들이 시스템이 로드하는 것임. 

  13. Apple Developer, “ActivityUIDismissalPolicy”. 세 가지 케이스: .default, .immediate, .after(_:). Apple은 .default가 종료된 Live Activity를 “어느 정도 시간 동안” 최대 4시간까지 표시 유지하며, .after(_:)는 동일한 4시간 윈도우 내의 날짜를 받는다고 명시. 

관련 게시물

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 분 소요

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 분 소요

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 분 소요