Notification System

System Design

Understand it step by step (한국어로 이해 → 영어로 말하기)

Opendoor 같은 부동산 마켓플레이스에서 사용자에게 알림을 보내는 시스템을 설계한다. "새 오퍼 도착", "가격 변동", "거래 단계 변경" 같은 이벤트가 생기면 푸시, 이메일, SMS로 알림을 보낸다. 핵심은 절대 같은 알림을 두 번 보내지 않으면서(중복 방지), 트래픽이 몰려도 안 죽고, 발송 지연을 짧게 유지하는 것이다.
  1. 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. 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. 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. 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. 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. 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단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

🎙 이 카드 AI랑 음성 대화 · 기록 저장