뉴스피드 설계 — Fan-out을 언제 쓰기·읽기 시점으로 할 것인가
이 문제가 어려운 이유는 팔로워 수가 균등하지 않기 때문이다
뉴스피드(또는 타임라인) 설계는 "내가 팔로우하는 사람들의 최신 글을 시간순으로 보여주는" 문제입니다. 개념은 단순한데, 실제로 어려운 이유는 팔로워 분포가 극단적으로 치우쳐 있기 때문입니다. 팔로워 100명인 사용자와 팔로워 5천만 명인 유명인이 같은 시스템 안에 있습니다. 이 편차를 무시하고 하나의 전략으로 밀어붙이면 둘 중 한쪽에서 반드시 문제가 터집니다.
Fan-out on write — 쓰기 시점에 미리 배달한다
가장 직관적인 접근은, 누군가 글을 올리는 순간 그 사람의 모든 팔로워의 피드에 그 글을 미리 넣어두는 겁니다.
def publish_post(author_id, post):
post_id = save_post(post)
followers = get_followers(author_id)
for follower_id in followers:
redis.lpush(f"feed:{follower_id}", post_id) # 각 팔로워의 피드에 미리 삽입
return post_id
def read_feed(user_id, limit=20):
post_ids = redis.lrange(f"feed:{user_id}", 0, limit - 1)
return [get_post(pid) for pid in post_ids]이 방식의 장점은 읽기가 매우 빠르다는 겁니다. 사용자가 피드를 열면 이미 만들어져 있는 목록을 그대로 가져오기만 하면 됩니다. 읽기가 쓰기보다 훨씬 잦은 서비스(뉴스피드가 전형적으로 그렇습니다) 특성상 이건 상당히 매력적인 트레이드오프처럼 보입니다.
문제는 팔로워가 5천만 명인 계정이 글 하나를 올릴 때 드러납니다. 5천만 번의 쓰기가 그 순간 한꺼번에 발생합니다. 이걸 동기로 처리하면 글 작성 자체가 몇 분씩 걸리고, 비동기 큐로 넘겨도(앞서 다룬 메시지 큐가 정확히 이 상황을 위한 것입니다) 워커가 5천만 건을 처리하는 동안 시스템 부하가 크게 튑니다. 이걸 "핫 키 문제" 또는 "셀러브리티 문제"라고 부릅니다.
Fan-out on read — 읽는 시점에 그때그때 모은다
반대 접근은 아무것도 미리 계산해두지 않고, 사용자가 피드를 열 때 그 사람이 팔로우하는 모든 사람의 최신 글을 그 순간 모아서 시간순으로 합치는 겁니다.
def read_feed_on_demand(user_id, limit=20):
following = get_following(user_id)
all_recent_posts = []
for followed_id in following:
all_recent_posts.extend(get_recent_posts(followed_id, limit=limit))
all_recent_posts.sort(key=lambda p: p.created_at, reverse=True)
return all_recent_posts[:limit]글쓰기는 그냥 DB에 한 번 저장하면 끝이라 셀러브리티 문제가 사라집니다. 대신 대가는 읽기로 옮겨갑니다. 팔로우하는 사람이 2천 명인 사용자가 피드를 열 때마다 2천 번의 조회를 즉석에서 수행해야 합니다. 읽기가 쓰기보다 압도적으로 잦다는 이 서비스의 특성을 생각하면, 이 방식은 가장 빈번한 연산을 가장 느리게 만드는 선택입니다.
하이브리드 — 팔로워 수로 전략을 나눈다
두 방식 모두 극단에서 무너지는 걸 보면 답은 이미 나와 있습니다. 대부분의 실제 서비스는 팔로워 수를 기준으로 전략을 나눕니다.
flowchart TD
Post["새 글 작성"] --> Check{"작성자의 팔로워 수가\n임계값을 넘는가?"}
Check -->|아니오, 예: 10만 명 미만| FanoutWrite["Fan-out on write\n팔로워 피드에 즉시 삽입"]
Check -->|예, 셀러브리티| SkipWrite["미리 배달하지 않음\n작성자 타임라인에만 저장"]
Read["피드 조회"] --> Merge["미리 채워진 피드 +\n팔로우 중인 셀러브리티의\n최신 글을 그때 조회해 병합"]
FanoutWrite -.-> Read
SkipWrite -.-> Merge팔로워가 적은 대다수의 사용자는 fan-out on write로 처리해 읽기를 빠르게 유지합니다. 팔로워가 매우 많은 소수의 계정은 미리 배달하지 않고, 사용자가 피드를 열 때 "내가 팔로우하는 셀러브리티들의 최신 글"만 그 순간 조회해서 미리 채워진 피드와 합칩니다. 셀러브리티는 전체 사용자 중 극히 일부이므로, 이 경우에만 발생하는 즉석 조회 비용은 감당할 만한 수준으로 줄어듭니다.
이 하이브리드 전략에서 가장 중요한 건 임계값을 어디에 둘지가 아닙니다(그건 튜닝의 문제입니다). 핵심은 "하나의 전략이 모든 경우에 최선일 수 없다"는 걸 인정하고, 데이터 분포의 편차 자체를 설계 변수로 다뤘다는 점입니다. 이 챕터를 포함해 이 책의 사례 문제들에서 반복해서 강조하는 게 바로 이겁니다 — 실제 트래픽은 균등하지 않고, 그 불균등함을 무시한 설계는 평균적으로는 그럴듯해 보여도 꼬리(tail)에서 반드시 무너집니다.