News / Activity Feed

System Design

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

오픈도어 같은 부동산 마켓에서, 사용자가 팔로우/저장한 집과 동네에서 일어나는 일(가격 인하, 새 매물, 내 오퍼 상태 변경, 투어 예약 확정)을 시간순 피드로 보여줘야 한다. 핵심 어려움은 "한 번의 이벤트(예: 인기 매물 가격 인하)를 그걸 저장한 수만 명의 피드에 어떻게 뿌리느냐(팬아웃)"다. 읽기가 쓰기보다 훨씬 많고, 약간 늦어도(몇 초) 괜찮은 대신 항상 빠르게 보여야 한다.
  1. 1

    요구사항부터 못 박는다. 기능: (1) 사용자가 자기 홈피드를 시간 역순으로 본다 — 저장한 집/동네에서 온 이벤트. (2) 이벤트를 발행한다 — 가격 인하, 새 매물, 내 오퍼 상태 변경, 투어 확정. (3) 무한 스크롤(페이지네이션). 비기능: 읽기가 압도적으로 많은 read-heavy, 피드 로딩은 p99 200ms 이하로 빨라야 한다. 일관성은 강하지 않아도 됨 — 가격 인하가 내 피드에 5초 늦게 떠도 괜찮다(eventual consistency). 가용성 우선. 단, 내 '오퍼 상태' 이벤트는 놓치면 안 된다.

    ⚖️ Trade-off: 강한 일관성을 포기하고 약간의 지연(몇 초)을 받아들이는 대신, 읽기 속도와 가용성을 얻는다. 단, 오퍼 상태 같은 거래 이벤트는 별도로 신뢰성 보장.

    🗣 영어로 말해

    Read-heavy timeline feed; eventual consistency is fine, but feed reads must stay fast.

    checking microphone…

  2. 2

    규모를 숫자로 잡는다. DAU 200만, 한 명이 하루 평균 5번 피드 새로고침 → 읽기 약 1000만/일 ≈ 평균 115 QPS, 피크 ×5 ≈ 600 QPS 읽기. 쓰기(이벤트 발행)는 훨씬 적다: 가격 인하·새 매물·오퍼 변경 합쳐 하루 50만 ≈ 6 QPS, 피크 ×10 ≈ 60 QPS. 피크끼리 600:60 ≈ 10:1, 명백한 read-heavy. 저장: 이벤트 1건 ≈ 300 bytes, 하루 50만 → 150MB/일 ≈ 55GB/년(원본). 팬아웃 후 피드 항목까지 치면 한 매물 가격 인하가 평균 2천 명에게 복사 → 쓰기 증폭이 진짜 비용이다.

    reads  ~600 QPS peak
    writes ~60  QPS peak  (events published)
    fanout: 1 popular price-drop -> ~2,000 feed inserts
    🗣 영어로 말해

    About 600 read QPS at peak versus 60 write QPS — roughly 10 to 1, clearly read-heavy.

    checking microphone…

  3. 3

    API와 데이터 모델. 엔드포인트는 두 개가 핵심: 피드 읽기와 이벤트 발행. 데이터는 세 테이블. (1) follows: 누가 무엇을(home/neighborhood) 팔로우하는지 — 팬아웃 대상 찾기용. (2) activity: 원본 이벤트 한 번만 저장(source of truth). (3) feed: 사용자별로 미리 만들어둔 타임라인(팬아웃-온-라이트 결과). feed의 파티션 키는 user_id, 정렬 키는 created_at 내림차순 — '한 유저의 최신 N개'를 한 파티션에서 한 번에 읽기 위해. 커서 기반 페이지네이션(created_at + event_id)을 써서 offset의 스킵 비용을 피한다.

    🔧 도구:CassandraDynamoDBconsistent hashing

    GET  /feed?cursor=<ts>_<eventId>&limit=20
    POST /events  {type:"PRICE_DROP", homeId, payload}
    
    -- feed table (Cassandra/Dynamo)
    PK = user_id            -- partition: one user's timeline
    SK = created_at DESC, event_id
      type, home_id, payload, ttl
    🗣 영어로 말해

    Two endpoints: get my feed and publish an event; feed table is partitioned by user with time as sort key.

    checking microphone…

  4. 4

    상위 설계 — 요청 흐름. 쓰기 경로: 클라이언트 → LB → Event 서비스 → activity 테이블에 원본 1건 저장(source of truth) → Kafka에 이벤트 발행. Fan-out 워커가 Kafka를 소비 → follows에서 '이 집/동네를 저장한 유저 목록'을 찾아 → 각 유저의 feed 파티션에 항목을 INSERT. 읽기 경로: 클라이언트 → LB → Feed 서비스 → Redis 캐시(상위 50개 피드 캐싱) 확인 → 없으면 feed 테이블에서 user_id 파티션 한 번 읽기 → 반환. 읽기는 미리 만들어둔 걸 그냥 긁어오므로 빠르다.

    🔧 도구:KafkaRedisload balancer

    publish:  client -> Event svc -> activity(write once) -> Kafka
    fan-out:  Kafka -> worker -> follows lookup -> feed INSERT (per follower)
    read:     client -> Feed svc -> Redis(top 50) | feed partition
    🗣 영어로 말해

    Write path publishes to Kafka; fan-out workers precompute each follower's feed; reads just scan one partition.

    checking microphone…

  5. 5

    딥다이브 #1 — 팬아웃 전략(가장 점수 나는 곳). 기본은 팬아웃-온-라이트: 이벤트가 발행될 때 미리 모든 팔로워 피드에 써둔다 → 읽기가 매우 빠름. 문제는 핫키/셀럽 문제: '오픈도어 인기 매물'이나 '한 동네 전체'를 5만 명이 저장했는데 가격이 내려가면, 한 번의 쓰기가 5만 번의 피드 INSERT로 폭발 → 쓰기 증폭·지연 폭증.

    ⚖️ Trade-off: 하이브리드는 읽기 경로가 복잡해진다(두 소스 머지 + 정렬). 대신 핫키 쓰기 폭발을 막는다. 임계치 튜닝이 운영 포인트.

    ✅ Fix: 하이브리드로 푼다. 일반 매물(팔로워 적음)은 팬아웃-온-라이트로 미리 써둔다. 임계치(예: 팔로워 1만 이상)를 넘는 '핫' 매물·동네는 팬아웃-온-라이트를 건너뛰고, 그 이벤트는 따로 hot_events에 둔다. 사용자가 피드를 읽을 때 = (미리 만든 내 feed) ∪ (내가 팔로우한 핫 매물들의 최신 hot_events) 를 읽는 시점에 머지(팬아웃-온-리드). 이러면 5만 번의 쓰기 폭발을 피하고, 핫 이벤트는 읽을 때 소수만 머지하면 된다.

    🔧 도구:Kafkaconsistent hashing

    🗣 영어로 말해

    Pure fan-out-on-write explodes when a hot listing has fifty thousand followers.

    checking microphone…

  6. 6

    딥다이브 #2 — 핫 피드 읽기 캐싱과 신뢰성. 활성 유저(전체의 ~5%)가 읽기의 대부분을 차지하므로, 그들의 상위 50개 피드를 Redis 리스트로 캐싱 → DB 히트를 크게 줄임. 캐시는 fan-out 워커가 feed INSERT할 때 같이 LPUSH + LTRIM(50)로 갱신, TTL로 콜드 유저는 자연 만료. 신뢰성: activity 테이블에 원본을 먼저 commit한 뒤 Kafka 발행을 보장하려면 Outbox 패턴(같은 트랜잭션에 outbox row 기록 → CDC가 Kafka로) — 그래야 'DB엔 썼는데 이벤트 유실'을 막는다. 워커는 at-least-once라 event_id로 멱등(dedup)하게 INSERT.

    ⚖️ Trade-off: 캐시는 메모리 비용과 무효화 복잡도를 더한다. Outbox는 발행 지연(몇 초)을 더하지만 이벤트 유실 0을 보장한다.

    ✅ Fix: Outbox + CDC로 'DB 커밋'과 '이벤트 발행'을 원자적으로 묶고, 워커는 (user_id, event_id) 멱등 키로 중복 INSERT를 무시한다. Redis는 write-through로 갱신하고 TTL로 콜드 유저 정리.

    🔧 도구:RedisCDCKafkaoutbox

    🗣 영어로 말해

    Cache hot users' top fifty in Redis, and use an outbox so no published event is ever lost.

    checking microphone…

  7. 7

    트레이드오프 + 모니터링. 포기한 것: 강한 일관성(피드는 eventual, 가격 인하가 몇 초 늦게 뜸), 그리고 하이브리드 때문에 읽기 경로의 단순함. 얻은 것: 빠른 읽기(미리 계산), 핫키 안정성. 모니터링할 것: (1) fan-out 워커 Kafka consumer lag — 피드 신선도의 직접 지표. (2) 피드 읽기 p99 지연과 Redis 히트율. (3) 핫 매물 임계치 근처의 이벤트 수 — 임계치 튜닝 신호. (4) DLQ 깊이 — 멱등 INSERT 실패가 쌓이는지. (5) 오퍼 상태 같은 거래 이벤트는 별도 신뢰성 알람(유실 0이어야 함).

    ⚖️ Trade-off: eventual consistency와 읽기 경로 복잡도를 내주고, 빠른 읽기와 핫키 안정성을 얻었다. 핵심 감시 지표는 fan-out consumer lag = 피드가 얼마나 최신인지.

    🗣 영어로 말해

    I gave up strong consistency for speed; I'd watch consumer lag, feed p99, and DLQ depth.

    checking microphone…

7단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

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