알림은 대개 백엔드 설계에서 뒤로 밀립니다. 주문을 저장하고, 결제를 승인하고, 권한을 바꾸는 핵심 로직이 먼저이고, 메일이나 푸시는 “나중에 하나 보내면 되는 것"처럼 취급됩니다. 하지만 운영에 들어가면 알림은 단순 부가 기능이 아닙니다. 결제 실패 메일이 빠지면 사용자는 왜 서비스가 막혔는지 모릅니다. 비밀번호 변경 알림이 두 시간 늦게 가면 보안 사고 대응이 늦어집니다. 반대로 프로모션 푸시가 하루에 열 번 가면 사용자는 앱 알림을 꺼 버립니다.

알림의 어려움은 외부 효과라는 데 있습니다. DB 트랜잭션은 롤백할 수 있지만 이미 보낸 메일은 되돌릴 수 없습니다. 같은 이벤트가 worker 재시도로 두 번 처리되면 같은 푸시가 두 번 갑니다. provider가 500을 반환했는데 실제로는 발송했을 수도 있습니다. 사용자가 수신 거부를 눌렀는데 큐에 쌓인 과거 알림이 계속 나갈 수도 있습니다. 그래서 알림 시스템은 Transactional Outbox + CDC, Queue Visibility Timeout과 Ack/Nack, Idempotency Key, Webhook Delivery Reliability와 같은 운영 패턴을 같이 써야 합니다.

이 글의 목표는 “메일을 보내는 코드"가 아니라 알림 요청, 사용자 선호, 전송 시도, provider 응답, 재시도, suppression을 어떻게 분리할지를 정리하는 것입니다. 작은 서비스라도 이 경계를 잡아두면 나중에 채널이 이메일 하나에서 푸시, SMS, 카카오, 인앱 알림으로 늘어날 때 사고가 덜 납니다.

이 글에서 얻는 것

  • 알림을 transactional, security, marketing, digest로 나누고 각 유형별 허용 지연과 수신 거부 기준을 정할 수 있습니다.
  • 업무 트랜잭션에서 바로 provider를 호출하지 않고 outbox와 worker로 분리하는 이유를 이해합니다.
  • 사용자 preference, quiet hours, suppression, unsubscribe를 발송 시점에 어떻게 반영할지 기준을 잡습니다.
  • 중복 발송을 막기 위한 notification_id, dedup_key, provider message id, delivery log 설계를 정리합니다.
  • 운영 지표를 delivery success rate만이 아니라 피로도, bounce, retry, duplicate 관점으로 볼 수 있습니다.

핵심 개념/이슈

1) 알림 유형을 먼저 나누지 않으면 정책이 섞인다

모든 알림을 같은 큐에 넣고 같은 재시도 정책으로 처리하면 곧 문제가 생깁니다. 보안 알림과 마케팅 알림은 목적이 다릅니다. 주문 배송 알림과 주간 요약 메일도 지연 허용치가 다릅니다. 따라서 발송 코드보다 먼저 알림 taxonomy가 필요합니다.

유형예시지연 허용수신 거부기본 채널
Security비밀번호 변경, 새 기기 로그인1분 이내제한적이메일 + 인앱
Transactional주문, 결제, 배송, 환불1~5분보통 제한적이메일/푸시/SMS
Operational장애 공지, 점검, 정책 변경5~30분일부 가능이메일/인앱
Digest주간 요약, 활동 모음수 시간가능이메일/인앱
Marketing프로모션, 추천, 캠페인수 시간~수 일필수푸시/이메일

실무 기준으로 security와 transactional은 at-least-once + dedup에 가깝게 운영합니다. 잃는 비용이 중복보다 크기 때문입니다. Marketing과 digest는 반대입니다. 사용자가 원치 않는 발송을 줄이는 것이 더 중요하므로 quiet hours, frequency cap, unsubscribe가 우선입니다.

2) 업무 트랜잭션 안에서 바로 발송하지 않는다

가장 흔한 안티패턴은 주문 저장 트랜잭션 안에서 메일 provider를 바로 호출하는 것입니다.

create order
  -> insert order
  -> call email provider
  -> commit

이 구조는 간단하지만 실패 지점이 애매합니다. provider 호출이 느리면 주문 API latency가 늘어납니다. provider 호출은 성공했는데 DB commit이 실패하면 실제 주문은 없는데 메일은 갑니다. DB commit은 성공했는데 provider 호출이 timeout이면 재시도해야 할지 말지 알기 어렵습니다.

더 안전한 구조는 outbox입니다.

create order transaction
  -> insert order
  -> insert notification_outbox(order_paid, dedup_key)
  -> commit

notification worker
  -> read outbox
  -> check preference and policy
  -> create delivery_attempt
  -> call provider
  -> record result

핵심은 “주문이 생겼다"와 “알림을 보내야 한다"를 같은 DB 트랜잭션으로 남기고, 실제 외부 호출은 worker로 넘기는 것입니다. 이 패턴은 Transactional Outbox + CDC의 전형적인 적용입니다.

3) preference는 요청 시점과 발송 시점을 구분해서 봐야 한다

사용자 수신 설정은 생각보다 까다롭습니다. 이벤트 발생 시점에는 사용자가 push를 허용했지만 worker가 10분 뒤 처리할 때는 꺼졌을 수 있습니다. 반대로 보안 알림처럼 수신 거부가 제한되는 알림도 있습니다.

두 가지 방식을 선택할 수 있습니다.

  • 발송 시점 재조회: worker가 실행될 때 최신 preference를 확인합니다. 마케팅, digest, 추천 알림에 적합합니다.
  • 정책 snapshot 저장: outbox에 당시 정책 버전, 허용 채널, 법적 근거를 저장합니다. 결제, 보안, 약관 변경처럼 증거가 중요한 알림에 적합합니다.

권장 기준은 아래처럼 둡니다.

알림 유형preference 처리
Securitypolicy snapshot + 최신 차단 목록 확인
Transactionalpolicy snapshot + hard bounce/suppression 확인
Digest발송 시점 최신 preference
Marketing발송 시점 최신 preference + consent audit

즉 “큐에 들어갔으니 보낸다"는 부족합니다. worker는 발송 직전에 적어도 suppression list, hard bounce, unsubscribe, quiet hours를 확인해야 합니다.

4) 중복 제거 키는 이벤트 ID와 다를 수 있다

이벤트가 하나라고 알림도 하나인 것은 아닙니다. 주문 결제 완료 이벤트 하나로 사용자에게 이메일, 푸시, 인앱 알림이 각각 나갈 수 있습니다. 반대로 장바구니 알림은 같은 사용자의 여러 이벤트를 하나의 digest로 묶어야 할 수 있습니다.

그래서 키를 분리합니다.

의미
event_id원천 업무 이벤트 식별자
notification_id알림 요청 하나의 식별자
dedup_key같은 알림을 중복 생성하지 않기 위한 비즈니스 키
delivery_attempt_idprovider 호출 시도 하나의 식별자
provider_message_id외부 provider가 반환한 전송 식별자

예를 들어 order:{orderId}:paid:user:{userId}:email:v1을 dedup key로 두면 같은 주문 결제 이메일은 한 번만 생성됩니다. worker가 재시도하더라도 같은 notification_id의 attempt만 늘어납니다. provider가 timeout을 냈을 때도 새 알림을 만들지 않고 같은 delivery attempt 흐름에서 재확인하거나 재시도합니다.

dedup TTL은 알림 성격에 따라 다릅니다. 결제·주문은 3090일, 보안 알림은 730일, marketing campaign은 campaign 기간 + 7일 정도가 출발점입니다. TTL이 너무 짧으면 장애 복구 중 같은 알림이 다시 생성됩니다.

5) delivery success만 보면 알림 품질을 놓친다

provider가 202 Accepted를 줬다고 사용자가 읽은 것은 아닙니다. 이메일은 bounce될 수 있고, 푸시는 토큰이 만료될 수 있고, SMS는 carrier에서 지연될 수 있습니다. 또 성공률이 높아도 사용자가 알림을 꺼 버리면 시스템은 실패한 것입니다.

최소 지표는 아래처럼 나눕니다.

  • outbox lag p95/p99
  • delivery success rate by channel/provider
  • retry attempt count와 최종 실패율
  • duplicate suppressed count
  • hard bounce, soft bounce, invalid push token 비율
  • unsubscribe rate, notification disabled rate
  • user-level frequency cap hit rate
  • quiet hours deferred count
  • security notification latency p95

운영 기준 예시는 이렇게 시작할 수 있습니다.

지표초기 목표
security notification p9560초 이하
transactional notification p955분 이하
duplicate send rate0.1% 미만
provider retry final failure1% 미만
hard bounce 재시도0회
marketing unsubscribe spike전주 대비 +30% 초과 시 캠페인 중단

실무 적용

1) 기본 테이블을 작게 설계한다

처음부터 거대한 campaign platform을 만들 필요는 없습니다. 핵심은 요청과 시도를 분리하는 것입니다.

CREATE TABLE notification_outbox (
  id BIGSERIAL PRIMARY KEY,
  notification_type VARCHAR(80) NOT NULL,
  user_id BIGINT NOT NULL,
  channel VARCHAR(20) NOT NULL,
  dedup_key VARCHAR(200) NOT NULL,
  template_key VARCHAR(120) NOT NULL,
  payload_json JSONB NOT NULL,
  policy_version VARCHAR(40) NOT NULL,
  status VARCHAR(30) NOT NULL,
  scheduled_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ NOT NULL,
  UNIQUE (dedup_key, channel)
);

CREATE TABLE notification_delivery_attempt (
  id BIGSERIAL PRIMARY KEY,
  notification_id BIGINT NOT NULL,
  attempt_no INT NOT NULL,
  provider VARCHAR(40) NOT NULL,
  provider_message_id VARCHAR(120),
  status VARCHAR(30) NOT NULL,
  error_code VARCHAR(80),
  attempted_at TIMESTAMPTZ NOT NULL
);

payload_json에는 원문 개인정보를 많이 넣지 않는 편이 좋습니다. 템플릿 렌더링에 필요한 최소 값만 넣고, 민감한 값은 내부 ID나 masked value로 둡니다. 로그도 구조화 로깅 기준처럼 원문보다 식별자와 상태를 남깁니다.

2) worker는 발송기가 아니라 정책 실행자다

좋은 worker는 큐에서 꺼내 provider를 호출하는 코드가 아닙니다. 발송 전 정책을 실행합니다.

  1. notification 상태가 READY인지 확인한다.
  2. dedup key로 이미 성공한 발송이 있는지 확인한다.
  3. 최신 suppression, bounce, unsubscribe를 확인한다.
  4. quiet hours면 DEFERRED로 미룬다.
  5. 사용자 frequency cap을 넘으면 digest나 drop으로 전환한다.
  6. provider를 호출하고 delivery attempt를 기록한다.
  7. retry 가능 오류와 영구 실패 오류를 구분한다.

재시도 기준도 채널별로 둡니다.

채널재시도
Email5xx/timeout만 3~5회, hard bounce는 즉시 suppression
Pushinvalid token은 즉시 폐기, transient 오류만 2~3회
SMS비용이 높으므로 1~2회, 중요 알림만 fallback
In-appDB 저장 실패는 재시도, 읽음 처리는 별도

3) quiet hours와 frequency cap을 숫자로 시작한다

알림 피로를 줄이는 가장 쉬운 방법은 기본 숫자를 두는 것입니다.

  • 일반 push quiet hours: 현지 시간 22:00~08:00
  • 보안 알림: quiet hours 예외 허용
  • 마케팅 push: 사용자당 하루 1~2개
  • 추천/활동 알림: 사용자당 하루 3개 이하, 초과분 digest
  • 동일 template dedup window: 6~24시간
  • 캠페인 중 unsubscribe rate가 전주 대비 30% 이상 튀면 자동 pause

여기서 중요한 것은 “모든 알림을 줄인다"가 아니라 “중요하지 않은 알림이 중요한 알림의 신뢰를 갉아먹지 않게 한다"입니다.

4) 템플릿과 정책 버전을 같이 남긴다

나중에 CS가 “왜 이 메일이 갔나요?“라고 묻는 순간이 옵니다. 이때 필요한 것은 provider dashboard의 성공 여부만이 아닙니다. 어떤 이벤트 때문에, 어떤 정책 버전으로, 어떤 수신 설정에서, 어떤 템플릿으로 갔는지가 필요합니다.

delivery evidence에는 최소 아래를 남깁니다.

notification_evidence:
  notification_id: 908172
  source_event: order_paid
  source_event_id: ord_evt_123
  user_id: 42
  channel: email
  template_key: order_paid_v3
  policy_version: notify_policy_2026_06
  preference_basis: transactional_required
  provider: ses
  provider_message_id: msg_abc
  final_status: delivered

이 증거가 있으면 중복 발송, 수신 거부 민원, 보안 알림 누락을 훨씬 빨리 조사할 수 있습니다.

5) 설계 리뷰는 알림 하나를 끝까지 추적하는 방식으로 한다

알림 시스템 리뷰를 할 때는 테이블 목록이나 provider 설정부터 보면 흐름이 쉽게 흩어집니다. 더 좋은 방법은 알림 하나를 골라 이벤트 발생부터 사용자 도달, 실패, 재처리, 민원 대응까지 따라가는 것입니다. 예를 들어 “결제 실패 이메일"을 고르면 아래 질문에 답해야 합니다.

단계확인 질문남겨야 할 증거
이벤트 생성결제 실패가 몇 번 연속일 때 알림을 만들 것인가?source event id, failure reason, payment attempt id
outbox 기록같은 결제 실패 알림이 중복 생성되지 않는가?notification id, dedup key, policy version
정책 판단수신 거부, hard bounce, quiet hours를 어떻게 반영하는가?preference basis, suppression reason, scheduled_at
provider 호출timeout, 5xx, hard bounce를 어떻게 구분하는가?delivery attempt id, provider status, error code
재처리재시도해도 안전한가, 사람 승인이 필요한가?retry count, next retry at, terminal status
사용자 문의왜 보냈는지 30초 안에 설명할 수 있는가?template key, rendered data version, final status

이 방식의 장점은 모호한 정책이 바로 드러난다는 점입니다. “마케팅 알림은 수신 거부를 본다"는 말은 쉬워도, 이미 큐에 들어간 캠페인 알림을 사용자가 5분 뒤 거부했을 때 보낼지 말지는 따로 정해야 합니다. “보안 알림은 무조건 보낸다"도 마찬가지입니다. hard bounce 주소에 계속 보내거나, 야간에 SMS fallback을 무제한 허용하면 보안보다 사용자 피해가 커질 수 있습니다.

실무 리뷰에서는 적어도 세 가지 알림을 고르는 편이 좋습니다. 하나는 반드시 보내야 하는 security 알림, 하나는 결제·주문 같은 transactional 알림, 하나는 사용자가 피로를 느끼기 쉬운 marketing 또는 digest 알림입니다. 세 알림의 정책이 모두 같은 코드 경로와 같은 재시도 횟수를 공유한다면 아직 분리가 부족하다는 신호입니다. 반대로 알림마다 테이블과 worker가 전부 다르면 운영 비용이 과합니다. 목표는 채널별 provider 코드를 재사용하되, 유형별 정책과 증거는 분리하는 것입니다.

6) 작은 팀은 완성형 플랫폼보다 운영 불변식을 먼저 둔다

알림 플랫폼을 처음부터 크게 만들 필요는 없습니다. 작은 팀이라면 이메일 하나, push 하나로 시작해도 됩니다. 다만 아래 불변식은 초기에 넣는 편이 나중에 싸게 먹힙니다.

  • 업무 트랜잭션 안에서 provider를 직접 호출하지 않는다.
  • 같은 dedup_key + channel 조합의 알림 요청은 한 번만 만든다.
  • worker는 발송 직전에 suppression과 preference를 확인한다.
  • provider 호출 결과는 성공, 재시도 가능 실패, 영구 실패로 나눠 저장한다.
  • hard bounce와 invalid token은 다음 발송 전에 차단 목록에 반영한다.
  • CS가 확인할 수 있는 delivery evidence를 최소 30~90일 보관한다.

이 불변식은 구현 기술과 무관합니다. RDB outbox를 쓰든, durable queue를 쓰든, SaaS notification provider를 쓰든 지켜야 합니다. 오히려 SaaS provider를 쓸수록 내부 evidence가 더 중요해집니다. provider 화면은 provider 입장의 delivery 상태를 보여줄 뿐, 우리 서비스의 사용자 동의, 정책 버전, 업무 이벤트 맥락까지 대신 설명해주지 않습니다.

트레이드오프/주의점

첫째, outbox는 발송 안정성을 높이지만 즉시성을 조금 희생합니다. 보안 알림처럼 1분 이내가 필요한 경우 worker polling interval, queue lag, provider latency를 따로 관리해야 합니다. outbox를 쓴다고 느려도 되는 것은 아닙니다.

둘째, latest preference 재조회는 사용자 의사를 잘 반영하지만 감사 증거가 약해질 수 있습니다. 반대로 snapshot은 증거가 강하지만 이후 수신 거부를 무시하는 구조가 될 수 있습니다. 그래서 marketing은 latest preference, transactional은 snapshot + suppression 확인처럼 나눠야 합니다.

셋째, 여러 채널 fallback은 조심해야 합니다. 이메일 실패 시 SMS로 보내는 것은 사용자 경험상 좋아 보이지만 비용, 동의, 민감정보 노출 문제가 생길 수 있습니다. fallback은 security나 payment-critical처럼 제한된 유형에만 허용합니다.

넷째, provider 성공 응답을 과신하면 안 됩니다. provider가 받았다는 것과 사용자가 받은 것은 다릅니다. bounce, complaint, invalid token feedback loop를 받아 suppression list에 반영해야 장기 품질이 유지됩니다.

다섯째, 알림 payload에 개인정보를 너무 많이 넣으면 delivery log가 위험한 저장소가 됩니다. 주문 번호, 내부 ID, masked email처럼 최소한의 증거만 남기고, 본문 원문은 장기 보관하지 않는 편이 안전합니다.

체크리스트 또는 연습

  • 알림 유형을 transactional, security, marketing, digest로 분류했다.
  • 각 유형별 지연 SLO, 수신 거부 가능 여부, 기본 채널이 문서화되어 있다.
  • 업무 트랜잭션은 provider를 직접 호출하지 않고 outbox를 남긴다.
  • notification_id, dedup_key, delivery_attempt_id, provider_message_id가 분리되어 있다.
  • worker가 발송 직전에 preference, suppression, bounce, quiet hours를 확인한다.
  • hard bounce와 invalid push token은 재시도하지 않고 차단 목록에 반영한다.
  • 사용자당 frequency cap과 campaign pause 조건이 있다.
  • delivery evidence에 template key, policy version, source event가 남는다.

연습으로는 현재 서비스의 알림 10개를 골라 위 표에 넣어 보세요. 10개 중 수신 거부 가능 여부, 재시도 횟수, 중복 제거 기준을 바로 말하지 못하는 알림이 3개 이상이면 알림 시스템은 아직 “보내는 기능” 수준일 가능성이 높습니다. 그다음 결제 실패 알림 하나를 골라 outbox row, delivery attempt row, preference 판단 기준, provider 실패 시 재시도 정책을 10줄 runbook으로 써 보면 실무 감각이 빠르게 잡힙니다.