
뉴스 피드 시스템: Push vs Pull 모델
인스타그램처럼 피드를 만들려고 했는데, 팔로워 100만 명인 사람이 글을 올리면 어떻게 되는 걸까? Push와 Pull 모델의 트레이드오프를 정리했다.

인스타그램처럼 피드를 만들려고 했는데, 팔로워 100만 명인 사람이 글을 올리면 어떻게 되는 걸까? Push와 Pull 모델의 트레이드오프를 정리했다.
왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

직접 가기 껄끄러울 때 프록시가 대신 갔다 옵니다. 내 정체를 숨기려면 Forward Proxy, 서버를 보호하려면 Reverse Proxy. 같은 대리인인데 누구 편이냐가 다릅니다.

소셜 미디어 프로젝트를 시작하면서 가장 먼저 만들어야 했던 게 피드였다. "내가 팔로우하는 사람들의 최근 게시물을 보여주면 되겠지"라고 생각했는데, 막상 구현하려니 생각보다 복잡했다.
처음엔 단순하게 접근했다. 사용자가 피드를 열 때마다 팔로우하는 모든 사람의 게시물을 가져와서 시간순으로 정렬하면 끝이라고 생각했다. 실제로 10명, 100명 정도 팔로우할 때는 잘 작동했다.
그런데 문제는 스케일이었다. 팔로워가 100만 명인 유명인이 글을 올리면? 또는 한 사용자가 1000명을 팔로우하고 있다면? 갑자기 데이터베이스 쿼리가 느려지고, 서버가 버거워하기 시작했다.
그때 깨달았다. 피드는 단순한 조회 기능이 아니라, 읽기와 쓰기 사이의 트레이드오프를 결정하는 시스템 설계 문제였다는 걸.
내가 처음 구현한 방식이 바로 Pull 모델이었다. 사용자가 피드를 요청할 때마다 실시간으로 데이터를 가져오는 방식이다.
-- Pull 모델의 전형적인 쿼리
SELECT posts.*
FROM posts
WHERE user_id IN (
SELECT followed_id
FROM follows
WHERE follower_id = 'current_user_id'
)
ORDER BY created_at DESC
LIMIT 20;
이 방식은 도서관에서 책을 찾는 것과 비슷하다. 내가 관심있는 작가 리스트를 들고 도서관에 가서, 그 작가들의 최신 책을 일일이 찾아서 모으는 거다. 매번 방문할 때마다 새로 찾아야 한다.
하지만 사용자가 많아지면서 문제가 보이기 시작했다.
// 팔로우 1000명인 사용자의 피드 로딩 시간
const startTime = Date.now();
const feed = await getFeedPull(userId);
const loadTime = Date.now() - startTime;
console.log(`Load time: ${loadTime}ms`); // 3000ms... 너무 느리다!
결국 Pull만으로는 인스타그램이나 트위터 같은 대규모 서비스를 만들 수 없다는 걸 깨달았다.
그래서 찾게 된 게 Push 모델이다. 이건 완전히 다른 접근이었다. 게시물이 작성되는 순간에 모든 팔로워의 피드에 미리 넣어두는 것이다.
이건 신문 배달과 비슷하다. 신문사가 새 신문을 찍으면, 구독자들의 집 우편함에 미리 배달해둔다. 구독자는 그냥 우편함을 열어보기만 하면 된다.
// Push 모델: 게시물 작성 시 fan-out
async function createPost(userId: string, content: string) {
// 1. 게시물 생성
const post = await db.posts.create({
user_id: userId,
content: content,
created_at: new Date()
});
// 2. 모든 팔로워의 피드에 푸시 (fan-out on write)
const followers = await db.follows.findMany({
where: { followed_id: userId }
});
// 3. 각 팔로워의 피드 테이블에 삽입
const feedInserts = followers.map(follower => ({
user_id: follower.follower_id,
post_id: post.id,
created_at: post.created_at
}));
await db.feeds.createMany({ data: feedInserts });
return post;
}
// 피드 조회는 엄청 빠르다
async function getFeedPush(userId: string) {
return await db.feeds.findMany({
where: { user_id: userId },
include: { post: true },
orderBy: { created_at: 'desc' },
take: 20
});
}
그런데 Push 모델을 구현하면서 Fan-out 문제를 마주쳤다. 이게 정말 큰 문제였다.
// 팔로워 100만 명인 유명인이 게시물을 올리면?
const celebrity = { id: 'celeb_123', followers: 1_000_000 };
await createPost(celebrity.id, '안녕하세요!');
// 데이터베이스에 100만 개의 레코드를 삽입해야 한다...
// 이건 몇 분이 걸릴 수도 있다!
Fan-out 문제란 게시물 하나가 수많은 피드로 "펼쳐지는(fan-out)" 현상이다. 팬이 많은 사람일수록 쓰기 비용이 기하급수적으로 증가한다.
또 다른 문제는 저장 공간이다. 팔로워 100만 명이 각각 20개씩 게시물을 피드에 저장한다면? 2000만 개의 레코드가 필요하다. 대부분은 읽히지도 않을 데이터인데 말이다.
진짜 깨달음은 여기서 왔다. "왜 하나만 써야 한다고 생각했을까?"
트위터가 실제로 사용하는 방식을 공부하다가 알게 됐다. 그들은 사용자 유형에 따라 다른 전략을 사용한다.
// Hybrid 모델: 사용자 유형에 따라 다른 전략
async function createPostHybrid(userId: string, content: string) {
const post = await db.posts.create({
user_id: userId,
content: content,
created_at: new Date()
});
const user = await db.users.findUnique({ where: { id: userId } });
// 팔로워 수에 따라 전략을 나눈다
if (user.followers_count > 100_000) {
// 유명인: Push 안 함, Pull로 처리
console.log('Celebrity post - will be pulled on demand');
return post;
}
// 일반 유저: Push (fan-out)
const followers = await db.follows.findMany({
where: { followed_id: userId }
});
const feedInserts = followers.map(follower => ({
user_id: follower.follower_id,
post_id: post.id,
created_at: post.created_at
}));
await db.feeds.createMany({ data: feedInserts });
return post;
}
// 피드 조회도 hybrid
async function getFeedHybrid(userId: string) {
// 1. Push된 피드 가져오기 (일반 유저들의 게시물)
const pushedFeed = await db.feeds.findMany({
where: { user_id: userId },
include: { post: { include: { user: true } } },
orderBy: { created_at: 'desc' },
take: 50
});
// 2. 팔로우하는 유명인들의 게시물은 실시간으로 Pull
const celebritiesFollowed = await db.follows.findMany({
where: {
follower_id: userId,
followed: { followers_count: { gte: 100_000 } }
}
});
const celebrityPosts = await db.posts.findMany({
where: {
user_id: { in: celebritiesFollowed.map(f => f.followed_id) },
created_at: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
},
include: { user: true },
orderBy: { created_at: 'desc' }
});
// 3. 머지하고 정렬
const merged = [...pushedFeed, ...celebrityPosts]
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
.slice(0, 20);
return merged;
}
이 방식의 핵심은 파레토 원칙이다. 대부분의 사용자는 팔로워가 적다. 1%의 유명인만 수십만 명의 팔로워를 가지고 있다. 그렇다면 99%는 효율적인 Push로 처리하고, 1%만 Pull로 처리하면 된다.
이건 레스토랑 주방과 비슷하다. 인기 메뉴는 미리 준비해두고(Push), 특별 주문은 그때그때 만든다(Pull). 모든 걸 미리 만들 수도 없고, 모든 걸 주문받고 만들 수도 없다.
// 피드 테이블 설계
interface FeedItem {
id: string;
user_id: string; // 이 피드를 볼 사용자
post_id: string; // 게시물 ID
author_id: string; // 게시물 작성자
created_at: Date; // 게시물 생성 시간
inserted_at: Date; // 피드에 삽입된 시간
}
// 인덱스가 중요하다
// CREATE INDEX idx_feeds_user_created ON feeds(user_id, created_at DESC);
왜 inserted_at과 created_at을 둘 다 저장할까? 나중에 피드를 재생성하거나 순서를 조정할 때 필요하기 때문이다.
시간순 정렬만으로는 부족하다. 인스타그램이나 페이스북처럼 관여도(engagement)를 고려해야 한다.
// 간단한 랭킹 스코어
function calculateFeedScore(post: Post, user: User): number {
const ageInHours = (Date.now() - post.created_at.getTime()) / (1000 * 60 * 60);
// 최신성: 오래될수록 감소 (지수 감쇠)
const recencyScore = Math.exp(-ageInHours / 24);
// 인기도: 좋아요, 댓글, 공유 수
const popularityScore =
post.likes_count * 1.0 +
post.comments_count * 2.0 +
post.shares_count * 3.0;
// 친밀도: 이 작성자와의 과거 상호작용
const affinityScore = calculateAffinity(user, post.author);
// 가중 합산
return (
recencyScore * 0.5 +
Math.log(1 + popularityScore) * 0.3 +
affinityScore * 0.2
);
}
// 피드 가져올 때 스코어로 정렬
async function getRankedFeed(userId: string) {
const rawFeed = await getFeedHybrid(userId);
const user = await db.users.findUnique({ where: { id: userId } });
return rawFeed
.map(item => ({
...item,
score: calculateFeedScore(item.post, user)
}))
.sort((a, b) => b.score - a.score)
.slice(0, 20);
}
초기에는 offset 방식을 썼다가 문제를 겪었다.
// Offset 방식 (안 좋음)
async function getFeedOffset(userId: string, page: number) {
return await db.feeds.findMany({
where: { user_id: userId },
orderBy: { created_at: 'desc' },
skip: page * 20,
take: 20
});
}
// 문제: 사용자가 2페이지를 보는 동안 새 게시물이 추가되면
// 3페이지로 갈 때 중복이나 누락이 생긴다!
해결책은 커서 기반 페이지네이션이었다.
// Cursor 방식 (좋음)
async function getFeedCursor(userId: string, cursor?: string) {
const where: any = { user_id: userId };
if (cursor) {
// 커서(마지막 항목의 created_at) 이후의 데이터만
where.created_at = { lt: new Date(cursor) };
}
const items = await db.feeds.findMany({
where,
orderBy: { created_at: 'desc' },
take: 21 // 하나 더 가져와서 다음 페이지 존재 여부 확인
});
const hasMore = items.length > 20;
const feed = items.slice(0, 20);
const nextCursor = hasMore ? feed[feed.length - 1].created_at.toISOString() : null;
return { feed, nextCursor, hasMore };
}
피드는 캐싱하기 완벽한 대상이다. 특히 Push 모델에서는.
// Redis를 활용한 피드 캐싱
import Redis from 'ioredis';
const redis = new Redis();
async function getCachedFeed(userId: string): Promise<FeedItem[]> {
const cacheKey = `feed:${userId}`;
// 1. 캐시 확인
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. DB에서 가져오기
const feed = await getFeedHybrid(userId);
// 3. 캐시에 저장 (5분 TTL)
await redis.setex(cacheKey, 300, JSON.stringify(feed));
return feed;
}
// 새 게시물이 푸시될 때 캐시 무효화
async function invalidateFeedCache(userId: string) {
await redis.del(`feed:${userId}`);
}
// 또는 더 나은 방법: Redis Sorted Set 사용
async function pushToFeedCache(userId: string, postId: string, timestamp: number) {
const cacheKey = `feed:${userId}`;
// Sorted Set에 추가 (timestamp가 score)
await redis.zadd(cacheKey, timestamp, postId);
// 최신 1000개만 유지
await redis.zremrangebyrank(cacheKey, 0, -1001);
// TTL 설정
await redis.expire(cacheKey, 3600);
}
async function getFeedFromCache(userId: string, limit: number = 20) {
const cacheKey = `feed:${userId}`;
// 최신순으로 가져오기
const postIds = await redis.zrevrange(cacheKey, 0, limit - 1);
if (postIds.length === 0) {
return null; // 캐시 미스
}
// 게시물 데이터 가져오기 (배치 조회)
const posts = await db.posts.findMany({
where: { id: { in: postIds } },
include: { user: true }
});
// 원래 순서대로 정렬
const postMap = new Map(posts.map(p => [p.id, p]));
return postIds.map(id => postMap.get(id)).filter(Boolean);
}
Redis Sorted Set을 쓰면 메모리 효율적으로 피드를 관리할 수 있다. post_id만 저장하고, 실제 데이터는 필요할 때 배치로 가져온다.
트위터는 2012년에 Pure Pull에서 Hybrid로 전환했다. 그들의 블로그 포스트에서 배운 게 많았다.
재밌는 점은 트위터가 "Timeline Service"라는 별도 마이크로서비스를 만들었다는 것이다. 피드 생성 로직이 너무 복잡해져서 독립된 서비스로 분리한 거다.
인스타그램은 조금 다르다. 사진이라는 컨텐츠 특성상 미디어 로딩 최적화가 더 중요하다.
인스타그램의 흥미로운 점은 "Stories"를 별도 시스템으로 관리한다는 것이다. 24시간 후 사라지는 특성상 다른 전략이 필요하기 때문이다.
피드 시스템을 만들면서 배운 핵심 교훈들을 정리하면:
완벽한 솔루션은 없다: Pull도, Push도 모든 경우에 최적은 아니다. Hybrid가 현실적이다.
스케일을 고려하라: 처음엔 간단하게 시작해도 좋지만, 성장 계획이 있다면 나중에 마이그레이션할 경로를 생각해둬야 한다.
측정이 중요하다: 피드 로딩 시간, 쓰기 지연, 캐시 히트율 등을 모니터링해야 병목을 찾을 수 있다.
캐싱은 필수다: 메모리는 싸다. Redis를 적극 활용하면 많은 문제가 해결된다.
페이지네이션은 커서 기반으로: Offset 방식은 실시간 피드에서 문제가 많다.
Fan-out은 비동기로: 게시물 작성이 느려지면 안 된다. 백그라운드 작업으로 처리하라.
결국 피드 시스템은 읽기와 쓰기의 균형을 찾는 것이다. 사용자 행동 패턴을 이해하고, 데이터를 어디에 저장할지, 언제 계산할지를 결정하는 게 핵심이다.
처음 시작할 때는 Pull로 간단하게 만들고, 트래픽이 늘어나면 점진적으로 Push를 도입하고, 더 커지면 Hybrid로 진화하는 게 현실적인 경로인 것 같다.
이제 다음 프로젝트에서 피드를 만들 땐 자신있게 설계할 수 있을 것 같다.