stack-analysis
CHAPTER 82 / 90
읽기 약 2분
FUNCTION
뉴스피드: 팬아웃 vs 풀 모델
핵심 개념
fan-out·timeline cache·hybrid·랭킹 — 뉴스피드 구현 표준.
본문
Pull 모델 (단순)
-- 사용자 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)
// 게시물 작성 시 — 모든 팔로워의 피드 캐시에 추가
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 모델 — 비용 분석
사용자 100만 명, 평균 팔로워 100명:
- 게시물 1개 작성 → 100번의 ZADD
- 분당 1000개 게시물 → 분당 100,000 ZADD
- → Redis로 충분 (수백만 OPS)
메가 인플루언서 (팔로워 1000만):
- 게시물 1개 → 1000만 번의 ZADD
- 30초 이상 소요 → 비효율
- → Hybrid 필요Hybrid 모델 (Twitter 방식)
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)
// 단순 시간순 → 알고리즘 피드
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);
}피드 캐싱 전략
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