그래프 데이터베이스로 활동 피드 구현하기 Building Activity Feeds with Graph Database
Building Activity Feeds with Graph Database
The activity feed is the heart of any social network. Users see posts from friends and people they follow, sorted by time or relevance. Graph databases handle this naturally since feeds are essentially traversals through relationship edges.
1. Feed Generation Strategies
Three approaches exist for feed generation:
Pull Model: Query posts on demand by traversing connections. 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.
For moderate scale (millions of users), the pull model with proper indexing works well. This post focuses on pull-based feeds.
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);
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. Feed Item Projection
Rather than returning full entities, project only needed fields:
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. 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. 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);
The visibility logic: public posts are always visible, friends-only posts require a friendship, and users always see their own posts.
6. Discover Feed
A discover feed shows posts from 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, factor in 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. 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:
// Bad: traverses all posts ever
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. 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. Conclusion
Graph databases simplify feed queries because the traversal pattern mirrors the social graph structure. The pull model works well at moderate scale—traverse connections, filter by visibility, sort by time. For high-traffic scenarios, combine with caching or a hybrid push model. The final post in this series covers friend recommendations using graph algorithms.
댓글남기기