APNs 전송 전에 알림 후보를 거르는 방법

푸시 알림의 발송 전 필터. 다루는 범위: 푸시를 보내기 전에 남길 기록, 후보에서 발송까지 가는 길, 먼저 만든 미발송 규칙. 실제 구현과 운영 기록을 기준으로 설명한다.

푸시는 APNs 토큰을 받아 바로 보내면 끝나는 일이 아니다. 사용자 의도, 관심 장소, 알림 종류, quiet hours, 원천 데이터 최신성, 중복 방지, 발송 감사 기록이 모두 필요하다.

한강자리에서 알림은 많이 보내려고 만든 것이 아니다. 한강 나들이에서 실패할 가능성을 줄이기 위한 것이다. 그래서 푸시의 핵심은 “무엇을 보낼까”보다 “무엇을 보내지 않을까”에 있다.

푸시는 화면보다 더 조심스럽다. 앱 화면은 사용자가 열어서 보지만, 푸시는 사용자의 시간을 먼저 끊고 들어간다. 그래서 어려운 부분은 APNs 연동보다 보내도 되는지 가르는 규칙 쪽에 있었다. 같은 원천 데이터 변화라도 어떤 사용자에게는 행동할 정보이고, 어떤 사용자에게는 소음이다.

푸시를 보내기 전에 남길 기록

서버에서는 발송 전후에 남길 기록을 다음처럼 나눈다.

영역역할
Identity설치 단위 신호를 저장
SubscriptionAPNs device token과 구독 상태를 따라감
Preference공원, 주차장, 알림 종류별 사용자 의도
Notification fact원천 데이터에서 나온 알림 후보를 정규화한 사실
Fact claim같은 fact를 두 번 다루지 않도록 claim
Delivery decision보내거나 막은 이유를 기록
Outbox실제 알림 발송을 기다리는 durable queue
Delivery attempt전송 응답과 실패 이력을 기록
Redis index빠른 후보 조회를 위한 보조 index

Postgres에는 기준 기록과 감사 기록을 남긴다. Redis는 후보 조회와 스트림 속도를 돕는다.

후보에서 발송까지 가는 길

sequenceDiagram
  autonumber
  participant iOS as iOS 앱
  participant API as 푸시 API
  participant PG as Postgres
  participant Redis as 선호 인덱스
  participant Facts as 알림 fact
  participant Worker as 푸시 워커
  participant APNs as APNs

  iOS->>API: 구독과 알림 설정 등록
  API->>PG: 설치/구독/설정 저장
  API->>Redis: 빠른 후보 조회용 인덱스 갱신
  Facts->>PG: 정규화한 알림 후보 저장
  Worker->>PG: 처리할 후보 선점
  Worker->>Redis: 대상 구독 빠르게 찾기
  Worker->>PG: 발송 규칙 평가와 결정 기록
  Worker->>PG: 바로 보낼 항목을 outbox에 추가
  Worker->>APNs: APNs에 전송
  APNs-->>Worker: 전송 결과 반환
  Worker->>PG: 시도 결과와 후속 조치 기록

여기서 중요한 기록은 delivery decision이다. 알림 후보가 생겼다고 모두 outbox로 가는 것은 아니다. 보내도 되는 조건을 통과한 후보만 발송 대기열로 가고, 나머지는 suppression reason과 함께 남긴다.

사용자가 어떤 알림을 원했는지 먼저 봐야 한다. 전송 queue와 audit은 그 선이 정해진 뒤에 의미가 생긴다. 사용자가 원하지 않는 알림을 가려내는 일이 먼저이고, APNs 전송은 그 다음 단계다.

flowchart TB
  Fact["알림 fact"] --> Context["fact 점유와 후보 조회<br/>Redis 우선 · Postgres 대체"]
  Context --> Decision{"선호 설정 · 조용한 시간<br/>쿨다운 · 최신성 통과?"}
  Decision -->|억제| Suppressed["억제 결정"]
  Decision -->|push_now| Delivery["푸시 outbox<br/>APNs 전송 · 시도 기록"]

먼저 만든 미발송 규칙

모든 변화가 알림이 되면 앱은 금방 피로해진다. 예를 들어 단순한 혼잡도 변화는 어떤 사용자에게는 의미가 없다. 반면 즐겨찾기 주차장이 급격히 줄거나, 관심 공원에 통제 공지가 생기면 행동을 바꿔야 할 수 있다.

그래서 내부 fact type을 사용자에게 그대로 노출하지 않는다. 사용자가 원한 알림 단위로 묶는다.

  • 중요한 변화.
  • 주차 실패 방지.
  • 관심 공원의 행사와 공지.
  • 조용한 시간.
  • 반복 알림 cooldown.
  • 원천 데이터 최신성에 따른 발송 보류.

푸시에서 원천 데이터 최신성은 특히 조심해서 봤다. 오래된 데이터에서 나온 사실은 즉시 푸시하면 안 된다. 사용자의 행동을 바꾸는 알림일수록 더 보수적으로 다뤄야 한다.

마지막 단계의 APNs 전송

iOS 개발을 처음 할 때는 APNs device token 등록에 집중하기 쉽다. 하지만 서버 관점에서 그 값은 시작점이다.

서비스를 돌리면 다음 문제가 더 어렵다.

  • device token이 갱신되거나 무효화되는 경우.
  • 동일 설치가 여러 구독 상태를 거치는 경우.
  • 사용자가 권한을 껐다 켜는 경우.
  • 발송 실패가 일시적인지 영구적인지 구분하는 경우.
  • 같은 알림 후보가 worker 재시작 후 중복 발송되는 경우.

그래서 outbox와 delivery attempt를 분리한다. outbox는 “보내야 할 일”이고, attempt는 “보내려 했던 기록”이다. APNs 응답은 감사 기록과 device token 상태 갱신에 모두 사용된다.

남겨야 할 것은 “보냈나?”보다 “왜 그렇게 했나?”였다. device token이 무효였는지, 조용한 시간이라 억제했는지, 원천 데이터가 오래돼 보류했는지, APNs가 일시 실패했는지에 따라 다음 조치가 다르다. 이 차이를 저장하지 않으면 push가 좋은지 나쁜지 감으로만 보게 된다.

이유를 이해한 뒤의 권한 요청

알림 권한 요청은 first launch에서 바로 띄우기 쉽다. 하지만 한강자리에서는 사용자가 필요를 이해한 뒤 요청하는 순서를 택했다.

사용자가 관심 공원이나 주차장을 고르고, 어떤 알림이 필요한지 이해한 뒤 요청해야 한다. 권한은 시스템 팝업이지만, 설득은 앱 안의 말과 순서가 해야 한다.

알림 설정 UI도 내부 테이블을 그대로 드러내면 안 된다. 사용자가 이해하는 단위는 notification_fact_type이 아니라 “내가 가려는 곳에 문제가 생겼는지”다.

품질이 된 미발송 근거

APNs device token 등록은 push 시스템의 시작일 뿐이었다. 사용자 intent와 서버의 보내기 규칙을 나누고, outbox와 delivery attempt를 사라지지 않는 기록으로 남겨야 운영자가 다음 조치를 고를 수 있었다.

원천 데이터 최신성이 낮으면 보내지 않는 편이 맞을 수 있다. 빠른 조회용 index는 따로 두고, 최종 이유와 감사 기록은 나중에 설명할 수 있는 저장소에 남겼다. 권한 요청 시점도 구현 편의보다 사용자가 필요를 이해하는 순서에 맞췄다.

푸시 품질은 발송 성공률과 함께 suppression 규칙과 미발송 사유로 확인했다. 사용자의 시간을 먼저 끊기 때문에, 한강자리에서 좋은 push는 많이 보내는 push가 아니라 이유를 설명할 수 있는 push였다.

Comments

댓글

    이미지 확대