Facebook 스타일 뉴스 피드 설계: fan-out on write vs on read

1. 개요

뉴스 피드는 팔로우하는 사람들의 최신 글을 보는 홈페이지입니다. 이를 설계할 때의 핵심 질문은: 쓸 때 피드를 생성할지(fan-out on write) 읽을 때 생성할지(fan-out on read)? 입니다.

이 글은 완전한 사례 연구를 거쳐갑니다. 요구사항과 규모를 추정한 다음 데이터 모델과 API를 설계합니다. 그 다음 두 fan-out 전략을 비교하고 하이브리드 모델을 선택합니다. 마지막으로 셀러브리티, 일관성, 트래픽 급증 같은 어려운 점들을 다룹니다. 글을 읽은 후 Spring Boot, PostgreSQL, Redis, Kafka를 사용해 작동하는 피드를 직접 구축할 수 있습니다.

2. 요구사항

2.1. 기능 요구사항

  • 사용자가 글을 올림 (텍스트, 이미지 포함 가능).
  • 사용자가 다른 사람을 팔로우/언팔로우합니다.
  • 사용자가 피드를 봄: 팔로우하는 사람의 글, 최신순, 페이지네이션 포함 (무한 스크롤).

2.2. 규모와 제약

다음 수치를 가정합니다:

DAU (daily active users)        : 3억
하루에 피드 열기 (사용자당)      : 10회  -> 일 30억 조회 ≈ 35K QPS, peak ~100K QPS
하루에 글 작성 (사용자당)        : 0.5   -> 일 1.5억 글 ≈ 1.7K QPS, peak ~5K QPS
평균 팔로워 수                   : 300
가장 큰 계정                     : > 5천만 팔로워

여기서 몇 가지 제약을 도출합니다:

  • 읽기가 쓰기보다 훨씬 많음 (약 20배). 피드 읽기는 빨라야 하며, p99가 200ms 이하여야 합니다.
  • 최종 일관성 허용: 새 글이 팔로워의 피드에 몇 초 후 나타나는 것이 괜찮습니다.
  • 팔로워 분포가 매우 치우침. 대부분은 수백 명, 극소수는 수천만 명입니다.

3. 데이터 모델

진실의 원천은 PostgreSQL에 있습니다:

CREATE TABLE users (
    id              BIGINT PRIMARY KEY,
    name            TEXT   NOT NULL,
    follower_count  BIGINT NOT NULL DEFAULT 0
);

CREATE TABLE posts (
    id          BIGINT PRIMARY KEY,           -- Snowflake ID
    author_id   BIGINT      NOT NULL,
    content     TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted     BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_posts_author_time ON posts (author_id, created_at DESC);

CREATE TABLE follows (
    follower_id BIGINT NOT NULL,
    followee_id BIGINT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX idx_follows_followee ON follows (followee_id, follower_id);
  • idx_posts_author_time 인덱스는 “작가 X의 최신 N개 글 가져오기”를 처리합니다.
  • follows 테이블의 기본 키는 “내가 누구를 팔로우하는가”를 답합니다. idx_follows_followee 인덱스는 “누가 나를 팔로우하는가”를 답하며, fan-out이 바로 이 쿼리가 필요합니다.

미리 생성된 피드는 Redis에 있습니다. 각 사용자는 정렬된 집합을 가집니다:

Key    : feed:{userId}
Member : postId (문자열)
Score  : createdAt (epoch 밀리초)

주의: Redis score로 Snowflake ID를 사용하지 마세요. Redis의 score는 double이며 2^53까지의 정수만 정확하게 표현합니다. 64비트 Snowflake는 반올림되므로 서로 다른 두 글이 같은 score를 가질 수 있습니다. epoch 밀리초를 score로 사용하고 ID를 member에 보관하세요.

피드는 ID만 저장하며, 글의 내용은 저장하지 않습니다. 읽을 때 캐시에서 “수화(hydrate)“합니다. 이덕분에 글을 수정하거나 삭제할 때 한 곳만 수정하면 됩니다.

4. API

@RestController
@RequestMapping("/api")
public class FeedController {

    private final PostService postService;
    private final FeedService feedService;

    public FeedController(PostService postService, FeedService feedService) {
        this.postService = postService;
        this.feedService = feedService;
    }

    @PostMapping("/posts")
    public ResponseEntity<PostDto> create(@RequestHeader("X-User-Id") long userId,
                                          @RequestBody CreatePostRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(postService.create(userId, req.content()));
    }

    @GetMapping("/feed")
    public FeedPage feed(@RequestHeader("X-User-Id") long userId,
                         @RequestParam(required = false) String cursor,
                         @RequestParam(defaultValue = "20") int limit) {
        return feedService.getFeed(userId, Cursor.parse(cursor), Math.min(limit, 50));
    }
}

public record CreatePostRequest(String content) {}
public record FeedPage(List<PostDto> items, String nextCursor) {}

응답 예시:

GET /api/feed?limit=2
{
  "items": [ { "id": 7253..., "authorId": 42, "content": "Hello" }, { ... } ],
  "nextCursor": "1760245800123:7253..."
}

페이지네이션에는 커서 기반 (createdAtMillis:postId)을 사용하며, offset은 사용하지 않습니다. 피드는 계속 변합니다. offset을 사용하면 새 글이 맨 앞에 추가될 때 다음 페이지에서 이미 본 글이 반복됩니다.

5. Fan-out on write (Push)

아이디어: 작가가 글을 올리면 시스템이 글의 ID를 각 팔로워의 피드에 바로 기록합니다. 읽을 때는 단지 ZREVRANGEBYSCORE 명령만 필요합니다.

의존성 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

PostService는 글을 DB에 저장한 후 PostCreated 이벤트를 Kafka에 발행합니다. 별도의 consumer가 fan-out을 수행합니다:

public record PostCreatedEvent(long postId, long authorId, long createdAtMillis, long followerCount) {}

@Component
public class FanoutConsumer {

    static final int FEED_MAX = 500;
    static final long CELEBRITY_THRESHOLD = 10_000;
    private static final int BATCH = 1_000;

    private final FollowRepository follows;
    private final StringRedisTemplate redis;

    public FanoutConsumer(FollowRepository follows, StringRedisTemplate redis) {
        this.follows = follows;
        this.redis = redis;
    }

    @KafkaListener(topics = "post-created", groupId = "feed-fanout")
    public void onPostCreated(PostCreatedEvent e) {
        if (e.followerCount() >= CELEBRITY_THRESHOLD) {
            return; // 셀러브리티: 읽을 때 직접 가져오기 (섹션 7 참고)
        }
        long lastId = 0;
        List<Long> batch;
        do {
            batch = follows.findActiveFollowerIds(e.authorId(), lastId, BATCH);
            pushToFeeds(batch, e);
            if (!batch.isEmpty()) lastId = batch.getLast();
        } while (batch.size() == BATCH);
    }

    private void pushToFeeds(List<Long> followerIds, PostCreatedEvent e) {
        redis.executePipelined((RedisCallback<Object>) conn -> {
            StringRedisConnection c = (StringRedisConnection) conn;
            for (Long followerId : followerIds) {
                String key = "feed:" + followerId;
                c.zAdd(key, e.createdAtMillis(), String.valueOf(e.postId()));
                c.zRemRange(key, 0, -(FEED_MAX + 1)); // 최신 500개만 유지
            }
            return null;
        });
    }
}

설명:

  • findActiveFollowerIds는 팔로워를 keyset pagination (follower_id > lastId)으로 순회하며 매번 1,000명씩 가져옵니다. 최근 30일 내에 활동한 사용자만 가져옵니다.
  • executePipelined는 2,000개 명령을 한 번의 라운드 트립으로 묶습니다. 개별 2,000 라운드 트립 대신.
  • zRemRange(key, 0, -501)은 꼬리를 자르고 피드를 최대 500개 요소로 유지합니다. 메모리가 시간에 따라 팽창하지 않습니다.
  • ZADD는 같은 member와 score로는 멱등성입니다. Kafka가 이벤트를 다시 전달하면(at-least-once) 피드에 중복이 생기지 않습니다.

해당 SQL:

SELECT f.follower_id
FROM follows f
JOIN user_activity a ON a.user_id = f.follower_id
WHERE f.followee_id = :authorId
  AND f.follower_id > :lastId
  AND a.last_seen_at > now() - interval '30 days'
ORDER BY f.follower_id
LIMIT :batch;

장점: 읽기가 극도로 빠름, O(log N). 단점: 쓰기가 증폭됨. 1.5억 글 × 300 팔로워 ≈ 450억 ZADD/일. 5천만 팔로워의 셀러브리티가 한 글을 올리면 5천만 개의 쓰기 명령이 생깁니다.

6. Fan-out on read (Pull)

정반대 아이디어: 미리 아무것도 생성하지 않습니다. 읽을 때 시스템이 팔로우 목록을 가져온 후 그들의 최신 글을 쿼리합니다:

SELECT p.id, p.created_at
FROM posts p
WHERE p.author_id IN (SELECT followee_id FROM follows WHERE follower_id = :userId)
  AND p.created_at < :before
  AND p.deleted = false
ORDER BY p.created_at DESC
LIMIT 20;

장점: 쓰기가 저렴함 (단지 INSERT), 새 글이 즉시 표시됨, 피드에 RAM 소비 안 함. 단점: 매 읽기마다 수백 개의 작가에 접근해야 하며 이는 100K QPS에서 발생합니다. posts가 author_id로 샤딩되면 한 번의 읽기가 여러 샤드에 걸쳐 scatter-gather해야 합니다. p99를 200ms 이하로 유지하기 매우 어렵습니다.

요약:

                    Fan-out on write          Fan-out on read
쓰기 비용            높음 (× 팔로워 수)        낮음
읽기 비용            낮음 (1 Redis key)        높음 (× 팔로우 수)
표시 지연            몇 초 (비동기)            즉시
셀러브리티           재앙                      괜찮음
많이 팔로우           괜찮음                    느림

7. 하이브리드 모델 (Hybrid)

Facebook, Twitter, 그리고 대부분의 대규모 시스템은 하이브리드 방식을 사용합니다:

  • 일반 작가 (< 10K 팔로워): push 팔로워의 피드로.
  • 셀러브리티 (≥ 10K 팔로워): push하지 않음. 읽을 때 시스템이 팔로우하는 셀러브리티의 글을 pull한 후 push된 피드와 섞습니다.

각 사용자는 수십 명의 셀러브리티만 팔로우하므로 pull 부분이 작습니다. 셀러브리티의 최근 글은 수백만 명이 읽으므로 캐시가 매우 효율적입니다.

핵심은 병합 로직입니다. 테스트를 쉽게 하기 위해 순수 Java 클래스로 분리합니다:

public record FeedEntry(long postId, long createdAtMillis) {}

public record Cursor(long createdAtMillis, long postId) {
    static final Cursor START = new Cursor(Long.MAX_VALUE, Long.MAX_VALUE);

    public static Cursor parse(String raw) {
        if (raw == null || raw.isBlank()) return START;
        String[] p = raw.split(":");
        return new Cursor(Long.parseLong(p[0]), Long.parseLong(p[1]));
    }

    boolean isAfter(FeedEntry e) { // e가 cursor보다 더 오래된 건가?
        return e.createdAtMillis() < createdAtMillis
            || (e.createdAtMillis() == createdAtMillis && e.postId() < postId);
    }
}

public final class FeedMerger {

    private static final Comparator<FeedEntry> NEWEST_FIRST =
        Comparator.comparingLong(FeedEntry::createdAtMillis)
                  .thenComparingLong(FeedEntry::postId)
                  .reversed();

    public static List<FeedEntry> merge(List<FeedEntry> pushed, List<FeedEntry> pulled,
                                        Cursor cursor, int limit) {
        return Stream.concat(pushed.stream(), pulled.stream())
            .filter(cursor::isAfter)
            .collect(Collectors.toMap(FeedEntry::postId, e -> e, (a, b) -> a))
            .values().stream()
            .sorted(NEWEST_FIRST)
            .limit(limit)
            .toList();
    }
}
  • filter(cursor::isAfter)은 cursor보다 오래된 글만 유지합니다. 같은 밀리초인 두 글일 경우 postId가 순서를 결정합니다.
  • toMap(..., (a, b) -> a)는 중복을 제거합니다. 한 글이 두 소스에 나타날 수 있습니다. 예를 들어 작가가 방금 셀러브리티 임계값을 넘었을 때.
  • 그 후 목록이 최신순으로 정렬되고 limit에 따라 잘립니다.

FeedService가 각 부분을 조합합니다:

public FeedPage getFeed(long userId, Cursor cursor, int limit) {
    double max = cursor == Cursor.START ? Double.POSITIVE_INFINITY : cursor.createdAtMillis();

    List<FeedEntry> pushed = redis.opsForZSet()
        .reverseRangeByScoreWithScores("feed:" + userId, 0, max, 0, limit + 1)
        .stream()
        .map(t -> new FeedEntry(Long.parseLong(t.getValue()), t.getScore().longValue()))
        .toList();

    List<Long> celebs = followRepository.findCelebrityFolloweeIds(userId);
    List<FeedEntry> pulled = celebrityTimelineCache.recentPosts(celebs, cursor, limit);

    List<FeedEntry> page = FeedMerger.merge(pushed, pulled, cursor, limit);
    List<PostDto> items = postCache.hydrate(page); // multi-get, 삭제된 글 제외
    String next = page.isEmpty() ? null
        : page.getLast().createdAtMillis() + ":" + page.getLast().postId();
    return new FeedPage(items, next);
}

Redis 쿼리는 경계를 포함하는 max를 사용합니다. cursor와 같은 밀리초의 글이 더 있을 수 있기 때문입니다. FeedMerger가 다시 정확히 필터링합니다.

8. 병합 로직 테스트

class FeedMergerTest {

    @Test
    void mergesSortsDedupesAndRespectsCursor() {
        List<FeedEntry> pushed = List.of(
            new FeedEntry(5, 5_000), new FeedEntry(3, 3_000), new FeedEntry(1, 1_000));
        List<FeedEntry> pulled = List.of(
            new FeedEntry(4, 4_000), new FeedEntry(3, 3_000), new FeedEntry(2, 2_000));

        List<FeedEntry> firstPage = FeedMerger.merge(pushed, pulled, Cursor.START, 3);

        assertThat(firstPage).extracting(FeedEntry::postId).containsExactly(5L, 4L, 3L);

        Cursor next = new Cursor(3_000, 3);
        List<FeedEntry> secondPage = FeedMerger.merge(pushed, pulled, next, 3);

        assertThat(secondPage).extracting(FeedEntry::postId).containsExactly(2L, 1L);
    }
}

글 3은 두 소스 모두에 있지만 한 번만 나타납니다. 두 번째 페이지는 cursor 바로 뒤에서 시작하며, 중복이나 빠진 항목이 없습니다.

9. 어려운 점과 해결책

9.1. 셀러브리티의 hot key

모두가 celeb:{id}:recent를 읽으려 하면 한 Redis 노드에 부하가 몰립니다. 세 가지 대응책:

  • 각 인스턴스에 로컬 캐시(Caffeine)를 TTL 2~5초로 추가합니다. 100K QPS는 Redis로 내려가는 요청이 수백/초만 남습니다.
  • Redis 읽기 복제본을 이 키들에 사용합니다.
  • 셀러브리티가 글을 올릴 때 timeline cache에 주도적으로 쓰기하며, cache miss를 기다리지 않습니다. 이 방식은 thundering herd를 피합니다.

9.2. 글 삭제, 언팔로우, 새 팔로우 시 일관성

  • 글 삭제: 수백만 피드를 스캔할 필요 없습니다. hydrate할 때 postCache는 deleted = true인 글을 건너뜁니다. 백그라운드 작업이 나중에 ID를 점진적으로 정리합니다.
  • 언팔로우: 읽을 때 사용자의 팔로우 집합으로 필터링하며, 동시에 그 사람의 글을 feed:{userId}에서 비동기로 삭제하는 이벤트를 발행합니다.
  • 새 팔로우: backfill을 실행하여 새로 팔로우한 사람의 최근 약 20개 글을 가져온 후 피드에 ZADD합니다.

피드가 ID만 포함하므로 모든 불일치는 “참조 목록”에 있습니다. 내용은 항상 진실의 원천에 따릅니다.

9.3. DB와 Kafka 사이의 이벤트 손실

INSERT 글을 한 후 kafkaTemplate.send()를 호출하면 send가 트랜잭션 커밋 후 실패할 수 있습니다. 그러면 글은 존재하지만 절대 fan-out되지 않습니다.

주의: transactional outbox를 사용하세요. 글과 outbox 테이블의 행을 같은 트랜잭션에서 기록합니다. relay (또는 Debezium 같은 CDC)가 outbox를 읽고 Kafka로 푸시합니다. consumer 측은 멱등성을 가져야 하며, ZADD가 이미 충족합니다.

9.4. 트래픽 급증

축구 경기나 새해 카운트다운 같은 큰 이벤트는 글의 양을 10배 증가시킬 수 있습니다. 대응책:

  • Kafka를 버퍼로 사용합니다. Consumer는 능력에 따라 처리하며, lag가 증가하면 피드가 몇 초 더 지연됩니다. 이는 섹션 2의 제약 조건에 따라 허용됩니다.
  • authorId로 partition을 나눕니다 같은 작가의 글 순서를 보존합니다. partition 수를 늘려 consumer를 확장합니다.
  • 비활동 사용자는 무시합니다. 그들은 push되지 않습니다. 돌아와서 cache miss를 만나면 피드가 fan-out on read로 재구축되고 Redis에 기록됩니다.
  • 셀러브리티 임계값을 동적으로 낮춥니다. lag가 임계값을 넘으면 더 많은 작가를 pull 모드로 전환합니다.

9.5. Redis 메모리 추정

~2억 활동 사용자 × 500 entry × ~60 byte ≈ 6 TB

이 규모는 Redis Cluster를 필요로 합니다. FEED_MAX를 200~300으로 줄일 수 있습니다. 대부분의 사람이 몇백 글 이상으로 스크롤하지 않기 때문입니다. 더 깊이 스크롤하는 부분은 pull로 돌아갑니다.

10. 이 모델을 사용하지 말아야 할 때

  • 작은 애플리케이션 (수만 사용자): fan-out on read와 index를 가진 한 SQL 쿼리로 충분합니다. Kafka와 Redis를 추가하면 복잡성만 증가합니다.
  • 알고리즘으로 순위를 정하는 피드 (ML로 ranking): push 피드는 “후보 집합” 역할만 합니다. ranking 단계는 읽을 때 별도로 실행되며, 이때 시간순 순서가 최종 순서가 아닙니다.
  • 강한 일관성 요구사항 (글이 모든 팔로워에게 즉시 표시): 비동기 fan-out은 이를 보장하지 않습니다. pull을 사용하거나 글 작성 직후 응답에 자신의 글을 삽입해야 합니다 (read-your-writes).

11. 결론

Fan-out on write는 읽기를 최적화하지만 쓰기를 증폭시킵니다. Fan-out on read는 그 반대입니다. 소셜 네트워크처럼 팔로워 분포가 치우친 상황에서 실제 답은 하이브리드 모델입니다: 일반 작가는 push, 셀러브리티는 pull, 읽을 때 병합.

구현할 때 기억할 핵심 사항:

  • 피드는 ID만 저장하며, score는 Snowflake ID가 아닌 epoch 밀리초입니다.
  • 커서 (createdAt, postId)를 페이지네이션에 사용하여 안정적인 페이지를 만듭니다.
  • Consumer는 멱등성을 가져야 하며, outbox와 함께 이벤트 손실을 방지합니다.
  • 글 삭제와 언팔로우는 읽을 때 필터링하여 처리합니다.
  • Kafka와 동적 셀러브리티 임계값으로 트래픽 급증을 흡수합니다.
system-designrediskafkaspring-bootcase-study