강프로의 실험실

직접 만들고 운영하면서 남기는 개발 기록

ActivityKit 잠금화면 카운트다운, 네 가지를 다 써보고 남은 결론

·

아이의 하교 시각까지 남은 시간을 잠금화면에 띄우는 기능을 만들면서, ActivityKit에서 카운트다운을 그리는 방법을 네 번 갈아엎었다. 마지막 방식은 6개월 전에 “인프라 비용 때문에 안 한다”고 명시적으로 접었던 바로 그 설계였다. 왜 되돌아왔는지, 그 과정에서 ProgressView(timerInterval:)가 어떻게 배신했는지 적는다.

요구사항

잠금화면과 Dynamic Island에 남은 시간을 보여준다. 조건이 두 개 있었다.

  • 앱이 꺼져 있어도 동작해야 한다. 사용자는 앱을 켜두지 않는다. 커스텀 TimerTask는 프로세스가 죽으면 같이 멈추므로 애초에 후보가 아니다.
  • 예정 시각이 지나도 이상해지면 안 된다. 하교 시각이 지났다고 LiveActivity를 즉시 없애면 “확인하려던 순간 사라지는” 문제가 생겨서, 5분의 유예 구간을 두기로 했다.

Text(date, style: .relative) — 만료되면 거꾸로 센다

가장 먼저 손이 가는 방법이다. OS 렌더링 레이어가 알아서 갱신해주니 앱이 꺼져 있어도 숫자가 흐른다.

문제는 유예 구간에서 드러났다. .relative는 미래에서 과거 방향으로 계속 업데이트된다. 예정 시각이 지나는 순간 “3분 남음”이 “1초 전”, “2초 전”으로 카운트을 시작한다. 5분 내내 그 상태가 노출된다.

Text(timerInterval:countsDown:) + 클램핑

범위의 끝을 현재 시각으로 클램핑하면 만료 후 0에서 멈춘다.

Text(timerInterval: Date()...max(end, Date()), countsDown: true)
    .environment(\.locale, Locale(identifier: "ko_KR"))
    .monospacedDigit()

max(end, Date()) 덕분에 이미 지난 경우 범위 길이가 0이 되어 0:00에서 정지한다. 진행 바도 같은 계열로 맞췄다. 이전에 GeometryReader로 만든 커스텀 진행 바가 iOS 26 위젯에서 크래시를 낸 이력이 있어서, 시스템 컴포넌트로 가는 편이 안전하다고 봤다.

ProgressView(timerInterval: start...clampedEnd, countsDown: false)
    .progressViewStyle(.linear)
    .tint(done ? .red : color)
    .labelsHidden()

// clampedEnd = max(end, start.addingTimeInterval(1))
// start == end 일 때 range 오류 방지

여기까지가 2026년 6월의 결론이었다. 잘 돌았다. 문구를 바꾸기 전까지는.

③ 문구를 자연어로 바꾸는 순간 전제가 무너진다

07:12 같은 숫자보다 “12분 전”, “곧 하교” 같은 말이 잠금화면에서 훨씬 잘 읽힌다. 그래서 카피를 바꾸기로 했는데, 여기서 걸렸다.

Text(timerInterval:)은 OS가 갱신해주는 특수 컴포넌트다. 하지만 표시할 문자열을 우리가 정하는 순간, 그건 그냥 정적 Text가 된다. 정적 텍스트는 위젯 body가 다시 그려질 때, 즉 push를 받을 때만 값이 바뀐다.

다시 말해 “문구 개편”이라는 UI 작업이 “서버가 주기적으로 push를 보내야 한다”는 인프라 요구사항을 끌고 들어온다. 이건 6월에 비용 문제로 명시적으로 접었던 설계였다.

④ 접었던 결정을 다시 꺼냈다 — 서버 계산 + 매분 push

다시 계산해보니 전제가 이미 바뀌어 있었다.

  1. 비용은 사실상 해소된 상태였다. 알림 발송 잡이 이미 매분 돌고 있었고, 그 안에서 만료된 LiveActivity를 전수 스캔하는 루틴도 매분 실행 중이었다. 같은 자리에 update 전송을 얹는 한계 비용은 거의 0이다. 6월의 “비용” 판단은 그 시점의 구조를 보고 내린 것이었는데, 그 구조가 그새 바뀌어 있었다.
  2. 진짜 리스크는 따로 있었다. 구버전 앱이 이 빈도의 push를 감당하지 못해 APNs frequent-updates budget을 넘기면 throttle된다. 이건 비용이 아니라 호환성 문제고, 게이팅으로 풀 수 있다.

그래서 device_push_tokensapp_version 컬럼을 하나 추가하고, 최소 버전을 넘는 기기에만 매분 update를 보낸다. 앱 실행 로그 API를 따로 만들지 않았다 — 토큰 등록 엔드포인트가 이미 앱 실행마다 호출되는 데다, 그 테이블이 곧 발송 대상 테이블이라 가장 자연스러운 자리였다.

버전 비교는 문자열로 하면 안 된다. "1.10.0" < "1.9.0"으로 잘못 판정된다. 정수 튜플로 변환해 비교하고, 파싱 실패·NULL·자릿수가 모자란 값은 전부 fail-closed로 구버전 취급한다.

표시 문구는 서버가 계산해 remainingLabel로 내려준다. 클라이언트가 estimatedEndAt으로 직접 계산하면 기기와 서버의 시계 오차만큼 표시가 어긋난다. 올림 반올림이라 9분 30초 남으면 “10분 전”이 된다.

그리고 진행 바가 배신했다

숫자만 정적 텍스트로 바꾸고 진행 바는 ProgressView(timerInterval:) 그대로 뒀다. “얘는 OS가 알아서 채우니 push 빈도와 무관하게 안전하다”고 판단했다. 틀렸다.

실기기에서 매분 push가 갈 때마다 진행 바가 순간적으로 초기화됐다가 다시 채워졌다. start/end 값 자체는 그대로인데도 그랬다. 위젯 콘텐츠가 재전달될 때마다 이 컴포넌트의 내부 연속 애니메이션 상태가 다시 초기화되는 것으로 보인다.

여기서 얻은 게 이 글에서 제일 쓸모 있는 문장이다.

timerInterval 계열 컴포넌트에서 “push 없이도 자동 갱신된다”와 “잦은 push에도 안정적이다”는 별개의 속성이다. 앞엣것은 보장하지만 뒤엣것은 보장하지 않는다.

해결은 ProgressView(value:)로 내려오는 것이었다. 진행률을 렌더 시점에 직접 계산한다.

func progressValue(for state: ChildActivityState, at now: Date, done: Bool) -> Double {
    if done { return 1 }
    let start = state.triggeredAt
    let end   = state.estimatedEndAt
    guard end > start else { return 1 }
    return min(max(now.timeIntervalSince(start) / end.timeIntervalSince(start), 0), 1)
}

핵심은 이게 순수 함수라는 점이다. 같은 입력이면 항상 같은 값이 나오므로, push가 몇 번을 오든 값이 안 바뀌면 화면도 안 바뀐다. 리셋이 구조적으로 불가능해진다. 이 결정론 자체를 “같은 push를 반복해서 받아도 값이 동일하다”는 테스트로 고정해뒀다.

덤으로 걸려 나온 버그 두 개

매분 push로 바꾸자 잠재해 있던 문제들이 표면으로 올라왔다.

“다음 일정”이 매분 null로 덮어써지고 있었다. update 경로가 content state를 만들 때 다음 일정 인자를 아예 안 넘기고 있어서, push가 갈 때마다 해당 섹션이 조용히 비워졌다. 일회성 발송일 때는 드러날 일이 없던 버그다.

매분 update가 하루 종일 한 건도 안 나갔다. 게이팅에 쓸 기기 row를 ORDER BY 없이 first()로 뽑고 있었고, 하필 몇 달 전에 쓰다 만 아이패드 row(app_versionNULL)가 반환됐다. fail-closed 정책상 무조건 skip. updated_at DESC 정렬을 추가해 “가장 최근에 토큰을 갱신한 기기”를 우선하도록 고쳤다. 근본 해법은 LiveActivity와 기기를 FK로 잇는 것이지만, 가장 흔하고 파괴적인 케이스는 이걸로 막힌다.

두 번째 건은 교훈이 하나 더 있다. “실사용 패턴상 감내 가능하다”고 판단해 남겨둔 알려진 한계라도, 그 코드 경로가 일회성에서 상시 실행으로 바뀌면 노출 빈도가 완전히 달라진다. 이 한계는 이미 문서에 적어둔 것이었는데도 터졌다.

정리

방식앱 종료 시잦은 push임의 문구
Text(date, style: .relative)동작불가 · 만료 후 카운트업
Text(timerInterval:)동작불가
ProgressView(timerInterval:)동작리셋됨
서버 계산 + 정적 렌더마지막 값 유지안정가능
커스텀 Timer/Task멈춤가능

선택 기준은 결국 하나다. 표시할 문자열을 우리가 정해야 하는가. 그렇다면 OS 자동 갱신 컴포넌트는 후보에서 빠지고, 서버 주기 push가 전제로 따라온다. 그리고 그 순간 같은 화면에 남아 있는 다른 timerInterval 컴포넌트들도 전부 다시 검토해야 한다 — 우리가 진행 바에서 놓친 지점이 정확히 거기였다.


이 글에서 다룬 서비스

아이벤트

아이의 등하교와 학원 일정을 한 화면에 모아주는 iOS 앱입니다. 종이 알림장은 사진 한 장으로 정리되고, 등하원 시간이 다가오면 잠금화면에 실시간 카운트다운이 뜹니다. 완전 무료입니다.

프로젝트: