푸시 outbox와 suppression audit
보낸 알림과 보내지 않은 알림의 이유를 같은 기록으로 남겼다
푸시는 앱 밖의 사용자에게 직접 닿는다. 앱을 열지 않은 사람에게 말을 거는 일이기 때문에, “보낼 수 있다”보다 “보내도 되는가”가 먼저다.
한강자리의 알림은 세 단계를 지난다.
후보를 만든다. 규칙에 따라 보낼지 멈출지 정한다. outbox에 넣고 보낸 뒤 결과를 audit에 남긴다.
실제로 알림을 다루다 보면 가장 자주 보는 질문은 “왜 이 알림은 갔고, 왜 저 알림은 안 갔는가?”다. 이 질문에 답하려면 성공한 발송만 저장해서는 부족하다. 보내지 않은 이유도 기록으로 남아야 한다.
원천 변화는 먼저 알림 후보가 된다
push는 원천 이벤트를 그대로 보내지 않는다. 먼저 알림으로 다룰 수 있는 사실(canonical fact)로 바꾼다. 예를 들어 주차 잔여 대수가 기준 아래로 내려갔거나, 공원 혼잡이 바뀌었거나, 행사가 취소됐다는 변화가 fact가 된다.
그 다음 fact와 subscription을 맞춰 본다. 사용자가 어떤 공원이나 주차장에 관심을 표시했는지, 어떤 종류의 알림을 켰는지, 조용한 시간인지 같은 조건을 본다.
flowchart LR Source["원천 변화"] --> Fact["알림용 fact"] Fact --> Match["후보 맞추기"] Subscription["구독/알림 설정"] --> Match Match --> Policy["보낼지 정하기"] Policy --> Decision["push_now 또는 억제"] Decision --> Outbox["푸시 outbox"]
먼저 보내지 않는 규칙을 둔다
억제는 실패가 아니다. 사용자를 보호하는 선택이다.
한강자리에는 명시적인 suppression rule이 있다.
| 예 | 이유 |
|---|---|
| 주차장 상태 변화 알림 전체 억제 | 사용자가 원하는 것은 잔여 대수 기준 알림에 가깝다 |
| 잔여 대수 없는 주차장 threshold 알림 억제 | 행동 가능한 숫자가 없다 |
| 공원 상태 변화 알림 억제 | 너무 넓고 사용자가 할 일이 불명확하다 |
| 혼잡 변화가 불명확하면 억제 | 메시지 본문을 안전하게 만들 수 없다 |
이런 rule은 “알림을 덜 보내기”가 아니라 “사용자가 행동할 수 있는 알림만 보내기”에 가깝다.
사용자 설정과 영향도를 함께 본다
멈춤 규칙을 통과한 후보도 바로 outbox로 가지 않는다. 사용자 알림 모드와 fact가 얼마나 중요한지를 함께 본다.
이때 대략 다음을 묻는다.
- 사용자가 알림을 껐는가.
- 이 변화는 긴급도가 높은가.
- parking-first 모드에서 주차 관련 중요 신호인가.
- outing-brief 모드에서 공원/행사 관련 중요 신호인가.
- 그 외에는 smart mode에서 보낼 만큼 중요한가.
결과는 push_now 또는 suppress이며, 반드시 reason code를 남긴다.
flowchart TD
Candidate["맞은 후보"] --> Off{"알림 꺼짐?"}
Off -->|예| Suppress["억제: policy_mode_off"]
Off -->|아니오| Rule{"멈춤 규칙 해당?"}
Rule -->|예| SuppressRule["이유와 함께 억제"]
Rule -->|아니오| Tier{"중요하거나 T0?"}
Tier -->|예| Push["push_now"]
Tier -->|아니오| Mode["모드별 결정"]
안 보낸 이유도 한 줄로 남긴다
보내지 않았다면 그 이유도 기록해야 한다.
알림에서 “왜 안 갔지?”는 자주 나오는 질문이다. 후보가 없었는지, 맞는 구독이 없었는지, 규칙이 멈췄는지, outbox에는 들어갔지만 전송에 실패했는지 구분해야 한다.
delivery decision 기록에는 subscription, fact, target, decision, reason code, policy mode, policy version을 남긴다. 같은 fact와 subscription 조합을 두 번 처리하지 않도록 제한도 둔다.
이 기록이 있어야 알림이 제대로 가고 있는지 조절할 수 있다.
실제로 알림을 조절할 때는 보낸 알림보다 멈춘 알림을 더 자주 봐야 한다. 너무 많이 멈추면 fact 자체가 애매하거나 사용자 설정과의 매칭이 빗나간 것일 수 있고, 너무 적게 멈추면 앱이 시끄러워질 수 있다. suppression은 “실패”가 아니라 일부러 고른 선택이다.
APNs 전송은 outbox에서 처리한다
outbox는 APNs 전송을 API 요청 안에서 바로 하지 않게 해 주는 완충지대다.
sequenceDiagram
autonumber
participant Worker as 푸시 워커
participant Outbox as outbox 테이블
participant Dispatcher as 푸시 디스패처
participant APNs as APNs
participant Repo as 전송 저장소
Worker->>Outbox: 알림을 발송 대기열에 넣기
Dispatcher->>Outbox: 보낼 차례가 된 알림 가져오기
Dispatcher->>APNs: 알림 서비스에 메시지 보내기
alt 성공
Dispatcher->>Repo: 전송 성공으로 기록
else 잘못된 기기 식별자
Dispatcher->>Repo: 최종 실패와 기기 정리 후보 기록
else 인증 오류
Dispatcher->>Repo: 남은 작업 유예
else 재시도 가능한 실패
Dispatcher->>Repo: 다음 시도 시각과 대기 시간 저장
end
outbox에서 보낼 때는 다음을 구분한다.
- 만료된 항목은 보내지 않는다.
- 번역 문구가 준비되지 않은 메시지는 최종 실패로 둔다.
- 무효화된 기기 식별자는 cleanup 대상이 된다.
- APNs 인증 오류가 나면 남은 항목을 무리하게 보내지 않고 유예한다.
- 재시도 가능한 실패는 attempt count와 backoff를 반영한다.
보낸 수보다 이유를 본다
push는 성공 수만 보면 부족하다. 보낸 수, 멈춘 수, 재시도 수, 무효화된 기기 식별자, 오래 기다린 outbox 항목, 우선순위별 backlog를 같이 봐야 한다.
특히 suppression metric은 알림이 너무 조심스러운지, 너무 시끄러운지를 보여준다. 너무 많이 억제된다면 fact가 애매하거나 구독 규칙이 빗나간 것일 수 있다. 반대로 억제 없이 너무 많이 보내면 사용자는 알림을 끄게 된다.
그래서 outbox backlog만 보는 대시보드는 부족하다. outbox에 들어가기 전 단계에서 얼마나 많이 걸러졌는지, 어떤 reason code가 늘고 있는지, 무효 토큰과 인증 오류가 어디서 갈라지는지를 함께 봐야 한다.
알림 품질은 보낸 수로 끝나지 않았다
push는 “보낼 수 있음”보다 “보내도 됨”을 먼저 물어야 했다. suppression은 사용자를 보호하는 선택이고, 보내지 않은 이유도 reason code와 함께 audit으로 남겨야 했다.
outbox는 API 요청과 APNs 전송 실패를 분리한다. 번역 누락, 무효화된 기기 식별자, 인증 오류, 재시도 가능한 실패는 서로 다른 문제라서 같은 실패 수로 묶으면 다음 조치를 고를 수 없다.
알림을 다루다 보면 마지막에 남는 질문은 “몇 개 보냈나”가 아니었다. 왜 보냈고, 왜 보내지 않았고, 그 선택이 사용자에게 도움이 됐는지를 설명할 수 있어야 했다. suppression과 audit은 그 설명을 가능하게 하는 기록이었다.