본문으로 바로가기

뉴스피드 설계 — 트위터 타임라인 팬아웃

트윗 한 건이 TFE→큐→Fanout Daemon을 지나 팔로워들의 Redis 타임라인에 미리 꽂히는 fan-out-on-write, 그리고 그 규칙이 깨지는 세 지점(유명인·비활성 사용자·머신 사망)을 21단계로 따라간다.

1 / 21사용자가 트윗을 작성합니다. 로드 밸런서를 지나 TFE(Twitter Front End)가 받고, Ruby/Unicorn 워커가 약 145ms 안에 수집을 끝낸 뒤 클라이언트 연결을 닫습니다.클라이언트는 팬아웃이 끝나길 기다리지 않습니다 — 이후는 전부 비동기입니다.
쓰기 경로 (fan-out-on-write)Redis 타임라인 클러스터 (RAM)읽기 경로 (hydration)영속 저장 (디스크)사용자활성 사용자 150M유명인 계정@ladygaga · 3,100만 팔로워TFETwitter Front End · Ruby/Unicorn비동기 큐쓰기·후속 처리 분리Fanout Daemonfan-out-on-writeFlock소셜 그래프 서비스Redis 머신 A타임라인 = ID 리스트Redis 머신 B복제본 2Redis 머신 C복제본 3Timeline Service해시링 O(1) 레플리카 선택T-bird / Tweetypie트윗 본문 + memcacheGizmoduck사용자 서비스 + memcacheGizzard샤딩된 MySQLPOST tweet
단계 목록 · 키보드 ←/→ 이동, Space 재생

출처: High Scalability — The Architecture Twitter Uses to Deal with 150M Active Users · InfoQ — Timelines at Scale (Raffi Krikorian, QCon)

읽기가 쓰기보다 약 75배 많다면, 타임라인 조립이라는 비싼 일을 드문 쓰기 시점으로 옮겨라 — 그게 fan-out-on-write이고, 트위터 홈 타임라인은 요청 순간이 아니라 트윗 순간에 이미 만들어져 있다.

왜 헷갈리는가

'타임라인은 읽을 때 팔로잉들의 트윗을 모아 정렬하는 것'이라는 직관이 흔한 오해다. 150M 사용자·300K QPS 규모에서 그 방식은 읽기마다 소셜 그래프 조회 + 수백 계정 스캔이 되어 버틸 수 없다. 트위터는 반대로 트윗이 써질 때 팔로워 전원의 Redis 리스트에 트윗 ID를 미리 꽂아 두고, 읽기는 리스트 하나를 O(1)로 집어오게 했다. 또 하나의 함정: Redis에는 본문이 없다. ID뿐인 리스트를 T-bird(본문)와 Gizmoduck(사용자)으로 채우는 hydration이 읽기의 후반부다.

애니메이션이 보여주는 것

쓰기 경로부터 시작한다 — TFE가 약 145ms에 수집을 끝내고 연결을 닫으면, 비동기 큐 뒤에서 Fanout Daemon이 Flock에 팔로워 목록을 물어 각 팔로워의 Redis 타임라인에 트윗 ID를 삽입한다. 팔로워 2만이면 삽입 2만 건, 한 번에 약 4천 목적지씩 파이프라인, 각 타임라인은 3대 머신에 3중 복제. 목표는 5초 이내(p99 최대 5분)다.

이어서 규칙이 깨지는 세 국면을 본다. 3,100만 팔로워의 유명인은 쓰기 팬아웃을 생략하고 읽기 시점에 병합하는 하이브리드로, 30일 이상 미접속 사용자는 Redis에 타임라인이 없어 Flock+디스크(Gizzard/MySQL)로 재구성하는 느린 첫 로드로, Redis 머신 사망은 3중 복제의 나머지 2대와 해시링 제외로 각각 처리된다.

왜 쓰기 시점에 뿌리는가 — 그리고 왜 ID만인가

읽기:쓰기가 약 75:1이다. 같은 일을 해야 한다면 75배 드문 쪽에서 하는 것이 총비용이 싸다. 쓰기 한 건이 O(팔로워 수)로 비싸지는 대신, 압도적 다수인 읽기가 미리 계산된 리스트 한 번 조회(중앙값 5ms)로 끝난다.

리스트에 본문을 복사하지 않는 이유는 두 가지다. 팔로워 수만큼 본문이 복제되면 RAM이 폭증하고, 트윗이 수정·삭제될 때 모든 사본을 고쳐야 한다. ID만 두면 공간은 트윗당 몇 바이트(트윗 ID + 작성자 ID + 4바이트 플래그)이고, 본문의 진실은 T-bird 한 곳이라 삭제가 즉시 모든 타임라인에 반영된다. 그 대가가 읽기 때의 hydration(API 약 400ms)이다.

  • 타임라인 리스트는 최대 800개 엔트리로 trim — RAM 상한을 예측 가능하게
  • 3중 복제 덕에 읽기는 '가장 빠른 레플리카 선택'이 곧 장애 우회
  • 비활성 사용자는 RAM에 안 두고, 돌아오면 디스크에서 재구성 — 첫 로드만 느리다

유명인 문제 — 하나의 전략은 모든 규모를 감당하지 못한다

fan-out-on-write의 비용은 팔로워 수에 비례한다. @ladygaga(당시 3,100만 팔로워)의 트윗 한 건은 삽입 수천만 건이 되어 5초 목표를 넘기고, 팔로워마다 도착 시각이 달라져 답글이 원본보다 먼저 보이는 순서 역전까지 일으켰다.

해법은 전략 교체가 아니라 하이브리드다. 일반 계정은 그대로 쓰기 팬아웃, 초대형 계정만 팬아웃을 생략하고 읽기 시점에 Redis 리스트와 병합(merge at read time)한다. 읽기 병합 비용은 '타임라인당 팔로우한 유명인 몇 명'이라 작고, 쓰기 폭발은 사라진다. 규모에 따라 비용이 역전되는 지점에 예외를 두는 것 — 이것이 이 설계의 진짜 교훈이다.

기억할 것

  • 읽기 75 : 쓰기 1 — 비싼 연산(타임라인 조립)을 드문 쓰기 시점으로 옮긴 것이 fan-out-on-write다.
  • Redis에는 트윗 ID + 작성자 ID + 4바이트 플래그만, 최대 800개 — 본문은 읽기 때 hydration으로 채운다.
  • 각 타임라인은 3대 머신에 3중 복제 — 머신 1대 사망은 해시링 제외 + 나머지 2대 읽기로 흡수된다.
  • 유명인은 쓰기 팬아웃 생략 후 read-time 병합 — 하나의 전략은 모든 팔로워 규모를 감당하지 못한다.
  • 30일 미접속 사용자는 캐시 미스 → Flock 팔로잉 조회 + 디스크 재구성 — 평시 O(1) 읽기는 팬아웃이 미리 일해 둔 결과다.
뉴스피드 설계 — 트위터 타임라인 팬아웃 | KIE