그래프 데이터베이스로 활동 피드 구현하기 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.
그래프 데이터베이스로 활동 피드 구현하기
활동 피드는 소셜 네트워크의 핵심이다. 사용자는 친구와 팔로우하는 사람들의 게시물을 시간순 또는 관련성 순으로 본다. 피드는 본질적으로 관계 엣지를 통한 탐색이기 때문에 그래프 데이터베이스가 이를 자연스럽게 처리한다.
1. 피드 생성 전략
피드 생성에는 세 가지 접근 방식이 있다:
Pull 모델: 요청 시 연결을 탐색하여 게시물을 쿼리한다. 구현이 간단하지만, 연결이 많은 사용자에게는 느리다.
Push 모델: 사용자가 게시하면 모든 팔로워의 피드에 기록한다. 읽기는 빠르지만, 쓰기와 저장 비용이 높다.
하이브리드 모델: 활성 사용자에게는 Pull, 캐시된 피드에는 Push. 복잡성과 성능의 균형을 맞춘다.
중간 규모(수백만 사용자)에서는 적절한 인덱싱을 갖춘 Pull 모델이 잘 작동한다. 이 포스트는 Pull 기반 피드에 초점을 맞춘다.
2. 기본 홈 피드 쿼리
홈 피드는 친구와 팔로우하는 사용자의 게시물을 보여준다:
@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는 누군가가 친구이면서 팔로우 대상일 때 중복을 제거한다.
3. 피드 아이템 프로젝션
전체 엔티티를 반환하는 대신 필요한 필드만 프로젝션한다:
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. 피드 서비스 계층
@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. 프로필 피드
사용자의 프로필은 보는 사람과의 관계에 따라 필터링된 게시물을 보여준다:
@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);
공개 설정 로직: 공개 게시물은 항상 보이고, 친구 공개 게시물은 친구 관계가 필요하며, 사용자는 항상 자신의 게시물을 볼 수 있다.
6. 탐색 피드
탐색 피드는 사용자 네트워크 외부의 게시물을 보여준다:
@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);
더 유용한 탐색 피드를 위해 참여도를 고려한다:
@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. 성능 최적화
인덱싱
자주 쿼리되는 필드에 인덱스가 있는지 확인한다:
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);
쿼리 힌트
대규모 그래프의 경우 쿼리 플래너를 안내하는 힌트를 추가한다:
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
탐색 깊이 제한
무제한 탐색을 피한다:
// 나쁨: 모든 게시물을 탐색
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
8. 피드 REST 컨트롤러
@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 모델과 결합한다. 이 시리즈의 마지막 포스트에서는 그래프 알고리즘을 사용한 친구 추천을 다룬다.
댓글남기기