4 분 소요


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.

댓글남기기