Understand it step by step (한국어로 이해 → 영어로 말하기)
- 1
기능 요구사항은 세 가지로 좁힌다. (1) 이벤트가 들어오면 알림을 만들어 보낸다 (오퍼 도착, 가격 변동, 거래 단계 변경). (2) 채널은 푸시/이메일/SMS 세 개를 지원한다. (3) 사용자가 채널별로 끄고 켤 수 있다 (preference). 비기능은: 같은 알림 중복 발송 금지(at-least-once 전송이라 멱등성 필수), 발송 지연 p99 5초 이내, 가용성 우선(알림이 1~2초 늦어도 되지만 시스템이 죽으면 안 됨). 일관성은 약해도 됨 — 알림은 약간 늦거나 순서가 바뀌어도 괜찮다.
⚖️ Trade-off: 강한 일관성(정확히 한 번, 순서 보장)을 포기하고 가용성과 처리량을 택한다. 대신 멱등성으로 중복만 막는다.
🔧 도구:멱등성 키at-least-once
🗣 영어로 말해“Functional: trigger, deliver across push, email, SMS, and respect per-channel user preferences.”
checking microphone…
- 2
규모를 대충 잡는다. 사용자 1000만, 하루에 사용자당 알림 2개면 하루 2000만 건. 평균 QPS = 2000만 / 86400초 ≈ 230 QPS. 피크는 평균의 약 10배로 잡아 ~2,300 QPS (오전에 가격 업데이트가 한꺼번에 나가는 패턴). 읽기:쓰기 비율은 알림 발송(쓰기) 위주라 거의 쓰기 중심. 알림 한 건 메타데이터 ~1KB면 하루 20GB, 90일 보관 시 약 1.8TB. 이 숫자가 '큐로 피크를 흡수해야 한다'와 '발송 기록은 Cassandra/DynamoDB 같은 쓰기 친화 스토어'라는 결정을 정당화한다.
🔧 도구:QPS 추정CassandraDynamoDB
avg QPS = 20M / 86400 ≈ 230 peak ≈ 230 × 10 = 2,300 QPS storage = 20M × 1KB × 90d ≈ 1.8TB
🗣 영어로 말해“Around 2,300 peak QPS and roughly 1.8 terabytes over ninety days, write-heavy.”
checking microphone…
- 3
API는 두 개가 핵심이다. POST /v1/notifications 로 이벤트를 받아 알림을 큐에 넣는다 (멱등성 키 헤더 필수). GET /v1/notifications/{userId} 로 인앱 알림함을 읽는다. 데이터 모델은 두 테이블. notifications 는 파티션 키 = user_id, 클러스터링 키 = created_at DESC 로 잡아 '한 유저의 최신 알림'을 한 파티션에서 빠르게 읽는다. preferences 는 파티션 키 = user_id 로 채널별 on/off를 저장. 멱등성을 위해 dedup_key (event_id + channel) 에 유니크 제약을 건다.
🔧 도구:DynamoDBCassandra파티션 키멱등성 키
POST /v1/notifications Header: Idempotency-Key: <event_id> { "userId": "u123", "type": "OFFER_RECEIVED", "channels": ["push","email"] } TABLE notifications ( user_id PARTITION KEY, created_at CLUSTERING DESC, notif_id, type, channel, status, dedup_key UNIQUE -- event_id + channel )🗣 영어로 말해“Partition by user_id, cluster by created_at descending, and dedupe on event_id plus channel.”
checking microphone…
- 4
상위 흐름은 이렇다. 다른 서비스(오퍼 서비스, 가격 서비스)가 이벤트를 발행 → Notification Service API가 받아서 멱등성 체크 후 Kafka 토픽에 넣는다. Worker들이 토픽을 컨슈밍한다. Worker는 먼저 preference를 확인(Redis 캐시, 미스 시 DB)하고, 사용자가 끈 채널은 거른다. 남은 채널마다 발송 기록을 DB에 쓰고, 실제 발송은 채널별 어댑터(APNs/FCM 푸시, SES 이메일, Twilio SMS)에 넘긴다. API와 발송을 큐로 분리했기 때문에 피크 2,300 QPS가 와도 큐에 쌓일 뿐 API는 항상 빠르게 응답한다.
🔧 도구:KafkaRedisAPNs/FCMSESTwilio
Service -> API -> Kafka -> Worker (idempotency) | Worker: check pref (Redis) -> SES / Twilio / FCM🗣 영어로 말해“Producer to API to Kafka to workers, which check preferences then fan out per channel.”
checking microphone…
- 5
딥다이브 1 — 중복 방지(멱등성). 카프카는 at-least-once라 같은 메시지를 두 번 줄 수 있고, 외부 발송 API(Twilio 등) 호출 후 ack 전에 워커가 죽으면 재시도 때 또 보낸다. 해결: dedup_key(event_id+channel)에 유니크 제약을 걸고, '발송 기록 INSERT'를 발송 시도 전에 먼저 한다. INSERT가 충돌(이미 있음)하면 이미 처리된 것이니 그냥 스킵. 발송 기록 쓰기와 상태 갱신을 한 트랜잭션으로 묶는다. 딥다이브 2 — hot key / fan-out. 인기 매물 가격이 떨어지면 수만 명에게 동시에 알림이 나간다(fan-out 폭주). 한 파티션에 몰리지 않게 user_id로 샤딩하고, 대량 발송은 배치 이벤트 하나를 워커들이 user_id 범위로 나눠 병렬 처리한다.
⚖️ Trade-off: 멱등성 INSERT를 발송 경로마다 하나 더 두는 비용(쓰기 1번 추가)을 내고, 재시도 상황에서 중복 발송이 사라진다. fan-out은 약간의 지연 분산을 받아들이고 hot partition 폭발을 피한다.
✅ Fix: 발송 전에 dedup 행을 먼저 INSERT → 충돌이면 스킵, 성공이면 발송. 이렇게 하면 재시도가 와도 정확히 한 번만 실제 발송된다. fan-out은 user_id 샤딩 + 워커 병렬화로 분산한다.
🔧 도구:멱등성 키유니크 제약Kafka at-least-once샤딩consistent hashing
INSERT INTO sends(dedup_key) VALUES (?) ON CONFLICT DO NOTHING; -- 0 rows = already sent, skip if inserted: provider.send(...) -- exactly one real send🗣 영어로 말해“I insert the dedup row before sending, so a retry collides and skips instead of double-sending.”
checking microphone…
- 6
포기한 것: 강한 일관성과 엄격한 순서. 같은 사용자에게 '가격 내림'이 '가격 오름'보다 먼저 도착할 수 있다 — 알림에선 보통 허용. 정확히 한 번이 아니라 '최소 한 번 + 멱등 dedup'이라, 극단적 경합에선 드물게 한 번 더 시도될 수 있지만 dedup이 막는다. 외부 발송사(Twilio/SES) 장애는 우리가 통제 못 하니 재시도 + DLQ로 받는다. 모니터링은: Kafka consumer lag(워커가 밀리는지), 발송 성공률을 채널별로, p99 발송 지연, DLQ 깊이(반복 실패한 알림), 그리고 채널별 provider 에러율(Twilio 5xx 급증 = 알림). lag이나 DLQ가 튀면 바로 알람.
⚖️ Trade-off: 강한 일관성/순서 보장과 '정확히 한 번'을 포기하고 가용성·처리량·운영 단순성을 얻었다. 대가는 드문 재시도와 약한 순서지만, 알림 도메인에선 받아들일 만하다.
🔧 도구:Kafka consumer lagDLQp99 지연프로바이더 에러율
🗣 영어로 말해“I gave up strict ordering and exactly-once; I watch consumer lag, DLQ depth, and per-channel delivery rate.”
checking microphone…
6단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.