이 글은 Velog에서 이전한 글입니다. Velog 원문 보기
푸시 알림 서버까지 만들고도 로컬 알림을 선택한 이유
Landit에는 사용자가 학습을 이어가도록 매일 20시에 리마인더를 보내는 기능이 필요했다.
이 알림 하나를 위해 EventBridge Scheduler, SQS, Push Device, 발송 이력, Expo Ticket과 Receipt까지 백엔드 구조를 설계하고 구현했다. 처음에는 서버가 최신 학습 상태를 확인한 뒤 Expo Push Service로 알림을 보내는 방식이 자연스럽다고 생각했다.
그런데 구조를 완성해 갈수록 질문이 달라졌다.
서버 푸시를 안정적으로 만들 수 있는가가 아니라, 현재 제품이 이 복잡성을 정말 필요로 하는가?
결론부터 말하면 첫 학습 리마인더의 구현 방향을 앱의 로컬 알림으로 바꿨다.
서버 푸시 구현이 실패해서 로컬 알림으로 돌아간 것은 아니다. 서버 푸시에 필요한 책임을 코드 수준까지 구체화한 뒤, 현재 요구사항과 운영 제약에는 더 단순한 구조가 맞다고 판단했다.
매일 20시 알림 하나에서 시작했다
처음 확정된 요구사항은 단순했다.
- 사용자가 학습을 이어가도록 알림을 보낸다.
- 발송 시각은 매일 오후 8시다.
- 알림을 누르면 다음 학습 화면으로 이동한다.
- 사용자는 알림을 켜거나 끌 수 있다.
고정된 시간에 같은 문구를 보내는 것만 생각하면 앱이 직접 알림을 예약해도 된다. Expo Notifications는 앱에서 특정 시각의 알림을 예약하고, 예약 식별자를 이용해 기존 알림을 취소하는 기능을 제공한다.
하지만 알림 내용을 구체화하면서 서버가 필요해 보이는 이유도 늘어났다.
서버 푸시를 설계할 때 사용자의 상태에 따라 알림을 세 가지로 나누기로 했기 때문이다.
| 알림 유형 | 선택 조건 | 이동 화면 |
|---|---|---|
CONTINUE_SCENARIO |
다음으로 진행할 시나리오가 있음 | /conversation/{scenarioId} |
CONTINUE_EXPRESSION |
완료한 시나리오에 아직 학습하지 않은 표현이 있음 | /expressions/{expressionId} |
REVIEW_LEARNING |
현재 활성 콘텐츠를 모두 완료함 | /home |
최근에 시나리오를 완료했는지, 표현 학습을 완료했는지에 따라 우선순위도 달라졌다. 최근 활동에 이어갈 대상이 없으면 다른 학습 유형으로 대체하고, 새로운 콘텐츠가 추가되면 다음 계산에서는 다시 이어 하기 알림으로 전환돼야 했다.
이 조건만 놓고 보면 최신 학습 데이터가 있는 서버에서 대상을 계산하는 편이 정확하다.
그래서 처음에는 서버 푸시를 기준으로 설계를 시작했다.
다만 이 세 유형은 서버 푸시에서 검토한 정책이다. 로컬 알림으로 방향을 바꾸면서 첫 배포 범위는 앱이 직접 아는 학습 이벤트를 기준으로 리마인더를 예약하는 형태로 줄였다.
Expo API를 호출하는 일보다 그 앞뒤가 더 복잡했다
서버 푸시의 마지막 단계만 보면 단순하다.
Expo Push Token
+ 제목
+ 본문
+ 이동 경로
→ Expo Push API
하지만 실제 시스템에는 이 요청 앞뒤로 더 많은 책임이 필요했다.
flowchart LR
APP["Landit 앱"] -->|"설치 상태와 Token 동기화"| API["기존 API 서버"]
EB["EventBridge Scheduler<br/>매일 20시"] --> SQS["Push SQS"]
SQS --> CONSUMER["PushNotificationConsumer"]
CONSUMER -->|"SCHEDULED_NOTIFICATION_BATCH"| TARGET["사용자별 알림 대상 계산"]
CONSUMER -->|"PUSH_RECEIPT_CHECK"| RECEIPT["Receipt 확인"]
TARGET --> DELIVERY["발송 이력 선점"]
DELIVERY --> EXPO["Expo Push Service"]
EXPO --> PROVIDER["FCM / APNs"]
EXPO --> TICKET["Ticket 결과 저장"]
TICKET -->|"15분 지연 메시지"| SQS
RECEIPT --> EXPO
SQS -->|"반복 실패"| DLQ["Push DLQ"]
서버는 다음 질문에 모두 답해야 했다.
- Token은 사용자와 기기 중 어디에 귀속되는가?
- 한 사용자가 여러 기기를 사용하면 어디까지 발송해야 하는가?
- 같은 기기에서 다른 계정으로 로그인하면 Token의 소유자는 누구인가?
- 20시에 어떤 사용자를 조회해야 하는가?
- 사용자마다 다른 학습 상태를 어떻게 N+1 조회 없이 계산할 것인가?
- SQS가 같은 메시지를 다시 전달하면 중복 알림을 어떻게 막을 것인가?
- Expo가 요청을 접수했다는 것과 FCM 또는 APNs가 받았다는 것을 어떻게 구분할 것인가?
- 일시 오류와 영구 오류를 어떤 기준으로 나눌 것인가?
- 삭제된 앱의 Token을 어떻게 발송 대상에서 제외할 것인가?
문제의 핵심은 Push API 호출이 아니라 발송 전후의 상태를 누가 소유할 것인가였다.
Token은 사용자 ID가 아니라 앱 설치를 기준으로 관리했다
처음에는 사용자 ID에 Expo Push Token을 저장하면 충분해 보였다.
하지만 Token은 사용자 계정 자체가 아니라 알림을 받을 앱 설치와 연결된다.
한 사용자가 iPhone과 Android 기기를 함께 사용할 수 있다. 반대로 하나의 기기에서 로그아웃한 뒤 다른 사용자가 로그인할 수도 있다. 이때 사용자 ID만 기준으로 Token을 저장하면 다중 기기와 계정 전환을 안전하게 표현하기 어렵다.
그래서 앱 설치마다 installationId를 만들고 서버에는 다음 상태를 저장하도록 설계했다.
installationId
+ 현재 사용자
+ 플랫폼
+ Landit 알림 ON/OFF
+ Expo Push Token
+ Token 상태
클라이언트 API도 설치 상태를 멱등하게 동기화하는 하나의 API로 제한했다.
PUT /api/v1/me/push-devices/{installationId}
동일한 installationId로 다시 요청하면 행을 추가하지 않고 현재 상태를 갱신한다. 같은 Expo Push Token이 다른 설치에 연결돼 있으면 이전 연결을 해제하고 현재 설치로 옮긴다.
발송 가능한 설치는 다음 조건을 모두 만족해야 한다.
pushEnabled == true
&& expoPushToken != null
&& tokenStatus == ACTIVE
이 구조는 다중 기기와 계정 전환을 안전하게 다룰 수 있었다.
다만 로컬 알림을 선택하면 이 중앙 Token 소유권 관리 자체가 첫 번째 알림 범위에서는 필요하지 않다. 서버 푸시가 제공하는 정교함이 곧 서버가 계속 소유해야 할 상태의 양이기도 했다.
별도 Worker는 만들지 않았지만 서버의 책임은 여전히 컸다
예약 알림을 서버에서 처리한다면 Spring의 @Scheduled보다 EventBridge Scheduler를 사용하는 편이 안전하다고 판단했다.
API 서버가 여러 대라면 각 인스턴스의 Spring Scheduler가 같은 작업을 실행할 수 있다. 배포나 재시작 시점도 예약 실행에 영향을 줄 수 있다. EventBridge Scheduler가 매일 20시에 SQS 메시지 한 건을 발행하면 예약 실행 시점과 애플리케이션 배포를 분리할 수 있다.
처음 검토한 전체 구조는 다음과 같았다.
EventBridge Scheduler
→ SCHEDULED_NOTIFICATION_BATCH
→ Push SQS
→ 별도 Push Worker
→ 사용자별 대상 계산
→ Expo Push Service
하지만 현재 규모에서 별도 ECS Service나 Fargate Task를 추가하는 것은 과하다고 판단했다.
Worker를 분리하면 CPU와 메모리를 API 서버와 격리할 수 있고 배포 책임도 명확해진다. 반면 새로운 실행 환경, 배포 파이프라인, 모니터링 대상과 고정 비용이 생긴다.
그래서 중간안으로 기존 API 서버 안에 SQS Consumer를 두었다.
EventBridge Scheduler
→ Push SQS
→ 기존 API 서버의 PushNotificationConsumer
→ 대상 계산과 Expo 발송
Consumer 동시성은 2로 작게 시작했다. 별도 서버 비용은 만들지 않으면서 SQS의 재시도와 DLQ는 사용할 수 있었다.
이 선택은 서버 푸시를 유지한다는 전제에서는 합리적이었다.
하지만 Worker를 없애도 다음 책임은 사라지지 않았다.
- Queue 소비와 Visibility Timeout 관리.
- API 서버와 배치 작업의 CPU·메모리 공유.
- 발송 대상 계산을 위한 DB 조회.
- 중복 발송 방지.
- Expo와 FCM/APNs 오류 처리.
- Ticket과 Receipt 상태 저장.
- 운영 Queue와 DLQ 모니터링.
별도 Worker 비용은 줄였지만 서버 푸시라는 선택의 운영 비용까지 사라진 것은 아니었다.
500명씩 계산하고 100건씩 발송했다
모든 활성 사용자를 한 번에 메모리에 올리거나 사용자마다 Repository를 조회하면 사용자 수가 늘수록 처리 시간과 쿼리 수가 함께 증가한다.
그래서 대상 계산은 userProfileId Keyset Pagination으로 500명씩 처리하도록 구성했다.
long lastUserProfileId = 0L;
while (true) {
NotificationTargetPage page = loadPage(lastUserProfileId, 500);
if (page.userProfileIds().isEmpty()) {
return;
}
List<SendPushNotificationCommand> commands = processPage(page);
notificationDispatchService.sendAll(commands);
lastUserProfileId = page.userProfileIds().getLast();
}
이 코드는 실제 구조를 설명하기 위해 세부 로직을 줄인 형태다.
대상 계산 단계에서는 각 페이지의 시나리오, 표현, 최근 완료 시각, 발송 가능한 Push Device를 일괄 조회한다. 사용자마다 같은 데이터를 다시 조회하지 않고 메모리에서 알림 유형 하나를 선택한다. 전체 사용자를 하나의 긴 트랜잭션으로 묶지 않고 페이지마다 상태를 저장한다.
계산 결과는 user_notification_state에 스냅샷으로 남긴다. 이 테이블은 학습 사실의 원본이 아니다. 시나리오와 표현의 실제 완료 이력에서 그날의 알림 유형과 대상을 다시 계산하므로, 새 콘텐츠가 추가되면 다음 배치에서 이어 하기 알림으로 전환된다.
Expo 발송 단위는 별개였다.
Expo Push API는 한 요청에 최대 100개의 메시지 객체를 받을 수 있다. 따라서 500명은 DB 조회와 정책 계산 단위이고, 100건은 Expo HTTP 요청 단위다.
500명
= DB 조회와 대상 계산 단위
100건
= Expo 요청 분할 단위
한때 실제 발송 대상마다 PUSH_SEND 메시지를 다시 SQS에 넣는 구조도 검토했다.
사용자가 5만 명이면 기기 수에 따라 5만 개가 넘는 메시지가 새로 생길 수 있다. 개별 실패를 격리하기는 쉬워지지만 현재 규모에서는 메시지 유형, 재처리 상태와 DLQ 관리만 더 복잡해졌다.
결국 500명 단위는 Queue Fan-out 단위가 아니라 서버 내부의 조회 단위로 남겼다. 선택된 기기는 페이지 안에서 모아 Expo에 최대 100건씩 직접 보냈다.
SQS가 같은 메시지를 다시 줘도 알림은 한 번만 보내야 했다
SQS Standard Queue는 최소 한 번 전달 방식을 사용한다. Visibility Timeout은 같은 메시지를 동시에 처리할 가능성을 줄여 주지만, 중복 전달 자체를 완전히 없애지는 않는다.
따라서 서버는 SQS가 메시지를 정확히 한 번 전달한다고 가정할 수 없었다.
중복 방지는 Expo 호출 전에 push_delivery 발송 이력을 선점하는 방식으로 구현했다.
날짜
+ 사용자
+ 알림 유형
+ Push Device
→ 중복 방지 키 생성
개념적인 키는 다음과 같다.
push:scheduled:{date}:{userProfileId}:{notificationType}:{pushDeviceId}
같은 예약 메시지가 다시 전달되면 동일한 키의 발송 이력이 이미 존재한다. 이때 Expo를 다시 호출하지 않는다. 일시 오류로 재시도가 허용된 이력만 기존 행을 다시 선점한다.
발송 이력은 단순한 로그가 아니었다.
외부 호출 전에 이번 발송을 어느 처리 흐름이 맡았는지 확정하는 멱등성 경계였다.
다만 현재 키가 보장하는 범위는 같은 날짜·사용자·알림 유형·기기에 대한 중복 방지다. 첫 처리와 중복 처리 사이에 사용자가 학습해 선정 유형이 바뀌면 다른 키가 만들어질 수 있다. 사용자·기기별 하루 한 건을 엄격하게 보장하려면 알림 유형을 제외한 일일 키를 사용하거나, 첫 계산 결과를 그날의 확정 대상으로 고정해야 한다.
배치 처리 시간이 길어질 가능성도 고려해야 했다. Consumer는 처리 시작과 페이지 전환 시 현재 SQS 메시지의 Visibility Timeout을 300초로 연장한다. Handler가 정상 종료된 경우에만 메시지를 삭제하도록 승인 정책도 ON_SUCCESS로 설정했다.
이 방식도 한 페이지의 조회와 발송이 300초 안에 끝난다는 전제가 필요하다. 실제 처리 시간이 이를 넘는다면 페이지 처리 중에도 주기적으로 연장하는 heartbeat를 두거나 작업 단위를 더 작게 나눠야 한다.
현재 구현은 같은 발송 이벤트의 중복 Expo 호출을 막는다. 사용자당 하루 한 건이라는 제품 정책까지 보장하려면 중복 키의 범위를 별도로 확정해야 한다.
Expo Ticket은 기기 전달 완료가 아니었다
Expo Push Service에 HTTP 요청이 성공했다고 해서 사용자 기기에 알림이 표시된 것은 아니다.
Expo는 요청을 접수하면 Push Ticket을 반환한다. Ticket은 Expo가 Payload를 받았다는 의미다. Expo가 이후 FCM 또는 APNs에 전달을 시도한 결과는 Push Receipt로 확인해야 한다.
stateDiagram-v2
[*] --> REQUESTED
REQUESTED --> TICKET_ACCEPTED: "Expo가 Ticket 반환"
REQUESTED --> FAILED: "영구 오류"
REQUESTED --> REQUESTED: "429, 5xx, timeout 재시도"
TICKET_ACCEPTED --> DELIVERED: "FCM 또는 APNs가 수신"
TICKET_ACCEPTED --> FAILED: "Receipt 실패"
여기서 DELIVERED는 사용자 기기에 알림이 표시됐다는 뜻이 아니다. Expo Receipt로 FCM 또는 APNs가 알림을 받았음을 확인한 상태다.
DeviceNotRegistered가 반환되면 두 상태가 함께 바뀐다.
flowchart LR
ERROR["Ticket 또는 Receipt<br/>DeviceNotRegistered"] --> DELIVERY_FAILED["PushDelivery<br/>FAILED"]
ERROR --> DEVICE_INVALID["현재 Token 소유 PushDevice<br/>INVALID"]
Expo는 발송 약 15분 뒤 Receipt를 확인할 것을 권장한다. 그래서 Ticket을 저장한 뒤 PUSH_RECEIPT_CHECK 메시지를 SQS에 900초 지연 발행하도록 구성했다.
오류라고 해서 모두 자동 재발송하지도 않았다.
| 오류 | 처리 |
|---|---|
| HTTP 429, 5xx, timeout | 같은 발송 이력으로 재시도 |
| 잘못된 Payload, 영구적인 요청 오류 | FAILED로 종료 |
| 일반 I/O, interruption, 응답 파싱 실패 | Expo 수신 여부가 불확실해 자동 재발송하지 않음 |
DeviceNotRegistered |
현재 Token 소유 설치를 INVALID 처리 |
네트워크 오류가 발생했다고 바로 재발송하면 Expo는 이미 첫 번째 요청을 받았는데 응답만 유실된 상황에서 중복 알림을 만들 수 있다.
재시도는 실패를 복구하는 기능이지만, 외부 시스템이 요청을 받았는지 모르는 상황에서는 새로운 중복 원인이 될 수도 있었다.
기술적으로 올바른 구조와 지금 필요한 구조는 달랐다
여기까지 구현하면서 서버 푸시에 필요한 책임은 분명해졌다.
- 앱 설치와 Token 소유권 관리.
- 사용자별 최신 학습 상태 계산.
- 예약 실행과 Queue 소비.
- 페이지 단위 조회와 트랜잭션.
- 발송 멱등성.
- Expo 요청 분할.
- Ticket과 Receipt 추적.
- 오류 분류와 Token 무효화.
- Visibility Timeout과 DLQ 운영.
각각은 불필요한 기능이 아니었다. 서버가 알림 발송의 원본이라면 필요한 책임이었다.
하지만 현재 제품 요구사항을 다시 보면 다른 결론이 나왔다.
첫 번째 알림의 목적은 복잡한 마케팅 캠페인이나 운영 공지가 아니었다. 사용자가 다시 학습하도록 정해진 시각에 리마인더를 보여주는 것이었다.
현재 범위에서는 다음 기능이 필요하지 않았다.
- 운영자가 특정 사용자 집단에 임의 알림을 발송하는 기능.
- 국가나 사용자별 시간대를 서버에서 관리하는 기능.
- 휴면 기간에 따라 여러 캠페인을 순차 발송하는 기능.
- 앱을 열지 않은 상태에서 서버 데이터만으로 알림 정책을 바꾸는 기능.
- 모든 기기의 발송 상태를 중앙에서 추적하는 기능.
- Receipt 기반 전달 분석.
반대로 앱은 사용자가 학습한 시점과 다음 알림을 갱신해야 하는 시점을 이미 알고 있었다.
이 조건에서는 서버가 매일 전체 사용자의 상태를 다시 계산하는 것보다, 앱이 학습 이벤트를 기준으로 다음 알림을 예약하는 편이 단순했다.
| 판단 기준 | 서버 푸시 | 로컬 알림 |
|---|---|---|
| 최신 서버 상태 기반 대상 계산 | 유리함 | 앱이 알고 있는 상태로 제한됨 |
| 운영자 임의 발송 | 가능함 | 어려움 |
| 다중 기기 중앙 통제 | 가능함 | 기기별 독립 동작 |
| Ticket·Receipt 추적 | 가능함 | 중앙 추적 없음 |
| Queue·DB·외부 푸시 서비스 운영 | 필요함 | 필요하지 않음 |
| 오프라인 예약 | 서버와 네트워크 필요 | OS가 예약 실행 |
| 현재 고정 리마인더 범위 | 구현 가능하지만 책임이 큼 | 더 단순하게 충족 가능 |
서버 푸시가 더 많은 기능을 제공한다는 사실은 분명했다.
문제는 그 기능들이 지금 필요하지 않았다는 점이었다.
첫 배포는 로컬 알림으로 방향을 바꿨다
최종적으로 첫 학습 리마인더는 앱에서 예약하는 로컬 알림으로 정리했다. 서버의 세 가지 알림 정책을 그대로 옮기는 대신, 앱이 직접 확인한 학습 이벤트를 기준으로 다음 리마인더를 갱신하는 범위부터 시작했다.
개념적인 흐름은 다음과 같다.
flowchart LR
COMPLETE["사용자 학습 완료"] --> STATE["기기 내부 알림 상태 갱신"]
STATE --> CANCEL["이전 예약 알림 취소"]
CANCEL --> SCHEDULE["다음 알림 예약"]
SCHEDULE --> OS["iOS / Android"]
OS -->|"예약 시각 도달"| USER["사용자에게 알림 표시"]
앱은 사용자가 학습할 때마다 기존 예약을 갱신한다. OS가 예약 실행을 담당하므로 알림 발송을 위해 서버가 실행되고 있을 필요가 없다.
Expo Notifications는 scheduleNotificationAsync가 반환한 식별자를 저장하고, cancelScheduledNotificationAsync로 특정 예약을 취소할 수 있다.
학습할 때까지 매일 알림을 보내는 정책이라면 20시 반복 예약 하나를 유지하면 된다. 사용자가 학습을 완료했을 때 기존 예약을 취소하고 다음 문구와 이동 경로로 다시 예약한다.
구현에 필요한 핵심 책임도 다음 정도로 줄어든다.
이전 예약 식별자 조회
→ 이전 예약이 있으면 취소
→ 다음 학습 리마인더 예약
→ 새 예약 식별자 저장
FE 구현에서는 사용자별 저장 키, OS 알림 권한, Android Notification Channel, 앱 재설치와 로그아웃 시 상태 초기화도 함께 고려해야 한다.
로컬 알림도 지정한 시각의 표시를 무조건 보장하는 기능은 아니다. Expo 문서 기준으로 Android 12 이상에서 정확한 시각의 알림을 예약하려면 SCHEDULE_EXACT_ALARM 권한이 필요하고, 실제 표시 여부는 OS 권한과 알림 Handler 설정에도 영향을 받는다.
따라서 제품 요구사항도 “반드시 20시 정각에 실행되는 작업”과 “20시 무렵 학습을 상기시키는 리마인더”를 구분해야 한다. 첫 번째라면 로컬 알림의 플랫폼 제약을 더 깊게 검토해야 하고, 두 번째라면 현재 구조로 충분할 수 있다.
기기 간 동기화도 자동으로 해결되지 않는다. 사용자가 기기 A에서 학습을 끝내도 기기 B에 저장된 예약은 B가 앱을 열어 상태를 동기화하기 전까지 그대로 남을 수 있다. 서버에 새 콘텐츠가 추가돼도 앱이 이를 확인하기 전에는 예약된 문구와 이동 경로를 바꿀 수 없다.
첫 배포가 이 시차를 허용할 수 있다면 로컬 알림의 단순성이 더 크다. 반대로 모든 기기의 알림을 즉시 맞춰야 한다면 서버 푸시가 다시 필요하다.
로컬 알림으로 방향을 바꾸면서 다음 항목은 첫 배포의 운영 대상에서 빠졌다.
EventBridge Scheduler
Push SQS와 DLQ
SQS Consumer
Push Device 동기화 API
Expo Push Token 소유권
push_delivery
Ticket과 Receipt 확인
외부 푸시 서비스 오류 재시도
단순히 코드 몇 줄이 줄어든 것이 아니다.
운영 중 확인해야 할 Queue, DB 상태, Expo와 FCM/APNs의 장애 경계도 첫 배포에서는 관리할 필요가 없어졌다.
로컬 알림이 항상 더 좋은 선택은 아니다
이번 결론을 “작은 서비스는 로컬 알림을 쓰면 된다”로 일반화할 수는 없다.
다음 요구사항이 생기면 서버 푸시가 다시 적절해진다.
- 앱을 오래 열지 않은 사용자를 서버 활동 기준으로 선별해야 함.
- 마지막 학습 후 1일, 3일, 7일처럼 휴면 기간별 정책이 달라짐.
- 새로운 콘텐츠나 운영 공지를 즉시 전달해야 함.
- A/B 테스트에 따라 문구와 발송 시간을 바꿔야 함.
- 사용자별 시간대와 국가 정책을 서버에서 통제해야 함.
- 여러 기기에 일관된 설정을 적용해야 함.
- 발송 이력과 FCM/APNs 전달 결과를 분석해야 함.
- 관리자 임의 발송과 긴급 알림이 필요함.
반대로 알림이 기기 안의 행동만으로 예약될 수 있고, 정책이 단순하며, 중앙 발송 이력이 필요하지 않다면 로컬 알림은 유효한 선택이다.
판단 기준은 서버 푸시와 로컬 알림 중 어느 기술이 더 우수한지가 아니었다.
알림 정책의 원본을 서버가 가져야 하는가, 아니면 기기가 이미 필요한 상태와 시점을 알고 있는가?
서버 푸시 구현은 낭비였을까?
결과만 보면 첫 배포의 구현 방향은 로컬 알림으로 바뀌었고, 서버 푸시 코드는 매일 20시 예약 발송에 사용하지 않았다.
그렇다고 서버 설계가 의미 없었던 것은 아니다.
구현을 통해 서버 푸시로 전환할 때 필요한 경계를 구체적으로 확인할 수 있었다.
- 사용자 ID와 설치 ID를 분리해야 하는 이유.
- SQS 중복 전달을 DB 멱등성으로 흡수하는 방법.
- DB 계산 단위와 Expo 요청 단위가 다른 이유.
- Ticket 접수와 FCM/APNs 전달 결과의 차이.
- 자동 재시도가 오히려 중복 알림을 만들 수 있는 조건.
- 별도 Worker가 다시 필요해지는 규모와 운영 신호.
무엇보다 “안정적으로 만들 수 있다”와 “지금 만들어야 한다”가 다른 판단이라는 점을 확인했다.
서버 푸시는 구현과 자동 테스트까지 진행했지만 당시 Scheduler는 비활성 상태였다. dev 실기기 E2E와 운영 발송 결과를 성과처럼 주장하지 않는 이유도 여기에 있다. 첫 배포에서 검증할 기준은 로컬 알림이다.
마치며
사용자별 학습 상태를 정확히 계산하려면 서버 푸시가 필요하다고 생각했다. 하지만 정확한 발송을 위해 필요한 상태와 실패 처리를 모두 펼쳐 놓고 보니, 첫 리마인더의 요구사항보다 시스템이 먼저 커지고 있었다.
중앙 통제, 사용자 세분화, 발송 추적이 필요해지면 서버 푸시는 다시 유효하다. 지금은 앱이 학습 이벤트와 다음 예약 시점을 이미 알고 있었고, 그 정보만으로 제품 요구사항을 충족할 수 있었다.
가장 어려웠던 부분은 Expo Push API 호출이 아니었다. 현재 제품에 필요한 신뢰성의 경계를 정하고, 이미 만든 구조보다 단순한 선택을 받아들이는 일이었다.
알림 아키텍처의 출발점은 어떤 기술을 쓸지가 아니라, 알림 정책의 원본을 누가 가지고 있는지였다.
참고 자료
'Project > Landit' 카테고리의 다른 글
| Sentry 500은 서버 장애가 아니었다. WAF Count 뒤에 있던 취약점 스캐너 (0) | 2026.08.05 |
|---|