본문으로 건너뛰기

그래프 데이터베이스로 활동 피드 구현하기

Implementing Activity Feeds with Graph Databases

활동 피드는 소셜 네트워크의 핵심이다. 사용자는 친구와 팔로우하는 사람들의 게시물을 시간순 또는 관련성 순으로 본다. 피드는 본질적으로 관계 엣지를 통한 탐색이기 때문에 그래프 데이터베이스가 이를 자연스럽게 처리한다.

Activity feeds are the heart of social networks. Users see posts from friends and people they follow, ordered by time or relevance. Graph databases handle this naturally because feeds are essentially traversals through relationship edges.

1. 피드 생성 전략

피드 생성에는 세 가지 접근 방식이 있다:

Pull 모델: 요청 시 연결을 탐색하여 게시물을 쿼리한다. 구현이 간단하지만, 연결이 많은 사용자에게는 느리다.

Push 모델: 사용자가 게시하면 모든 팔로워의 피드에 기록한다. 읽기는 빠르지만, 쓰기와 저장 비용이 높다.

하이브리드 모델: 활성 사용자에게는 Pull, 캐시된 피드에는 Push. 복잡성과 성능의 균형을 맞춘다.

중간 규모(수백만 사용자)에서는 적절한 인덱싱을 갖춘 Pull 모델이 잘 작동한다. 이 포스트는 Pull 기반 피드에 초점을 맞춘다.

1. Feed Generation Strategies

There are three approaches to generating feeds:

Pull Model: Query posts by traversing connections on request. Simple to implement but slow for users with many connections.

Push Model: When a user posts, write to all followers' feeds. Fast reads but expensive writes and storage.

Hybrid Model: Pull for active users, push for cached feeds. Balances complexity and performance.

At medium scale (millions of users), pull model with proper indexing works well. This post focuses on pull-based feeds.

2. 기본 홈 피드 쿼리

홈 피드는 친구와 팔로우하는 사용자의 게시물을 보여준다:

2. Basic Home Feed Query

The home feed shows posts from friends and followed users:

@Query("""
MATCH (me:User {username: $username})
CALL {
WITH me
MATCH (me)-[:FOLLOWS]->(followed:User)-[:AUTHORED]->(post:Post)
WHERE post.visibility = 'PUBLIC'
RETURN post, followed as author

UNION

WITH me
MATCH (me)-[:FRIENDS_WITH]-(friend:User)-[:AUTHORED]->(post:Post)
WHERE post.visibility IN ['PUBLIC', 'FRIENDS_ONLY']
RETURN post, friend as author
}
RETURN DISTINCT post, author
ORDER BY post.createdAt DESC
SKIP $skip LIMIT $limit
""")
List<FeedItem> getHomeFeed(String username, int skip, int limit);

CALL {} 서브쿼리는 두 소스를 결합한다: 팔로우하는 사용자의 게시물(공개만)과 친구의 게시물(공개 및 친구 공개). DISTINCT는 누군가가 친구이면서 팔로우 대상일 때 중복을 제거한다.

The CALL {} subquery combines two sources: posts from followed users (public only) and posts from friends (public and friends-only). DISTINCT removes duplicates when someone is both a friend and followed.

3. 피드 아이템 프로젝션

전체 엔티티를 반환하는 대신 필요한 필드만 프로젝션한다:

3. Feed Item Projection

Instead of returning full entities, project only the fields needed:

public record FeedItem(
Long postId,
String content,
LocalDateTime createdAt,
String authorUsername,
String authorDisplayName,
int likeCount,
int commentCount,
boolean likedByMe
) {}
@Query("""
MATCH (me:User {username: $username})
CALL {
WITH me
MATCH (me)-[:FOLLOWS|FRIENDS_WITH]-(connection:User)-[:AUTHORED]->(post:Post)
WHERE post.visibility <> 'PRIVATE'
RETURN post, connection as author
}
WITH DISTINCT post, author, me
OPTIONAL MATCH (post)<-[:LIKED]-(liker:User)
OPTIONAL MATCH (post)<-[:COMMENTED]-(comment)
OPTIONAL MATCH (me)-[myLike:LIKED]->(post)
RETURN post.id as postId,
post.content as content,
post.createdAt as createdAt,
author.username as authorUsername,
author.displayName as authorDisplayName,
count(DISTINCT liker) as likeCount,
count(DISTINCT comment) as commentCount,
myLike IS NOT NULL as likedByMe
ORDER BY post.createdAt DESC
SKIP $skip LIMIT $limit
""")
List<FeedItem> getHomeFeedWithStats(String username, int skip, int limit);

4. 피드 서비스 계층

4. Feed Service Layer

@Service
public class FeedService {

private final PostRepository postRepository;

public FeedService(PostRepository postRepository) {
this.postRepository = postRepository;
}

public Page<FeedItem> getHomeFeed(String username, Pageable pageable) {
int skip = (int) pageable.getOffset();
int limit = pageable.getPageSize();

List<FeedItem> items = postRepository.getHomeFeedWithStats(username, skip, limit + 1);

boolean hasNext = items.size() > limit;
if (hasNext) {
items = items.subList(0, limit);
}

return new PageImpl<>(items, pageable, hasNext ? skip + limit + 1 : skip + items.size());
}

public List<FeedItem> getProfileFeed(String username, String viewerUsername, int skip, int limit) {
return postRepository.getProfileFeed(username, viewerUsername, skip, limit);
}
}

5. 프로필 피드

사용자의 프로필은 보는 사람과의 관계에 따라 필터링된 게시물을 보여준다:

5. Profile Feed

A user's profile shows their posts filtered by the viewer's relationship:

@Query("""
MATCH (viewer:User {username: $viewerUsername})
MATCH (author:User {username: $authorUsername})-[:AUTHORED]->(post:Post)
WHERE post.visibility = 'PUBLIC'
OR (post.visibility = 'FRIENDS_ONLY' AND (viewer)-[:FRIENDS_WITH]-(author))
OR viewer = author
RETURN post, author
ORDER BY post.createdAt DESC
SKIP $skip LIMIT $limit
""")
List<FeedItem> getProfileFeed(String authorUsername, String viewerUsername, int skip, int limit);

공개 설정 로직: 공개 게시물은 항상 보이고, 친구 공개 게시물은 친구 관계가 필요하며, 사용자는 항상 자신의 게시물을 볼 수 있다.

Visibility logic: public posts are always visible, friends-only posts require a friendship, and users can always see their own posts.

6. 탐색 피드

탐색 피드는 사용자 네트워크 외부의 게시물을 보여준다:

6. Discover Feed

The discover feed shows posts outside the user's network:

@Query("""
MATCH (me:User {username: $username})
MATCH (author:User)-[:AUTHORED]->(post:Post)
WHERE post.visibility = 'PUBLIC'
AND NOT (me)-[:FOLLOWS|FRIENDS_WITH]-(author)
AND me <> author
AND NOT (me)-[:BLOCKED]-(author)
WITH post, author, rand() as random
ORDER BY random
LIMIT $limit
""")
List<FeedItem> getDiscoverFeed(String username, int limit);

더 유용한 탐색 피드를 위해 참여도를 고려한다:

For a more useful discover feed, consider engagement:

@Query("""
MATCH (me:User {username: $username})
MATCH (author:User)-[:AUTHORED]->(post:Post)
WHERE post.visibility = 'PUBLIC'
AND NOT (me)-[:FOLLOWS|FRIENDS_WITH]-(author)
AND me <> author
AND post.createdAt > datetime() - duration('P7D')
OPTIONAL MATCH (post)<-[:LIKED]-(liker)
WITH post, author, count(liker) as likes
ORDER BY likes DESC, post.createdAt DESC
LIMIT $limit
RETURN post, author, likes
""")
List<FeedItem> getTrendingFeed(String username, int limit);

7. 성능 최적화

인덱싱

자주 쿼리되는 필드에 인덱스가 있는지 확인한다:

7. Performance Optimization

Indexing

Ensure indexes exist on frequently queried fields:

CREATE INDEX post_created IF NOT EXISTS FOR (p:Post) ON (p.createdAt);
CREATE INDEX post_visibility IF NOT EXISTS FOR (p:Post) ON (p.visibility);
CREATE COMPOSITE INDEX post_vis_created IF NOT EXISTS FOR (p:Post) ON (p.visibility, p.createdAt);

쿼리 힌트

대규모 그래프의 경우 쿼리 플래너를 안내하는 힌트를 추가한다:

Query Hints

For large graphs, add hints to guide the query planner:

MATCH (me:User {username: $username})
USING INDEX me:User(username)
MATCH (me)-[:FOLLOWS]->(followed)-[:AUTHORED]->(post:Post)
USING INDEX post:Post(createdAt)
WHERE post.createdAt > datetime() - duration('P30D')
RETURN post
ORDER BY post.createdAt DESC

탐색 깊이 제한

무제한 탐색을 피한다:

Limit Traversal Depth

Avoid unbounded traversals:

// 나쁨: 모든 게시물을 탐색
MATCH (me)-[:FOLLOWS]->(f)-[:AUTHORED]->(post)
RETURN post ORDER BY post.createdAt DESC LIMIT 20

// 개선: 먼저 시간으로 필터링
MATCH (me)-[:FOLLOWS]->(f)-[:AUTHORED]->(post)
WHERE post.createdAt > datetime() - duration('P7D')
RETURN post ORDER BY post.createdAt DESC LIMIT 20
// Bad: traverses all posts
MATCH (me)-[:FOLLOWS]->(f)-[:AUTHORED]->(post)
RETURN post ORDER BY post.createdAt DESC LIMIT 20

// Better: filter by time first
MATCH (me)-[:FOLLOWS]->(f)-[:AUTHORED]->(post)
WHERE post.createdAt > datetime() - duration('P7D')
RETURN post ORDER BY post.createdAt DESC LIMIT 20

8. 피드 REST 컨트롤러

8. Feed REST Controller

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

private final FeedService feedService;

@GetMapping
public ResponseEntity<Page<FeedItem>> getHomeFeed(
@AuthenticationPrincipal UserDetails user,
@PageableDefault(size = 20) Pageable pageable) {
return ResponseEntity.ok(feedService.getHomeFeed(user.getUsername(), pageable));
}

@GetMapping("/discover")
public ResponseEntity<List<FeedItem>> getDiscoverFeed(
@AuthenticationPrincipal UserDetails user,
@RequestParam(defaultValue = "20") int limit) {
return ResponseEntity.ok(feedService.getDiscoverFeed(user.getUsername(), limit));
}

@GetMapping("/user/{username}")
public ResponseEntity<List<FeedItem>> getProfileFeed(
@PathVariable String username,
@AuthenticationPrincipal UserDetails viewer,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(
feedService.getProfileFeed(username, viewer.getUsername(), page * size, size));
}
}

9. 결론

그래프 데이터베이스는 탐색 패턴이 소셜 그래프 구조를 반영하기 때문에 피드 쿼리를 단순화한다. Pull 모델은 중간 규모에서 잘 작동한다—연결을 탐색하고, 공개 설정으로 필터링하고, 시간순으로 정렬한다. 고트래픽 시나리오에서는 캐싱이나 하이브리드 Push 모델과 결합한다. 이 시리즈의 마지막 포스트에서는 그래프 알고리즘을 사용한 친구 추천을 다룬다.

9. Conclusion

Graph databases simplify feed queries because the traversal pattern mirrors the social graph structure. The pull model works well at medium scale—traverse connections, filter by visibility, order by time. For high-traffic scenarios, combine with caching or hybrid push models. The final post in this series covers friend recommendations using graph algorithms.