🐝매일 한입
개발일지📖 13분 읽기

DaySeed 2.1.2 — 앱을 열지 않아도 일정 1시간 전에 잠금화면 카드가 뜨게 만들기

2.1 의 Live Activity 는 앱이 켜져 있을 때만 시작됐습니다. 2.1.2 는 서버가 APNs push-to-start 로 60분 전에 카드를 띄웁니다. 서버가 반복 일정을 다시 전개하는 이유, 환경별로 키를 나눈 이유, 갱신 토큰이 오지 않아 조용한 푸시로 우회한 기록.

📖 13분 읽기
#DaySeed#Live Activity#APNs#push-to-start#Expo#Supabase#1인개발

앱을 켜야만 뜨는 알림은 알림이 아니었다

DaySeed 2.1.0 에 "다음 일정" Live Activity 를 넣은 게 10월 9일입니다. 시작 60분 전부터 잠금화면과 Dynamic Island 에 제목·장소·남은 시간이 나오는 카드인데, 한 가지 전제가 있었습니다. iOS 는 백그라운드에서 Activity.request 를 막기 때문에, 카드는 앱이 포그라운드일 때만 시작할 수 있었습니다.

intent 에 적어 둔 발의 문장이 이 기능의 한계를 그대로 말해 줍니다. "앱을 켰다가 껐을 때 일정 표시가 아니라 1시간 전부터 일정 알림 느낌으로 뜨게 해줘." 12시에 앱을 열고 14시 일정이 있으면 13시에 저절로 떠야 하는데, 앱을 다시 열지 않으면 아무 일도 일어나지 않았습니다. 알림처럼 보이지만 알림의 역할은 못 하고 있던 셈입니다.

그래서 2.1.1 심사를 넣어 둔 채로 10월 17일 하루 동안 2.1.2 를 만들었습니다. 먼저 정한 것은 세 가지였습니다. 출시는 2.1.1 승인 뒤 2.1.2 로. 카드가 뜰 때 배너와 소리를 함께. push-to-start 를 쓸 수 없는 기기(iOS 17.0~17.1, 토큰 등록 전)는 지금처럼 앱을 열 때 시작.

서버가 띄우고, 앱은 띄우지 않는다

ActivityKit 에서 앱 없이 Activity 를 시작하는 길은 iOS 17.2 의 push-to-start 하나뿐입니다. 기기가 push-to-start 토큰을 발급하면, 서버가 그 토큰으로 APNs 에 liveactivity 푸시를 보내 카드를 시작할 수 있습니다. Expo 푸시 서비스는 이 종류의 푸시를 보내지 못해서, APNs 토큰 인증으로 직접 보내야 했습니다. 그래서 바뀐 것이 세 층입니다.

네이티브 모듈은 모듈이 만들어지는 순간부터 토큰을 지켜봅니다. JS 가 구독하기 전에 토큰이 올 수 있고, 서버가 띄운 Activity 가 앱을 백그라운드에서 깨울 수도 있어서, 받은 값을 들고 있다가 JS 가 나중에 꺼내 가게 했습니다.

func start() {
  if #available(iOS 17.2, *) {
    startTokens = Task { [weak self] in
      for await data in Activity<NextEventAttributes>.pushToStartTokenUpdates {
        let token = hex(data)
        self?.startToken = token
        self?.emitStart(token)
      }
    }
  }
  for activity in Activity<NextEventAttributes>.activities {
    watch(activity)
  }
  discovery = Task { [weak self] in
    for await activity in Activity<NextEventAttributes>.activityUpdates {
      self?.watch(activity)
    }
  }
}

앱은 로그인 직후 토큰·APNs 환경·시간대·언어·개인/함께 체크 상태를 묶어 서버에 기기 등록을 하고, 로그아웃 전에 등록을 지웁니다. 서버 쪽은 기기 테이블과 "어떤 기기에 어떤 회차를 띄웠나"를 적는 run 테이블, 등록 RPC 세 개, 그리고 1분마다 엣지 함수를 부르는 pg_cron 입니다. 엣지 함수는 매분 기기마다 후보 일정을 고르고 start·update·end 중 하나를 APNs 로 보냅니다. 후보를 고르는 규칙은 2.1 의 앱 쪽 규칙과 같습니다.

여기서 규칙을 하나 더 넣었습니다. push-to-start 가 등록된 기기에서는 앱이 직접 카드를 시작하지 않습니다. 서버와 앱이 둘 다 시작하면 같은 회차가 두 장 뜨기 때문입니다. 앱은 서버가 띄운 카드를 갱신하고, 엉뚱한 카드가 남아 있으면 정리하는 역할만 맡습니다.

// 2.1.2: with `serverStarts` (push-to-start registered for this device, see
// `pushStart.ts`) the server starts the card 60 minutes ahead; the app then
// only updates and ends it, never starts one — two starters would show the
// same occurrence twice.
export async function syncLiveActivity(
  candidate: LiveActivityCandidate | null,
  now: Date,
  opts: { serverStarts?: boolean } = {},
): Promise<void> {

왜 서버가 반복 일정을 다시 전개하나

가장 큰 설계 선택은 반복 일정을 서버에서 다시 전개하기로 한 것입니다. 클라이언트에는 이미 반복 전개 코드가 있으니, 앱이 앞으로의 일정표를 미리 올려 두고 서버는 그걸 읽기만 하는 쪽이 더 쉬워 보입니다. spec 에 남긴 이유는 하나입니다. 앱이 꺼져 있는 동안 파트너가 만들거나 지운 그룹 일정도 반영해야 하는데, 클라이언트가 미리 올린 일정표로는 그걸 알 수 없습니다.

대신 조건을 걸었습니다. 반복 전개의 기준은 어디까지나 클라이언트 코드이고, 서버 포트는 테스트로 결과가 같음을 강제합니다. plan 에 적힌 검증은 무작위 400개 × 4구간에서 앱과 서버의 전개 결과가 일치하는지 보는 것이었습니다. 매일·매주 요일·매월 말일·매년·음력 매년·간격·종료일·횟수·예외일까지 전부 서버에 옮겼습니다.

APNs 키도 하나가 아니라 둘입니다. 시뮬레이터와 개발 빌드는 sandbox 로, 스토어 빌드는 production 으로 가는데, 팀 범위의 "Sandbox & Production" 키는 이미 한도가 차 있어서 topic-specific 키를 환경별로 하나씩 발급했습니다. 함수는 기기가 등록한 APNs 환경으로 키를 고르고, sandbox 키가 없으면 개발 기기에는 아무것도 보내지 않습니다.

async function apply(action: Action, device: Device): Promise<void> {
  const cfg = keys[device.apns_env];
  if (!cfg.keyId || !cfg.privateKey) return; // no sandbox key: dev devices get nothing

APNs 는 공급자 토큰을 20분보다 자주 바꾸면 거부하기 때문에 토큰은 30분 단위로 캐시하고 있었는데, 키가 둘이 되면서 캐시도 키 ID 별 Map 으로 바뀌었습니다.

갱신 토큰이 안 와서 조용한 푸시로 우회했다

push-to-start 로 시작된 Activity 를 서버가 갱신하거나 끝내려면 그 Activity 의 갱신 토큰이 필요하고, 그 토큰은 앱이 받아 서버에 올려야 합니다. 10월 17일 시뮬레이터(iOS 26.5) 실측 기록은 이렇습니다. sandbox push-to-start 로 카드는 정상으로 떴고 날짜·문구·파트너 글리프까지 맞았는데, 시스템은 갱신 토큰을 발급했는데도 앱의 pushTokenUpdates 에는 40초가 넘도록 오지 않았습니다. 페이로드에 input-push-token 을 넣어도 같았습니다.

이 상태로 두면 서버가 띄운 카드를 서버가 못 내립니다. 파트너가 일정을 지워도 카드는 다음 앱 실행까지 남습니다. 첫 커밋의 주석은 "갱신 토큰이 없으면 앱이 다음에 실행될 때 카드를 갱신한다"였는데, 같은 날 두 번째 커밋에서 조용한 푸시로 바꿨습니다. 갱신 토큰이 없는 run 을 끝내거나 갱신해야 할 때, 서버는 Expo 를 통해 content-available 푸시를 그 사용자의 프로필 토큰으로 보냅니다. 앱은 백그라운드 태스크로 깨어나 캘린더 스냅샷을 다시 쓰고, 평소의 동기화 경로가 카드를 내리거나 갱신합니다. 백그라운드에서도 end 와 update 는 허용되기 때문에 가능한 길입니다.

TaskManager.defineTask(LIVE_ACTIVITY_WAKE_TASK, async ({ data, error }) => {
  if (error) return;
  let raw = '';
  try {
    raw = JSON.stringify(data ?? '');
  } catch {
    return;
  }
  if (!raw.includes(WAKE_KIND)) return;
  await writeCalendarSnapshot();
});

한계도 spec 에 그대로 적어 두었습니다. iOS 가 백그라운드 푸시를 미루면 늦어질 수 있고, 그래도 다음 앱 실행에서는 정리됩니다. 그리고 이것은 시뮬레이터 결과입니다. 실기기에서 갱신 토큰이 제대로 오는지는 아직 확인하지 못했고, 그 결과를 spec 에 적는 것이 plan 의 인수 시나리오 마지막 항목입니다.

확인한 것과 남은 것

plan 에 체크된 기록을 기준으로 적습니다. 코드 단계의 검증은 로컬 PG14 에서의 마이그레이션 리허설(재적용, 토큰 회전 시 run 유지, 계정 전환, RLS, anon 차단), 엣지 함수 deno check 와 JWT 서명 검증 스크립트, 반복 전개 앱=서버 테스트, xcodebuild 시뮬레이터 컴파일까지였습니다. simctl push 는 liveactivity 를 일반 알림으로 넣어 버려 push-to-start 를 재현하지 못했고, 그래서 APNs 키가 생긴 뒤에야 sandbox 로 실제 시작을 볼 수 있었습니다.

서버는 10월 17일에 먼저 배포했습니다. 시크릿 여섯 개, 함수, 마이그레이션, Vault 의 cron 비밀. 운영 E2E 는 리뷰어 계정과 시뮬레이터의 sandbox 토큰으로 돌렸고, cron 1분 안에 start 로 카드가 뜨고, 제목 변경에 update, soft delete 에 end 가 나간 것까지 기록돼 있습니다. 테스트 일정·기기·run 은 지운 뒤 남은 행이 0 이었습니다.

서버를 바이너리보다 먼저 보낼 수 있었던 이유는 간단합니다. 구 바이너리는 토큰을 등록하지 않으므로 기기 행이 없고, 기기 행이 없으면 cron 이 함수를 부르지 않습니다.

2.1.2 바이너리(빌드 202610171329)는 같은 날 로컬 빌드 뒤 제출했고, 지금 상태는 심사 대기입니다. 릴리스 노트에는 기념일 화면과 4명 공유처럼 2.1.1 뒤 OTA 로 먼저 나간 것들이 같이 들어갔습니다.

남은 것은 두 가지입니다. 실기기에서 push-to-start 갱신 토큰이 오는지, 그리고 그에 따라 조용한 푸시가 주 경로가 될지 보조 경로로 남을지. plan 의 인수 시나리오 아홉 개는 TestFlight 실기기에서 돌리는 것이라 아직 비어 있습니다. 판정 지표는 run 테이블의 시작 수와, 2.1 의 앱 시작 대비 카드 탭 비율로 잡아 두었습니다.

DaySeed 는 매일의 습관을 한 번의 탭으로 기록하고 캘린더를 가장 가까운 사람과 함께 쓰는 iOS 앱입니다. App Store 에서 받기

💬 댓글