푸시 알림을 보내기 전에 줄이는 구조

구독 설정, 조용한 시간, 최신성, 감사 기록으로 발송 후보를 걸렀다

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

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

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

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

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

영역역할
Identity설치 단위와 device identity를 저장
SubscriptionAPNs 토큰 계열 상태와 앱 버전·환경을 따라감
Preference공원, 주차장, 알림 종류별 사용자 의도
Notification fact원천 데이터에서 나온 알림 후보를 정규화한 사실
Fact claim같은 fact를 두 번 다루지 않도록 claim
Delivery decision보내거나 막은 이유를 기록
Outbox실제 APNs 발송을 기다리는 durable queue
Delivery attemptAPNs 응답과 실패 이력을 기록
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-->>Worker: 전송 결과 반환
  Worker->>PG: 시도 결과와 후속 조치 기록

여기서 중요한 기록은 delivery decision이다. 알림 후보가 생겼다고 모두 outbox로 가는 것이 아니다. 보내도 되는 조건을 통과한 경우에만 push_now가 된다. 그 외에는 suppress로 남긴다.

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

flowchart LR
  Fact["알림 fact"] --> Claim["fact 점유"]
  Claim --> Candidates["후보 조회<br/>Redis 우선<br/>Postgres 대체"]
  Candidates --> Policy["발송 규칙<br/>선호 설정<br/>조용한 시간<br/>쿨다운<br/>최신성"]
  Policy --> Decision{"전송 결정"}
  Decision -->|push_now| Outbox["푸시 outbox"]
  Decision -->|억제| Suppressed["억제 결정"]
  Outbox --> APNs["APNs"]
  APNs --> Attempts["전송 시도"]

보내지 않는 규칙을 먼저 만들었다

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

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

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

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

APNs 전송은 마지막 단계다

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

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

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

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

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

권한 요청은 이유를 이해한 뒤에 한다

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

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

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

보내지 않은 이유가 품질이 됐다

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

원천 데이터 최신성이 낮으면 발송보다 suppress가 맞을 수 있다. Redis index는 빠른 조회용으로 두고, 최종 이유와 감사 기록은 Postgres에 남겼다. 권한 요청 시점도 구현 편의보다 사용자가 필요를 이해하는 순서에 맞췄다.

푸시는 기술적으로 연결되는 순간보다, 보내지 않기로 정하는 순간에서 품질이 갈렸다. 사용자의 시간을 먼저 끊기 때문에, 한강자리에서 좋은 push는 많이 보내는 push가 아니라 이유를 설명할 수 있는 push였다.

이미지 확대