Chapter 10
알림 시스템 설계
APNs·FCM·SMS·이메일 4채널의 통합 알림 시스템. 채널별 큐로 분리하고 알림 로그·사용자 설정·재시도로 신뢰성을 보강한다.
알림 시스템의 핵심 부품 4가지
알림의 마지막 마일은 모두 외부 서비스(APNs·FCM·SMS·이메일)에 의존한다. 외부 통제 불가와 채널별로 다른 처리율·인증·실패 모드 두 제약이 시스템 설계의 기준이 된다.
- ① 채널별 큐 분리 — APNs/FCM/SMS/Email 각각의 큐. 한 채널이 막혀도 다른 채널은 살아 있게.
- ② 알림 로그 + dedup 키 — 손실 방지 + 중복 방지.
- ③ 사용자 설정 + 템플릿 — 보내기 전에 opt-in 체크, 메시지는 템플릿에서 파라미터만 치환.
- ④ Rate limit + 재시도 — 사용자·외부 서비스 모두 보호.
1. 요구사항 정의
알림 시스템 설계는 채널 비대칭, 외부 의존, 대량 발송 세 가지 제약을 동시에 다뤄야 한다.
면접에서 확정할 질문
- 지원 채널 — 푸시(iOS/Android)? SMS? 이메일? 셋 다?
- 실시간성 — 즉시 발송이 기본? 예약 발송도?
- 발송 주체 — 클라이언트 단말? 서버 측 트리거? 외부 서비스?
- 사용자 수신 거부 옵션 — 채널별·카테고리별 opt-in/out?
- 일일 발송량 — iOS·Android·SMS·이메일 각각
- 다국어·다지역 지원 여부 (시간대·언어)
이 챕터의 표준 요구사항
| 채널 | 일일 발송량 | 비고 |
|---|---|---|
| iOS push | 1,000만 | APNs |
| Android push | 1억 | FCM |
| SMS | 500만 | Twilio 같은 외부 서비스 |
| 5억 | SendGrid/Mailchimp |
채널마다 두 자릿수 차이가 나는 트래픽이라 한 큐로 묶을 수 없다. 이메일이 폭주하면 푸시도 막힌다. 이것이 4번 섹션의 채널별 큐 분리의 1차 근거.
이 시스템이 어려운 3가지 구조적 이유
APNs/FCM/SMS/Email은 인증 방식·페이로드 크기·전송 지연·에러 코드가 전혀 다르다. 한 코드로 처리하면 if 폭탄.
APNs·FCM·Twilio·SendGrid 모두 외부 서비스. 우리가 장애를 통제할 수 없고, 응답 시간 변동·rate limit· 일시 거부에 대응해야 한다.
안 보내는 것(loss)도 문제, 두 번 보내는 것(duplicate)도 문제. 사용자는 둘 다 불신·이탈로 응답한다.
2. 이메일 / SMS / 푸시 알림 구조
각 채널이 실제로 어떻게 작동하는지부터 본다. 모두 외부 서비스가 마지막 마일을 책임지지만, 흐름·전제 조건은 채널마다 다르다.
iOS Push — Provider → APNs → Device
필요한 입력: device token(앱이 디바이스 + 앱 식별), payload(alert/badge/sound 등 JSON), 인증서(APNs와의 TLS 인증).
Android Push — Provider → FCM → Device
APNs와 거의 동일한 구조. 입력은 FCM 등록 토큰 + payload. APNs 대비 인증서가 아닌 API 키 기반이라 운영이 좀 더 단순한 편.
SMS · Email — 외부 서비스 위탁
자체 통신망 구축은 비현실 → 거의 100% 3rd party. 비용이 푸시 대비 훨씬 비싸므로 ‘본인 인증’ 같은 고가치 알림에 집중.
직접 SMTP를 돌리면 평판·스팸 필터 우회가 거의 불가능 → 평판 관리 잘하는 외부 서비스에 위탁. 도달률·오픈율 분석이 따라옴.
연락처 정보 수집 — 모든 채널의 출발선
어떤 채널이든 “누구한테 보낼지” 정보가 미리 DB에 있어야 한다. 사용자가 가입할 때·앱을 처음 켤 때 수집해 저장한다. 한 사용자는 디바이스 여러 개·이메일 여러 개를 가질 수 있으니 1:N 관계로 모델링.
| 테이블 | 주요 컬럼 | 메모 |
|---|---|---|
| users | id, email, country, timezone, … | 1 |
| devices | user_id, device_token, os, last_seen | N (사용자당 여러 단말) |
토큰이 만료되거나 디바이스가 바뀌면 APNs/FCM이 invalid token을 반환한다. 워커는 이 응답을 받으면 devices에서 해당 토큰을 비활성·삭제한다. 앱이 다시 켜질 때 새 토큰이 등록된다. 이 cleanup이 빠지면 매번 같은 무효 토큰에 헛발송한다.
3. 단일 서버 → 확장형 아키텍처
“이벤트가 들어오면 외부 서비스에 보낸다.” 가장 단순한 형태에서 시작해, 어떤 문제가 생기는지 보면서 확장형 아키텍처로 진화시킨다.
1차 — 단일 서버 직결
요청을 받자마자 서버가 외부 API를 직접 호출한다. 트래픽이 적을 때는 동작하지만 곧 한계가 온다.
이 구조에서 무엇이 깨지는가:
- 단일 장애점 — 서버가 죽으면 알림 전체 정지
- 외부 지연이 입구로 역류 — APNs가 느리면 이벤트 소스의 요청도 같이 늘어진다
- 채널 간 간섭 — 이메일 폭주가 푸시 처리를 막는다
- 실패 시 복구 불가 — 워커가 죽는 순간 미발송 메시지가 사라진다
2차 — 확장형 아키텍처
위 문제를 한꺼번에 푸는 구조: 다중 인스턴스 + 채널별 큐 + 워커의 3단 파이프라인. 큐가 입구와 외부 호출 사이를 끊어준다.
- API 인증(appKey/appSecret), 페이로드 검증
- users/devices/settings 조회 (DB + 캐시)
- 사용자 opt-out·rate limit 체크 후 채널별 큐로
- 각 채널 워커는 그 채널 SDK·인증만 안다 → 단순
- 실패 시 재시도 큐로(지수 백오프)
- 성공/실패를 알림 로그에 기록
큐가 한가운데 끼어 있어 생산(서버)과 소비(워커)가 완전히 분리된다. 워커 장애·외부 지연이 입구 API로 역류하지 않는다.
4. 메시지 큐 기반 비동기 처리
확장형 아키텍처의 핵심이 채널별 큐다. 왜 4개의 큐인지, 큐가 가져다주는 효과가 무엇인지 정리한다.
채널별로 다른 3가지
| 차원 | iOS | Android | SMS | |
|---|---|---|---|---|
| 처리량(일일) | 1,000만 | 1억 | 500만 | 5억 |
| 평균 지연 | 수백 ms | 수백 ms | 초~수십 초 | 초~분 |
| 실패 모드 | invalid token | invalid token | 번호 거부/지역 | 바운스/스팸 |
한 큐로 묶으면 일어나는 일
- 가장 느린 채널이 전체 처리량을 결정 — 이메일이 막히면 푸시도 같이 늦어진다
- 장애 격리 불가 — SendGrid가 다운되면 APNs도 함께 대기열 폭주
- 워커 코드가 if 폭탄 — 채널마다 인증·페이로드가 달라 한 워커에서 분기하면 복잡도 폭증
큐가 가져다주는 3가지 효과
순간 트래픽 스파이크를 평탄화. 입구 API 응답 시간이 외부 서비스 지연과 분리된다.
큐 깊이를 모니터링해 한 채널이 막힐 때 다른 채널·서비스에 영향 없이 격리.
실패한 메시지를 다시 큐에 넣어 워커가 떠난 뒤에도 시도가 이어진다. 워커 무상태화 가능.
5. 알림 저장 및 전달
“보내야 한다 / 보내지 말아야 한다”를 결정하고, 보낼 내용을 만들고, 이력을 남기는 부분. 사용자 설정·템플릿·알림 로그가 한 묶음으로 움직인다.
알림 로그(DB) — 손실 방지의 기준선
알림 요청을 받자마자 DB에 한 줄 기록한 뒤 큐에 넣는다. 큐가 메시지를 잃거나 워커가 죽어도, 로그를 보고 미발송 항목을 재시도할 수 있다.
- 상태 전이:
PENDING → IN_FLIGHT → SENT / FAILED FAILED는 재시도 큐 또는 사람 검수 큐로- DB가 진실의 원천 — 큐는 작업 배포 채널일 뿐
중복 방지 — Dedup key (멱등성)
분산 시스템에서는 at-least-once가 기본값이다. 같은 알림 요청이 두 번 들어오면? 클라이언트가 매 요청에 유일한 dedup key(idempotency key)를 붙이고, 서버는 그 키로 중복 체크.
dedup key 검사 + 로그 삽입을 한 트랜잭션으로 처리해야 중복 enqueue를 막을 수 있다. 키 저장은 보통 짧은 TTL의 인메모리(Redis) + 영구 DB 이중화.
사용자 알림 설정
가입 시·설정 화면에서 사용자가 채널·카테고리별 opt-in/out을 지정. 알림 서버는 보내기 전에 이 설정을 조회한다.
| 컬럼 | 예시 | 설명 |
|---|---|---|
| user_id | 42 | 사용자 ID |
| channel | push / sms / email | 채널 식별 |
| category | marketing / security / order | 카테고리(보안은 강제 발송 가능) |
| opt_in | true / false | 수신 여부 |
알림 템플릿
“주문이 발송되었습니다” 같은 메시지를 매번 새로 작성하지 않고, 템플릿에 파라미터를 끼워넣는다. 캠페인·다국어 관리에 필수.
- 일관성 — 같은 알림은 항상 같은 톤·문구
- 관리 용이 — 문구 수정 한 번이면 전 캠페인 반영
- 다국어 — 같은 키로 언어별 템플릿 다중 보관
처리 순서 — 값싼 검사부터
비싼 외부 호출(템플릿 렌더링·DB·외부 서비스)을 하기 전에 opt-out 체크가 가장 먼저. 거부된 메시지를 큐까지 보내면 큐가 의미 없이 부풀고 비용이 든다.
- opt-out 체크 (값쌈)
- rate limit 체크
- 템플릿 렌더링
- 큐 enqueue
- 워커의 외부 서비스 호출 (값비쌈)
6. 장애 처리 및 안정성
외부 서비스는 언제든 5xx를 내고, 워커는 언제든 죽는다. 신뢰성은 재시도·격리·정리 세 메커니즘의 조합으로 만든다.
재시도 — 지수 백오프 + 상한
- 일시 실패(5xx) → 1s · 2s · 4s · 8s · 16s … 지수 백오프로 재큐
- 영구 실패(invalid token·번호 거부 등) → 재시도 안 함, 연락처 비활성화
- 최대 재시도 횟수 초과 → Dead Letter Queue로 보내 운영자 알림
큐의 durability는 큐 안에 있는 동안만 보장된다. 워커가 ack 직후 죽으면 알림은 발송되었는데 시스템이 그걸 모를 수 있다. 반대로 발송 전 워커가 죽어 메시지가 재배달되면 중복. 모두 알림 로그(DB)로 풀어야 한다.
Invalid token cleanup
APNs/FCM이 invalid token을 반환하면 워커는 devices에서 해당 토큰을 비활성·삭제한다. 이 cleanup이 빠지면 영구 실패 토큰에 매번 재시도하며 외부 서비스 rate limit만 갉아먹는다.
가용성
- 알림 서버·워커 모두 stateless → 다중 인스턴스 + 로드밸런서
- 큐는 다중 노드 복제(Kafka·SQS·RabbitMQ 등)
- 외부 서비스 장애에 대비해 대체 공급자를 둘 수도 있음 (이메일: SendGrid + Mailgun 등)
일관성 수준
- 알림 로그·dedup 키 검사 → 강한 일관성
- 오픈/클릭 추적 데이터 → 결과적 일관성으로 충분
7. 확장성과 최적화 고려사항
기본 시스템이 동작한 뒤 단계적으로 더하는 것들. 사용자 보호, 외부 서비스 보호, 운영 가시성·효과 측정이 이 단계에서 들어간다.
사용자별 Rate limit
- 사용자에게 단시간 다량 알림을 보내면 스팸으로 인지 → 알림 끄기·앱 삭제로 응답
- “1시간에 N개 이하” 같은 한도를 사용자별로 (4장 rate limiter와 연결)
- 보안·결제 같은 고우선 카테고리는 예외로 두는 게 보통
외부 서비스 Rate limit
APNs·FCM·Twilio·SendGrid 모두 자체 rate limit이 있다. 우리가 한 번에 폭주시키면 일시 차단되고, 그 사이 메시지는 큐에 쌓인다. 워커는 외부 limit을 인지하고 발송 속도를 조절(token bucket 등).
보안 — appKey / appSecret
- 알림 API는 누구나 호출 가능하면 안 됨 — 인증된 클라이언트(내부 서비스 또는 파트너)만
- appKey/appSecret 발급 + 요청마다 HMAC 서명
- 서명 검증 + IP 화이트리스트로 이중 방어
모니터링
- 큐 깊이 — 채널별 큐가 늘어나면 외부 서비스 지연·장애 신호
- 발송 성공률·실패 코드 분포 — invalid token이 급증하면 디바이스 cleanup 누락
- 워커 처리량·재시도율·DLQ 적재량 등 운영 지표
이벤트 트래킹 — 효과 측정
- 오픈율·클릭율·전환율 — 캠페인 효과 측정, 사용자 행동 데이터 축적
- 추적 이벤트는 분석 서비스(데이터 웨어하우스)로 흘려보낸다