신뢰할 수 있는 시간대 인식 푸시 알림 시스템 구축
전 세계 수천 명의 사용자에게 식사 알림, 기분 확인, 수분 섭취 알림, 수면 신호, 맞춤 알림 등 개인화된 매일의 알림을 보내야 하는 건강 & 웰빙 앱이 있었습니다. 핵심은 모든 알림이 정확한 현지 시간에, 단 한 번만, 그리고 작동하지 않는 기기에는 절대 도달하지 않아야 한다는 것이었습니다. 우리는 이를 가능하게 하는 분산 파이프라인을 설계하고 구축했습니다.
당면 과제
- 대규모 시간대 정확성. "오전 9시 아침 식사 알림"은 사용자마다 다른 의미를 가집니다. 서버 시간으로 보내면 시드니의 누군가에게는 새벽 3시에 알림이 갈 것입니다. 각 알림은 사용자의 현지 시간에 맞춰야 했습니다.
- 스팸 및 중복 없음. cron 스케줄링은 필연적으로 겹치고 다시 실행됩니다. 엄격한 보장이 없으면 한 명의 사용자가 동일한 "점심 시간 🥗" 알림을 두세 번 받을 수 있으며, 이는 앱 삭제로 이어질 수 있습니다.
- 기기 신뢰성 부족. 사용자는 앱을 삭제하고, 권한을 철회하며, 푸시 토큰을 지속적으로 변경합니다. 오래된 토큰에 알림을 맹목적으로 보내는 것은 리소스를 낭비하고 전달 지표를 손상시킵니다.
- 무차별 스케줄러 없이 정확한 타이밍. 60초마다 데이터베이스를 계속 두드리는 cron 없이 수백 개의 알림을 정확한 시간에 전달하려면 단순한 폴링보다 더 스마트한 메커니즘이 필요했습니다.
우리의 솔루션
우리는 무엇을 보낼지, 언제 보낼지, 그리고 실제로 보내는 과정을 명확하게 분리하는 3단계 파이프라인을 구축하여 각 단계가 독립적으로 실패하고 복구할 수 있도록 했습니다. 데이터베이스는 진실의 원천이며, 메시지 큐는 정확한 타이밍을 처리하고, 단일 워커 계층은 푸시 프로바이더와 통신합니다.

아키텍처
- Expo-notifications는 iOS와 Android 모두에 단일 토큰 형식과 전달 API를 사용하는 네이티브 채널, 고유한 소리 및 딥 링크를 갖춘 React Native 클라이언트입니다.
- FCM 및 APN 위에 통합 푸시 추상화로 expo-server-sdk를 사용하는 NestJS 백엔드.
- 진실의 원천인 MongoDB — NotificationMessage, NotificationToken, 그리고 NotificationCounter 컬렉션.
- 정확한 스케줄링된 전달을 위한 카테고리별(식사, 기분, 활동, 안전, 알림) ActiveMQ (STOMP) 지연 큐.
- 사용자별 시간대가 해결된 알림 레코드를 생성하는 Creator crons.
- 각 큐를 구독하고 전송 전에 최종 유효성 검사를 수행하는 Consumer workers.
- crons 및 consumers를 실행하는 AWS ECS Fargate; 전용 EC2 인스턴스에서 실행되는 ActiveMQ.
주요 기능
- 시간대 인식 스케줄링. date-fns-tz를 사용하여 각 사용자의 현지 발송 시간을 계산하고, 저장용으로 UTC로 다시 변환하며, 하루에 한 번의 알림을 보장하기 위해 UTC 날짜 범위로 제한합니다.
- 데이터베이스에 의한 멱등성 보장. 보류 중인 메시지에 대한 부분 고유 인덱스는 cron이 두 번 실행될 때도 중복 생성을 불가능하게 합니다:
| // 메시지가 PENDING 상태이고 삭제되지 않은 경우에만 고유합니다 schema.index( { userId: 1, notificationTokenId: 1, category: 1, label: 1, scheduledAt: 1 }, { unique: true, partialFilterExpression: { status: 'pending', isDeleted: false } } ); |
3. 지연 큐를 통한 정확한 전달. 분 단위 cron 대신 스케줄러는 메시지를 지금 대기열에 넣지만, ActiveMQ의 scheduled-delay 헤더를 사용하여 정확한 예정 시간으로 전달을 연기합니다:
| client.send(`/queue/${queueName}`, { persistent: 'true', 'AMQ_SCHEDULED_DELAY': String(delayMs), // 예정된 시간에 정확히 전달됩니다 }, JSON.stringify(message)); |
4. 기기당 단일 활성 토큰. 부분 고유 인덱스는 기기당 하나의 활성 토큰을 정확히 보장합니다. 새로운 로그인은 이전 토큰을 깔끔하게 제거하며, 동시 로그인에 대비하여 지수 백오프 재시도를 사용합니다.
5. 영수증 확인 + 자동 정리. 전송 후 Expo 영수증을 폴링합니다. DeviceNotRegistered 응답은 즉시 비활성 토큰을 비활성화하여 다시는 해당 토큰에 전송을 낭비하지 않도록 합니다.
| f (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') { await this.deactivateToken(token); // 비활성 기기로의 전송 중지 } |
6. 실패 한도. 각 기기에는 재시도 카운터가 있습니다; 3회 연속 실패 후 토큰은 자동으로 폐기됩니다 — 무한 루프나 좀비 토큰이 없습니다.
7. 발송 시 사용자 의도 존중. 알림 환경설정은 스케줄링 시점뿐만 아니라 전달 시점에도 consumer에 의해 다시 확인됩니다 — 따라서 알림 1시간 전에 옵트아웃한 사용자는 절대 알림을 받지 않습니다. 메시지는 정직한 최종 상태로 결정됩니다: success, failed, 또는 is_missed.
결과
- 모든 사용자는 전 세계 어디에서나 정확한 현지 시간에 알림을 받습니다 — 근무 외 시간 알림은 없습니다.
- 데이터베이스 수준 멱등성을 통해 중복 알림이 완전히 제거되었습니다.
- 비활성 및 오래된 기기 토큰이 자동으로 감지되고 폐기되어 깔끔한 전달을 유지합니다.
- 무차별 스케줄러 없이도 정확하고 분 단위로 정밀한 전달로 신뢰성이 향상되고 데이터베이스 부하가 감소했습니다.
기술 스택
React Native · Expo Notifications · NestJS · TypeScript · MongoDB · ActiveMQ (STOMP) · expo-server-sdk · date-fns-tz · AWS ECS Fargate

