일출/일몰 알림은 “매일 같은 시간”이 아니라 “매일 다시 계산되는 시간”을 다루기 때문에 고정 시간 알림보다 훨씬 어렵다. 같은 “일출 10분 전”이라도 사용자 위치, 날짜, 시간대에 따라 시각이 계속 달라지고, 권한 거부나 토큰 만료 같은 실패까지 함께 처리해야 한다.
이 글에서는 태양 추적 앱에서 사용자별 개인화 알림 파이프라인을 구현한 경험을 공유한다. 설정 저장 플로우에서는 사용자 설정과 위치/시간대 데이터를 서버 기준 상태로 확정한다. 예약 발송 플로우에서는 실행 직전 검증으로 오발송을 막고 다음 회차를 재예약한다. 사용자 수신/오픈 플로우에서는 payload 기반 라우팅으로 최종 사용자 경험을 완성한다.
사용 서비스
- Expo: 알림 설정 UI, 권한 처리, 탭 후 라우팅을 담당한다.
- Firebase Functions: 구독 저장, 시각 계산, 예약/발송 오케스트레이션을 담당한다.
- Firestore: 사용자/구독/예약(job) 상태를 저장하는 기준 저장소로 사용한다.
- Cloud Tasks: 예약된 시점에 발송 작업을 정확히 실행하는 트리거로 사용한다.
- Firebase Cloud Messaging (FCM): 디바이스에 푸시 알림을 실제로 전달한다.
1. 설정 저장 플로우
%%{init: {'theme':'dark','themeVariables': {'primaryColor':'#1f2937','primaryTextColor':'#e5e7eb','primaryBorderColor':'#9ca3af','lineColor':'#e5e7eb','actorBkg':'#1f2937','actorTextColor':'#e5e7eb','actorBorder':'#9ca3af','signalColor':'#e5e7eb','signalTextColor':'#e5e7eb','labelBoxBkgColor':'#1f2937','labelTextColor':'#e5e7eb','labelBoxBorderColor':'#9ca3af','sequenceNumberColor':'#111827'}}}%%
sequenceDiagram
autonumber
participant User as User
participant App as Expo App
participant API as Firebase Functions
participant DB as Firestore
participant Queue as Cloud Tasks
User->>App: Enable sunrise alert
App->>API: POST /registerDeviceAndProfile
App->>API: POST /replaceSubscriptions
API->>DB: Save subscriptions (bump version if changed)
API->>DB: Reconcile and compute next run
API->>DB: Upsert notificationJob
API->>Queue: Enqueue jobId
① 사용자가 알림 설정에서 일출 알림을 켠다.
② 앱이 registerDeviceAndProfile API로 사용자 선택
위치(위도/경도)와 시간대 데이터를 서버에 동기화한다.
③ 앱이 replaceSubscriptions API로 최신 구독 설정을 서버 기준
상태로 교체한다.
await notificationApi.registerDeviceAndProfile({
userId, deviceId, timezone,
latitude, longitude, locale, fcmToken,
});
await notificationApi.replaceSubscriptions({
userId,
subscriptions: nextSubscriptions,
});
④ 서버가 구독 상태를 저장하고, 변경이 있는 경우
notificationVersion을 증가시킨다.
⑤ 서버가 reconcileSubscription()을 실행해
computeNextOccurrence()로 다음 알림 시각을 계산한다.
⑥ 서버가 upsertNotificationJob()으로 계산 결과를
notificationJobs에 scheduled 상태로 저장한다.
⑦ 서버가 enqueueNotificationTask()로 Cloud Tasks 큐에
jobId를 넣어 실행 트리거를 만든다.
핵심은 “최신 설정 확정 → 버전 증가 → 다음 1회 예약” 순서를 지키는 것이다. 여기서 증가한 version은 이후 예약 발송 플로우에서 stale 작업을 차단하는 검증 키로 사용된다.
위치/날짜 기준으로 다음 알림 시각을 계산하는 핵심 코드:
const times = SunCalc.getTimes(now, lat, lng);
let target = times[topic];
if (!target || target.getTime() <= now.getTime()) {
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
target = SunCalc.getTimes(tomorrow, lat, lng)[topic];
}
계산된 시각을 예약 상태와 큐 실행으로 연결하는 코드:
const scheduledFor = computeNextOccurrence(input);
await upsertNotificationJob({
userId, subscriptionId, scheduledFor,
version: notificationVersion, status: "scheduled",
});
await enqueueNotificationTask({ userId, subscriptionId, scheduledFor });
2. 예약 발송 플로우
%%{init: {'theme':'dark','themeVariables': {'primaryColor':'#1f2937','primaryTextColor':'#e5e7eb','primaryBorderColor':'#9ca3af','lineColor':'#e5e7eb','actorBkg':'#1f2937','actorTextColor':'#e5e7eb','actorBorder':'#9ca3af','signalColor':'#e5e7eb','signalTextColor':'#e5e7eb','labelBoxBkgColor':'#1f2937','labelTextColor':'#e5e7eb','labelBoxBorderColor':'#9ca3af','sequenceNumberColor':'#111827'}}}%%
sequenceDiagram
autonumber
participant Queue as Cloud Tasks
participant API as Firebase Functions
participant DB as Firestore
participant FCM as FCM
Queue->>API: execute sendNotificationTask(jobId)
API->>DB: Validate job/user/version
API->>FCM: send multicast push
API->>DB: Mark job as sent
API->>API: reconcile next occurrence
① Cloud Tasks가 sendNotificationTask(jobId)를 호출한다.
② sendNotificationTask 내부에서 job 상태, 사용자 정보, version을
검증해 stale 작업을 먼저 차단한다.
③ 검증을 통과한 경우에만 sendMulticast()로 유효 토큰에 푸시를
발송한다.
④ 발송 결과를 반영해 job 상태를 sent 또는 실패 상태로 저장한다.
⑤ 처리 완료 후 다음 회차 예약을 위해 reconcileSubscription()을
다시 실행한다.
발송 단계에서 더 중요한 것은 “전송 전 검증”이다. job 상태, 사용자 유효성, version을 통과한 경우에만 FCM을 호출하고, 처리 직후 다음 회차를 다시 예약한다. 이 패턴이 누락과 중복 발송을 동시에 줄여준다.
if (!job || job.status !== "scheduled") return;
const user = await getUser(job.userId);
if (!user || job.version !== user.notificationVersion) {
await markJobCancelled(jobId, "stale-version");
return;
}
await sendMulticast(tokens, payload);
3. 사용자 수신/오픈 플로우
%%{init: {'theme':'dark','themeVariables': {'primaryColor':'#1f2937','primaryTextColor':'#e5e7eb','primaryBorderColor':'#9ca3af','lineColor':'#e5e7eb','actorBkg':'#1f2937','actorTextColor':'#e5e7eb','actorBorder':'#9ca3af','signalColor':'#e5e7eb','signalTextColor':'#e5e7eb','labelBoxBkgColor':'#1f2937','labelTextColor':'#e5e7eb','labelBoxBorderColor':'#9ca3af','sequenceNumberColor':'#111827'}}}%%
sequenceDiagram
autonumber
participant FCM as FCM
participant OS as iOS/Android OS
participant User as User
participant App as Expo App
FCM-->>OS: Deliver push
OS-->>User: Show notification banner
User->>OS: Tap notification (optional)
OS->>App: Open app with payload
① FCM이 iOS/Android OS에 푸시를 전달한다.
② OS가 배너/알림센터에 알림을 표시한다.
③ 사용자가 알림을 탭하면 OS가 앱 오픈 이벤트를 전달한다.
④ 앱이 addNotificationResponseReceivedListener에서 payload를 읽고
router.push로 해당 화면으로 이동한다.
사용자 경험은 마지막 구간에서 결정된다. 탭 후 payload를 읽어 정확한 화면으로 이동해야 흐름이 끊기지 않는다. 실제로는 “수신 성공”보다 “오픈 후 라우팅 정합성”이 리텐션에 더 큰 영향을 줬다.
Notifications.addNotificationResponseReceivedListener((response) => {
const payload = response.notification.request.content.data;
if (payload?.type === "sunrise" || payload?.type === "sunset") {
router.push("/notifications");
}
});
운영에서 마지막으로 챙길 것
실서비스에서는 큐 지연과 일시 장애가 반드시 생긴다. 그래서 주기 스위퍼로 “활성 구독인데 예약 작업이 없는 상태”를 다시 채우는 복구 루틴이 필요하다. 안정성은 단일 발송 성공률보다, 실패 후 자동 복구 능력에서 결정된다.
for (const sub of activeSubscriptions) {
const hasJob = await hasScheduledJob(sub.id);
if (!hasJob) await reconcileSubscription(sub);
}
마무리
이번 작업은 내가 처음으로 큐 기반 알림 파이프라인을 직접 구현해본 경험이었다. Cloud Tasks를 제외한 다른 구성 요소들은 이미 써봤고 크론잡도 만들어봤지만, 여러 서비스가 작업을 주고받는 큐 시스템은 처음엔 꽤 헷갈렸다.
그래도 다이어그램으로 플로우를 정리해보니 전체 구조가 한눈에 들어와서 큰 도움이 됐다. 앞으로도 서비스에 필요한 파이프라인은 직접 구현하면서 운영 관점까지 함께 다듬어볼 예정이다.


