OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 82 / 90
stack-analysis
CHAPTER 82 / 90
읽기 약 2
FUNCTION

뉴스피드: 팬아웃 vs 풀 모델


핵심 개념

fan-out·timeline cache·hybrid·랭킹 — 뉴스피드 구현 표준.

본문

Pull 모델 (단순)

SQL📋 코드 (18줄)
-- 사용자 A의 피드 = A의 팔로잉 사용자들의 최근 게시물
SELECT p.*
FROM posts p
JOIN follows f ON f.followed_id = p.user_id
WHERE f.follower_id = ?
  AND p.deleted_at IS NULL
ORDER BY p.created_at DESC
LIMIT 50;


-- 인덱스
CREATE INDEX idx_follows_follower ON follows(follower_id, followed_id);
CREATE INDEX idx_posts_user_created ON posts(user_id, created_at DESC);


-- 문제:
-- 1000명 팔로잉 시 → 큰 JOIN
-- 매 요청마다 계산 → CPU 부담

Push 모델 (Fan-out on write)

TYPESCRIPT📋 코드 (35줄)
// 게시물 작성 시 — 모든 팔로워의 피드 캐시에 추가
async function createPost(userId: string, content: string) {
  const post = await db.post.create({ data: { userId, content } });

  // 팔로워 목록
  const followers = await db.follow.findMany({
    where: { followedId: userId },
    select: { followerId: true },
  });

  // 각 팔로워의 timeline에 게시물 ID 추가 (Redis ZSET)
  const pipeline = redis.pipeline();
  for (const f of followers) {
    pipeline.zadd(
      `timeline:${f.followerId}`,
      Date.now(),
      post.id,
    );
    pipeline.zremrangebyrank(`timeline:${f.followerId}`, 0, -1001);  // 최근 1000개만
  }
  await pipeline.exec();

  return post;
}


// 피드 조회 — Redis에서 ID 가져와 게시물 로드
async function getFeed(userId: string, limit = 20) {
  const ids = await redis.zrevrange(`timeline:${userId}`, 0, limit - 1);
  const posts = await db.post.findMany({
    where: { id: { in: ids } },
    include: { user: true, _count: { select: { likes: true, comments: true } } },
  });
  return ids.map(id => posts.find(p => p.id === id)).filter(Boolean);
}

Push 모델 — 비용 분석

📋 코드 (10줄)
사용자 100만 명, 평균 팔로워 100명:
- 게시물 1개 작성 → 100번의 ZADD
- 분당 1000개 게시물 → 분당 100,000 ZADD
- → Redis로 충분 (수백만 OPS)


메가 인플루언서 (팔로워 1000만):
- 게시물 1개 → 1000만 번의 ZADD
- 30초 이상 소요 → 비효율
- → Hybrid 필요

Hybrid 모델 (Twitter 방식)

TYPESCRIPT📋 코드 (53줄)
const CELEBRITY_THRESHOLD = 100_000;  // 팔로워 10만+

async function createPost(userId: string, content: string) {
  const post = await db.post.create({ data: { userId, content } });
  const user = await db.user.findUnique({ where: { id: userId } });

  if (user!.followersCount < CELEBRITY_THRESHOLD) {
    // 일반 사용자 → Push
    await fanOut(post);
  } else {
    // 인플루언서 → Pull (계산은 조회 시)
    // 그 사용자를 팔로잉하는 사람들이 직접 조회
    await redis.sadd('celebrity_users', userId);
  }

  return post;
}


// 피드 조회 (Hybrid)
async function getFeed(userId: string, limit = 20) {
  // 1. Push 캐시에서 가져오기
  const cachedIds = await redis.zrevrange(`timeline:${userId}`, 0, limit * 2);

  // 2. 인플루언서 게시물 추가 (Pull)
  const followingCelebrities = await db.follow.findMany({
    where: {
      followerId: userId,
      followed: { followersCount: { gte: CELEBRITY_THRESHOLD } },
    },
    select: { followedId: true },
  });

  const celebrityPosts = followingCelebrities.length > 0
    ? await db.post.findMany({
        where: {
          userId: { in: followingCelebrities.map(f => f.followedId) },
          createdAt: { gte: new Date(Date.now() - 24 * 3600 * 1000) },
        },
        orderBy: { createdAt: 'desc' },
        take: limit,
      })
    : [];

  // 3. 합쳐서 정렬
  const allPosts = await db.post.findMany({
    where: { id: { in: [...cachedIds, ...celebrityPosts.map(p => p.id)] } },
  });

  return allPosts
    .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
    .slice(0, limit);
}

랭킹 알고리즘 (Instagram·TikTok)

TYPESCRIPT📋 코드 (34줄)
// 단순 시간순 → 알고리즘 피드
function rankPost(post: Post, viewer: User): number {
  const ageHours = (Date.now() - post.createdAt.getTime()) / 3600000;

  // 시간 감쇠
  const recency = 1 / Math.pow(1 + ageHours, 1.5);

  // 참여도 (좋아요·댓글·공유)
  const engagement = post.likesCount * 1 + post.commentsCount * 3 + post.sharesCount * 5;

  // 작성자와의 관계
  const relationship =
    viewer.closeFriends.includes(post.userId) ? 5 :
    viewer.frequentInteractions.includes(post.userId) ? 3 :
    1;

  // 콘텐츠 유사도 (관심사)
  const interestMatch = computeInterestMatch(post, viewer);

  return recency * (engagement * 0.4 + relationship * 0.3 + interestMatch * 0.3);
}


// 피드 = 후보 100개 → 랭킹 → 상위 20개
async function getRankedFeed(userId: string) {
  const viewer = await db.user.findUnique({ where: { id: userId } });
  const candidates = await getFeedCandidates(userId, 100);

  return candidates
    .map(post => ({ post, score: rankPost(post, viewer!) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 20)
    .map(x => x.post);
}

피드 캐싱 전략

📋 코드 (17줄)
L1 (메모리·Edge): CDN
- 최근 게시물 (변경 적음)
- 60초 캐시

L2 (Redis): 사용자별 timeline
- 게시물 ID ZSET
- 1주일 보관

L3 (PostgreSQL): 영구 저장
- 모든 게시물


[조회 흐름]
1. CDN 확인 → 미스
2. Redis ZSET → 게시물 ID 가져옴
3. PostgreSQL → 게시물 본문 로드
4. 1+2+3 합쳐서 응답

다음 챕터

CH.83 "실시간 채팅: WebSocket + 메시지 저장".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 프로젝트의 뉴스피드 부분을 분석해서
실전 분석 + 개선 우선순위를 알려줘.
ChatGPT

무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro

뉴스피드 관련 실제 서비스 5개를
비교 분석해서 패턴 추출를 알려줘.
Gemini

무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro

내 코드베이스에서 뉴스피드
최적화 가능 위치를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 풀스택 시장의
뉴스피드 트렌드를 솔직히 알려줘.

⭐ 이것만 기억하세요
뉴스피드: 팬아웃 vs 풀 모델 이 3가지만 확실히 잡으세요
1.Push (fan-out)는 일반 사용자, Pull은 인플루언서 — Hybrid 표준
2.랭킹 알고리즘 = 시간 감쇠 + 참여도 + 관계 + 관심사
3.3계층 캐시 (CDN→Redis→DB)로 읽기 최적화


공유하기
진행도 82 / 90