백엔드 데이터 인프라 64편 — Twitter Clone 패턴 + Fanout 전략

2026-05-17백엔드 데이터 인프라

백엔드 데이터 인프라 64편. Redis Twitter Clone 패턴 — 사용자(Hash)·팔로워(Set)·타임라인(Sorted Set) 자료구조 조합으로 소셜 서비스 핵심 기능 풀어내기. Fanout-on-write vs Fanout-on-read 트레이드오프, 셀럽 계정 하이브리드 전략까지 풀어쓴 학습 노트.

📚 백엔드 데이터 인프라 · 64편 — Twitter Clone 패턴 + Fanout 전략

이 글은 백엔드 데이터 인프라 시리즈 130편 중 64편이에요. 63편 까지 단일 패턴들을 풀어 봤다면, 이번 64편은 여러 자료구조를 조합해 소셜 서비스를 통째로 풀어내는 클래식 예제 — Twitter Clone 패턴. Redis 공식 문서의 오래된 클래식 튜토리얼 이지만, 현대 서비스에도 그대로 적용 가능.

Twitter Clone 패턴이 어렵게 느껴지는 이유

단일 자료구조는 쉬워도 여러 자료구조를 조합해 시스템 한 덩어리 를 풀어내는 건 처음 보면 막막해요.

첫째, Fanout(한 글을 여러 받은편지함에 뿌리기) 개념이 안 잡힙니다. "한 사람이 글 올리면 그 사람을 팔로우하는 모든 사람의 피드에 박힌다" 는 동작이 "어떻게 효율적으로?" 가 안 보여요.

둘째, 셀럽 계정 (5,000만 팔로워) 처리 가 압도적인 문제. fanout-on-write 면 한 트윗에 5,000만 번 write 가 필요해요. 이걸 어떻게 풀까가 실제 Twitter·Instagram 같은 회사의 핵심 엔지니어링 문제.

이 글에서 데이터 모델·핵심 5가지 동작·Fanout 두 전략·셀럽 하이브리드까지 한 덩어리로 정리.

데이터 모델 — 4가지 키 패턴

키 패턴 자료구조 용도
user:{uid} Hash 사용자 프로필 (name·email·bio·created_at)
followers:{uid} Set 이 사람을 팔로우하는 사용자 ID 집합
following:{uid} Set 이 사람이 팔로우하는 사용자 ID 집합
tweet:{tid} Hash 트윗 본문 (text·author·timestamp·likes)
user_timeline:{uid} Sorted Set 이 사람이 작성한 트윗 (timestamp 정렬)
home_timeline:{uid} Sorted Set 이 사람의 피드 (팔로잉의 트윗)
global_timeline Sorted Set 전체 글로벌 피드

핵심 — 사용자·트윗 본문은 Hash, 관계는 Set, 시간 순서가 의미 있는 모든 것은 Sorted Set.

동작 1: 회원가입 + 프로필

> HSET user:42 username "alice" name "Alice Kim" email "alice@..." created_at 1747475123
(integer) 4

단순 Hash 한 줄. HINCRBY user:42 followers_count 1 같이 집계 카운터 도 한 Hash 안에 같이 두면 조회 효율 좋음.

동작 2: 팔로우 / 언팔로우

# user42가 user99를 팔로우
> SADD following:42 user99
> SADD followers:99 user42

# 언팔로우
> SREM following:42 user99
> SREM followers:99 user42

양방향 Set내가 누구를 팔로우 + 누가 나를 팔로우. 둘 다 Set 멤버십 체크 O(1).

활용:

# user42와 user99 의 공통 팔로잉 (추천)
> SINTER following:42 following:99
# user42 가 모르는 user99 의 팔로잉
> SDIFF following:99 following:42
# user99 가 user42 를 팔로우하나?
> SISMEMBER followers:42 user99

여기서 시험 함정이 하나 있어요 — 팔로우·언팔로우는 두 명령 atomic 보장 필요. 한 명령만 성공하면 불일치 상태. MULTI/EXEC 또는 Lua script 로 묶음:

redis.call('SADD', 'following:' .. ARGV[1], ARGV[2])
redis.call('SADD', 'followers:' .. ARGV[2], ARGV[1])
return 1

동작 3: 트윗 작성

# 1. 트윗 본문 저장
> HSET tweet:1001 text "Hello world" author "user42" timestamp 1747475123 likes 0
(integer) 4

# 2. 작성자 본인의 타임라인에 추가
> ZADD user_timeline:42 1747475123 1001

# 3. 글로벌 타임라인 (선택)
> ZADD global_timeline 1747475123 1001
> ZREMRANGEBYRANK global_timeline 0 -10001    # 최근 1만 건만 유지

# 4. Fanout — 팔로워들의 home_timeline 에 박기 (아래 깊이)

ZADD 의 score = timestamp → 시간 순서 자동 정렬. ZREVRANGE 한 줄로 최근 N 개 조회.

동작 4: 타임라인 조회 (Home Feed)

# 최근 20개 트윗 ID
> ZREVRANGE home_timeline:42 0 19
1) "1005"
2) "1003"
3) "1001"
...

# 각 트윗 본문 가져오기 (Pipelining 권장)
pipe = r.pipeline(transaction=False)
for tid in tweet_ids:
    pipe.hgetall(f"tweet:{tid}")
results = pipe.execute()

핵심 효율 — home_timeline 이 미리 만들어져 있으면 피드 조회는 O(log N + 20) 의 빠른 작업. 팔로우한 사람들의 트윗을 매번 JOIN 하지 않아도 됨.

동작 5: Fanout — 두 가지 전략

가장 핵심 결정 — "트윗을 어떻게 팔로워들에게 전달하나".

Fanout-on-Write (Push 모델)

def post_tweet(uid, text):
    tid = next_tweet_id()
    timestamp = int(time.time())
    r.hset(f"tweet:{tid}", mapping={"text": text, "author": uid, "timestamp": timestamp})
    r.zadd(f"user_timeline:{uid}", {tid: timestamp})

    # 모든 팔로워의 home_timeline 에 push
    followers = r.smembers(f"followers:{uid}")
    pipe = r.pipeline(transaction=False)
    for fid in followers:
        pipe.zadd(f"home_timeline:{fid}", {tid: timestamp})
        pipe.zremrangebyrank(f"home_timeline:{fid}", 0, -801)   # 최근 800개 유지
    pipe.execute()

장점은 피드 조회가 매우 빠르다는 점 — 이미 만들어져 있으니 ZREVRANGE 한 줄로 끝. 단점은 트윗 작성 시 write가 N배로 늘어난다는 점 — 팔로워 1만 명이면 ZADD 1만 번.

Fanout-on-Read (Pull 모델)

def get_home_timeline(uid, limit=20):
    following = r.smembers(f"following:{uid}")
    pipe = r.pipeline(transaction=False)
    for target in following:
        pipe.zrevrange(f"user_timeline:{target}", 0, limit-1, withscores=True)
    all_tweets = pipe.execute()

    # 메모리에서 timestamp 순 merge + top N
    merged = sorted(
        [(tid, ts) for tweets in all_tweets for tid, ts in tweets],
        key=lambda x: -x[1]
    )[:limit]
    return merged

장점은 트윗 작성이 매우 빠르다는 점 — 자기 timeline에 한 번 ZADD 하면 끝. 단점은 피드 조회가 비싸진다는 점 — 팔로잉 N명이면 ZREVRANGE N개에 merge까지.

선택 가이드

사용자 패턴 권장
읽기 ≫ 쓰기 (대부분의 일반 사용자) Fanout-on-Write
쓰기 ≫ 읽기 (스팸 / IoT 데이터) Fanout-on-Read
양쪽 다 많음 Hybrid (아래)

셀럽 계정 — Hybrid 전략

여기가 정말 중요한 자리. 팔로워 5,000만 명 인 셀럽 트윗 = 5,000만 ZADD = 현실적 불가능.

하이브리드 모델

일반 사용자(팔로워 < N명)는 Fanout-on-Write로 쓰고, 셀럽 사용자는 Fanout-on-Read로 따로 처리. 피드 조회 시점에 push 받은 일반 사용자 트윗과 pull로 가져온 셀럽 트윗을 메모리에서 merge.

CELEB_THRESHOLD = 100_000

def post_tweet(uid, text):
    tid = next_tweet_id()
    timestamp = int(time.time())
    r.hset(f"tweet:{tid}", ...)
    r.zadd(f"user_timeline:{uid}", {tid: timestamp})

    followers_count = r.scard(f"followers:{uid}")
    if followers_count < CELEB_THRESHOLD:
        # 일반 사용자 — fanout-on-write
        followers = r.smembers(f"followers:{uid}")
        pipe = r.pipeline(transaction=False)
        for fid in followers:
            pipe.zadd(f"home_timeline:{fid}", {tid: timestamp})
        pipe.execute()
    # 셀럽이면 push 안 함

def get_home_timeline(uid, limit=20):
    # 1. 일반 사용자 트윗 (이미 push 된 것)
    pushed = r.zrevrange(f"home_timeline:{uid}", 0, limit-1, withscores=True)

    # 2. 셀럽 트윗 (pull)
    following = r.smembers(f"following:{uid}")
    celeb_following = [u for u in following if is_celeb(u)]
    pipe = r.pipeline(transaction=False)
    for c in celeb_following:
        pipe.zrevrange(f"user_timeline:{c}", 0, limit-1, withscores=True)
    pulled = sum(pipe.execute(), [])

    # 3. merge + top N
    merged = sorted(pushed + pulled, key=lambda x: -x[1])[:limit]
    return merged

이 모델이 실제 Twitter·Instagram·Facebook 등이 채택한 패턴 (이름은 fanout-on-write with celebrity exception).

추가 패턴 — 검색·추천·통계

검색

Set 으로 키워드 인덱스:

> SADD search:hashtag:redis 1001 1003 1005
> SADD search:hashtag:database 1003 1007
> SINTER search:hashtag:redis search:hashtag:database
1) "1003"

본격 검색은 RediSearch(Redis 공식 전문검색 모듈, 75편) 또는 외부 Elasticsearch.

추천 — 공통 친구 / 공통 관심사

# user42가 모르는 user99의 팔로잉
> SDIFF following:99 following:42
# user42 와 user99 의 공통 팔로잉 (관심사 유사)
> SINTER following:42 following:99

인기 트윗 / Trending

> ZINCRBY trending:tweets 1 tweet:1003
> ZREVRANGE trending:tweets 0 9 WITHSCORES
# 만료
> EXPIRE trending:tweets 3600

트윗 좋아요·리트윗 카운터

> HINCRBY tweet:1003 likes 1
> SADD liked_by:1003 user42        # 중복 좋아요 방지
> SCARD liked_by:1003               # 좋아요 수

한계·실무 함정

1. home_timeline 크기 제한

무한 증가하면 메모리 폭증. 최근 N개 유지 (ZREMRANGEBYRANK) 필수. 더 오래된 글은 DB 에서 페이징.

2. 팔로워 변경 시 home_timeline 정리

A 가 B 를 unfollow 했는데 B 의 트윗이 A 의 home_timeline 에 남음. 옵션은 두 가지. 백그라운드 cleanup으로 주기적으로 내가 follow 안 하는 트윗을 제거하거나, 읽기 시 필터링으로 home_timeline을 가져온 뒤 현재 following으로 필터를 거는 방식.

3. 트윗 삭제 — 모든 home_timeline 에서 제거

원래 작성자의 팔로워 N명 각각의 home_timeline 에서 해당 트윗을 제거해야 함. fanout-on-write 의 반대 작업. 비용 큼.

4. Hot Key — 셀럽 데이터

user:elon 같은 셀럽 키에 초당 수만 요청. 읽기 캐시 + Cluster 의 hash tag 분산 검토.

시험 직전 한 번 더 — Twitter Clone 함정 압축 노트

  • 데이터 모델 = Hash (프로필·트윗) · Set (관계) · Sorted Set (타임라인)
  • 팔로우 = 양방향 Set (following: + followers:)
  • 팔로우/언팔로우 = 두 명령 atomic (MULTI/EXEC 또는 Lua)
  • 트윗 = Hash 본문 + ZADD 타임라인
  • 타임라인 score = timestamp → 시간 순서 자동
  • 피드 조회 = ZREVRANGE home_timeline 0 N
  • Fanout-on-Write = 작성 시 모든 팔로워 home_timeline 에 push
  • 장점 = 피드 조회 빠름 / 단점 = 작성 시 N개 write
  • Fanout-on-Read = 작성 시 자기만 저장, 조회 시 팔로잉 N개 merge
  • 장점 = 작성 빠름 / 단점 = 조회 비쌈
  • 셀럽 하이브리드 = 일반=Write, 셀럽=Read, 조회 시 merge
  • 실제 Twitter·Instagram 채택 패턴
  • home_timeline = 무한 증가 방지, 최근 N개 유지 (ZREMRANGEBYRANK)
  • 언팔로우 후 home_timeline 정리 = 백그라운드 cleanup 또는 읽기 시 필터
  • 트윗 삭제 = 모든 팔로워 home_timeline 에서 제거 (비용 큼)
  • 검색 = Set 키워드 인덱스 (간단) / RediSearch (본격, 75편)
  • 추천 = SINTER following:A following:B (공통 관심사)
  • Trending = ZINCRBY trending:tweets 1 tweet:id + EXPIRE
  • 좋아요 = HINCRBY tweet:id likes 1 + SADD liked_by:id user (중복 방지)
  • Hot Key = 셀럽 데이터, 읽기 캐시 + 분산
  • Pipelining 으로 다중 trip 작업 최적화

공식 문서: Redis Twitter Clone Pattern 에서 자세한 사양과 변형을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!