알림 시스템 설계 — 1억 명에게 10초 안에 푸시를 보내는 구조
블랙프라이데이 자정, 쿠팡이 1억 명에게 동시에 “특가 시작!” 푸시를 보낸다. 10초 안에 전달되어야 한다. 하나의 서버가 직접 APNs와 FCM을 1억 번 호출하면? 서버는 즉시 죽는다. 알림 하나를 보내는 것은 쉽다. 신뢰할 수 있게, 대량으로, 빠르게 보내는 것이 시스템 설계의 전부다.
왜 알림 시스템이 어려운가
비유: 대형 우체국 분류 센터와 같다. 1억 통의 편지가 동시에 들어오면, 긴급/일반으로 분류하고, 각 배달부(APNs, FCM, Twilio, SendGrid)에게 적절히 배분하고, 배달 실패 시 재시도하고, 수신 거부 처리를 하고, 중복 발송을 막아야 한다. 이 모든 것이 동시에 일어난다.
단순 API 호출로 구현하면 어떤 문제가 생기는가:
| 문제 | 설명 |
|---|---|
| 동기 처리 | 알림 1건 전송에 200ms → 1억 건이면 231일 |
| 중복 발송 | 워커 재시작 시 같은 알림이 두 번 전송 |
| APNs/FCM 차단 | 초당 요청 한도 초과 시 IP 차단 |
| 단일 장애점 | APNs가 느려지면 전체 시스템이 막힘 |
| 데이터 유실 | 서버 재시작 시 메모리에 있던 알림이 사라짐 |
요구사항 분석
기능 요구사항
- 모바일 푸시 (iOS APNs, Android FCM)
- SMS 문자 메시지
- 이메일
- 알림 우선순위 (긴급/일반)
- 중복 발송 방지
- 전송 보장 (최소 1회)
- 사용자별 수신 거부 설정
규모 추정
모바일 푸시: 1,000만 건/일 → 116 QPS (평균), 350 QPS (피크)
SMS: 100만 건/일 → 11 QPS
이메일: 500만 건/일 → 58 QPS
총 알림: 1,600만 건/일 → 약 185 QPS (평균), ~600 QPS (피크)
전체 아키텍처
graph TD
Services["마이크로서비스들<br>(주문·결제·마케팅)"] --> API["알림 API 게이트웨이"]
API --> PrefCheck["1. 사용자 수신 설정 확인 (Redis 캐시)"]
API --> Dedup["2. 중복 방지 (Redis SET NX)"]
API --> Priority["3. 우선순위 분류"]
Priority --> P0["Kafka: notifications-critical<br>파티션 20개"]
Priority --> P1["Kafka: notifications-high<br>파티션 10개"]
Priority --> P2["Kafka: notifications-normal"]
Priority --> P3["Kafka: notifications-low"]
P0 & P1 --> PushWorker["푸시 워커 × 10"]
P0 & P1 --> SMSWorker["SMS 워커 × 5"]
P2 & P3 --> EmailWorker["이메일 워커 × 10"]
PushWorker -->|"실패"| DLQ["Dead Letter Queue"]
SMSWorker -->|"실패"| DLQ
EmailWorker -->|"실패"| DLQ
PushWorker --> APNs["Apple APNs"]
PushWorker --> FCM["Google FCM"]
SMSWorker --> Twilio["Twilio (1차)"]
SMSWorker --> Nexmo["Nexmo (fallback)"]
EmailWorker --> SendGrid["SendGrid"]
알림 채널별 동작 방식
모바일 푸시 — APNs와 FCM이 다른 이유
APNs(Apple)와 FCM(Google)은 각각 다른 프로토콜과 토큰 형식을 사용한다. 푸시 워커는 기기 타입을 보고 분기한다:
sequenceDiagram
participant API as "알림 API"
participant Kafka as "Kafka"
participant Worker as "푸시 워커"
participant APNs as "Apple APNs"
participant FCM as "Google FCM"
API->>API: 1. 사용자 기기 정보 조회 (device_token, platform)
API->>API: 2. 중복 체크 (Redis)
API->>Kafka: 3. 메시지 발행 (비동기)
API-->>API: 4. 202 Accepted 즉시 반환
Kafka->>Worker: 5. 메시지 소비
alt iOS 기기 (platform = 'ios')
Worker->>APNs: HTTP/2 + device_token
APNs-->>Worker: 200 OK
else Android 기기 (platform = 'android')
Worker->>FCM: HTTP + registration_token
FCM-->>Worker: success: 1
end
Worker->>LogDB: 6. 전송 결과 기록
왜 API가 즉시 202를 반환하는가? 실제 전송은 수백ms~수초가 걸린다. 동기로 기다리면 API 서버의 스레드가 모두 블로킹된다. Kafka에 발행하고 즉시 반환한다.
SMS — 공급자 Fallback이 왜 필요한가
Twilio가 장애나면 SMS가 전혀 안 간다. 주문 완료 SMS가 안 오면 고객 불안이 폭증한다. 공급자 이중화:
sequenceDiagram
participant W as "SMS 워커"
participant T as "Twilio (1차)"
participant N as "Nexmo (2차)"
W->>T: SMS 발송
alt 성공
T-->>W: 200 OK
else 실패 (3회 재시도 후)
W->>N: 대체 공급자로 발송
alt Nexmo 성공
N-->>W: 200 OK
else 모두 실패
W->>DLQ: Dead Letter Queue
W->>Alert: 운영팀 알림
end
end
중복 알림 방지 — 왜 반드시 필요한가
Kafka에서 메시지를 소비하다 워커가 크래시하면, 재시작 후 같은 메시지를 다시 처리한다. 이것이 At-Least-Once 전달의 부작용이다. 사용자 입장에서는 같은 주문 완료 알림이 두 번 온다.
class DeduplicationService:
def __init__(self, redis, window_seconds=3600):
self.redis = redis
self.window = window_seconds
def is_duplicate(self, user_id: str, event_type: str, content: str) -> bool:
# user_id + event_type + content_hash를 키로 사용
content_hash = hashlib.md5(content.encode()).hexdigest()
key = f"dedup:{user_id}:{event_type}:{content_hash}"
# SET NX: 키가 없을 때만 설정
# result = True → 새로 설정됨 → 중복 아님
# result = None → 이미 존재 → 중복
result = self.redis.set(key, "1", ex=self.window, nx=True)
return result is None
만약 중복 방지가 없으면? 마케팅 캠페인 알림이 5번 오는 상황이 발생한다. 사용자 이탈과 앱 삭제로 이어진다.
우선순위 큐 — 긴급 알림이 마케팅 알림에 막히지 않게
graph TD
Notif["알림 요청"] --> Classify{"우선순위 분류"}
Classify -->|"P0: 보안/결제"| P0["Kafka: critical<br>전용 워커 10개"]
Classify -->|"P1: 주문/배송"| P1["Kafka: high<br>전용 워커 5개"]
Classify -->|"P2: 소셜/댓글"| P2["Kafka: normal<br>공유 워커"]
Classify -->|"P3: 마케팅"| P3["Kafka: low<br>공유 워커"]
왜 같은 큐를 쓰면 안 되는가? 블랙프라이데이에 P3(마케팅) 알림 수천만 건이 쌓이면, 그 뒤에 들어온 P0(결제 완료) 알림이 수십 분 후에야 전달된다. 토픽 분리 + 전용 워커로 P0는 항상 10초 이내를 보장한다.
재시도 전략 — 지수 백오프가 왜 중요한가
APNs가 일시적으로 느려졌을 때 모든 워커가 즉시 재시도하면? 수천 개의 요청이 동시에 몰려 APNs를 더 힘들게 만든다(Thundering Herd). 지수 백오프 + 지터(Jitter):
graph TD
Send["알림 전송"] --> Fail{"실패?"}
Fail -->|"1회"| W1["1초 대기"]
W1 --> Send
Fail -->|"2회"| W2["4초 대기"]
W2 --> Send
Fail -->|"3회"| W3["16초 대기"]
W3 --> Send
Fail -->|"4회 초과"| DLQ["Dead Letter Queue"]
DLQ --> Alert["운영팀 알림"]
async def execute_with_retry(self, func, *args):
for attempt in range(self.max_retries + 1):
try:
return await func(*args)
except (NetworkError, TimeoutError) as e:
if attempt == self.max_retries:
await self.send_to_dlq(func, args, e)
raise
# 지수 백오프 + 랜덤 지터 (thundering herd 방지)
delay = min(
self.base_delay * (2 ** attempt) + random.uniform(0, 1),
self.max_delay
)
await asyncio.sleep(delay)
전송 보장 — Transactional Outbox 패턴
주문이 DB에 저장되는 것과 알림 발송이 원자적으로 처리되어야 한다. 주문은 DB에 저장됐는데 알림 발행 직전에 서버가 죽으면? 주문 완료 알림이 영원히 안 간다.
-- 같은 트랜잭션 안에서 처리
BEGIN TRANSACTION;
-- 1. 비즈니스 로직
UPDATE orders SET status = 'PAID' WHERE id = 12345;
-- 2. 알림을 같은 트랜잭션에 기록 (발행은 나중에)
INSERT INTO notification_outbox (user_id, type, payload, status)
VALUES (1001, 'ORDER_PAID', '{"orderId": 12345}', 'PENDING');
COMMIT;
-- 별도 스케줄러가 PENDING 행을 폴링해서 Kafka에 발행
-- 발행 완료 시 status = 'SENT'
이 패턴 없이 직접 Kafka에 발행하면? 트랜잭션이 롤백됐는데 Kafka에는 메시지가 이미 발행된 상황이 생긴다.
사용자 수신 설정 — 방해 금지 시간
def should_send_now(user_id: str, priority: str) -> bool:
# P0(보안/결제)는 방해 금지 무시 — 항상 전송
if priority == 'P0':
return True
settings = get_user_settings(user_id)
user_tz = pytz.timezone(settings.timezone)
user_now = datetime.now(user_tz).time()
# 22:00 ~ 08:00 방해 금지 시간 (자정 넘어가는 케이스 처리)
start, end = settings.quiet_hours_start, settings.quiet_hours_end
in_quiet = (user_now >= start or user_now < end) if start > end \
else (start <= user_now < end)
if in_quiet:
schedule_for_later(user_id, end) # 방해 금지 해제 시간에 재스케줄
return False
return True
극한 시나리오
graph TD
Marketing["마케팅팀: 1억명 캠페인 발송"] --> Segment["사용자 세그먼트 추출<br>(DB 쿼리)"]
Segment --> Batch["1000명씩 10만 배치 분할"]
Batch --> Kafka["Kafka: notifications-low<br>초당 1만 건 발행 (속도 제한)"]
Kafka --> Workers["워커 100개 병렬 처리"]
Workers --> APNs["APNs: 초당 1만건"]
Workers --> FCM["FCM: 초당 1만건"]
Note["예상 완료: 약 2시간 46분"]
왜 속도 제한이 필요한가? APNs/FCM은 초당 처리 한도가 있다. 한도 초과 시 IP 차단 → 모든 푸시 불가. 초당 1만 건 이하로 제어해서 차단을 피한다.
핵심 설계 결정 요약
| 결정 | 선택 | 이유 |
|---|---|---|
| 메시지 큐 | Kafka 우선순위별 토픽 분리 | 긴급 알림이 마케팅 알림에 막히지 않도록 |
| 중복 방지 | Redis SET NX (멱등성 키) | Kafka At-Least-Once의 부작용 제거 |
| 재시도 | 지수 백오프 + DLQ | Thundering Herd 방지, 영구 실패 알림 |
| 전송 보장 | Transactional Outbox 패턴 | DB 트랜잭션과 알림 발행의 원자성 |
| 공급자 이중화 | Twilio → Nexmo fallback | 단일 공급자 장애 시 서비스 지속 |
| 대량 발송 | 배치 분할 + 속도 제한 | APNs/FCM IP 차단 방지 |
댓글