4 분 소요


Implementing Friends, Followers, and Connections

With the schema in place, the next step is implementing the relationship mechanics. This covers following users, sending friend requests, accepting them, and querying mutual connections.

1. Follow Service

Following is the simplest relationship—one user follows another without requiring approval.

@Service
@Transactional
public class FollowService {

    private final UserRepository userRepository;

    public FollowService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void follow(String followerUsername, String targetUsername) {
        if (followerUsername.equals(targetUsername)) {
            throw new IllegalArgumentException("Cannot follow yourself");
        }

        User follower = userRepository.findByUsername(followerUsername)
            .orElseThrow(() -> new UserNotFoundException(followerUsername));
        User target = userRepository.findByUsername(targetUsername)
            .orElseThrow(() -> new UserNotFoundException(targetUsername));

        if (follower.getFollowing().contains(target)) {
            return; // Already following
        }

        follower.getFollowing().add(target);
        userRepository.save(follower);
    }

    public void unfollow(String followerUsername, String targetUsername) {
        User follower = userRepository.findByUsername(followerUsername)
            .orElseThrow(() -> new UserNotFoundException(followerUsername));
        User target = userRepository.findByUsername(targetUsername)
            .orElseThrow(() -> new UserNotFoundException(targetUsername));

        follower.getFollowing().remove(target);
        userRepository.save(follower);
    }
}

For better performance with large follower counts, use a direct Cypher query:

public interface UserRepository extends Neo4jRepository<User, Long> {

    @Query("""
        MATCH (follower:User {username: $followerUsername})
        MATCH (target:User {username: $targetUsername})
        MERGE (follower)-[:FOLLOWS]->(target)
        """)
    void follow(String followerUsername, String targetUsername);

    @Query("""
        MATCH (follower:User {username: $followerUsername})-[r:FOLLOWS]->(target:User {username: $targetUsername})
        DELETE r
        """)
    void unfollow(String followerUsername, String targetUsername);
}

2. Friend Request Workflow

Friendships require a request-accept flow. The relationship entity tracks the state:

@RelationshipProperties
public class FriendRequest {

    @Id
    @GeneratedValue
    private Long id;

    @TargetNode
    private User targetUser;

    private FriendshipStatus status;
    private LocalDateTime requestedAt;
    private LocalDateTime respondedAt;
}

public enum FriendshipStatus {
    PENDING, ACCEPTED, REJECTED, BLOCKED
}

Update the User entity to include friend requests:

@Node
public class User {
    // ...

    @Relationship(type = "FRIEND_REQUEST", direction = Direction.OUTGOING)
    private Set<FriendRequest> sentRequests = new HashSet<>();

    @Relationship(type = "FRIEND_REQUEST", direction = Direction.INCOMING)
    private Set<FriendRequest> receivedRequests = new HashSet<>();

    @Relationship(type = "FRIENDS_WITH")
    private Set<User> friends = new HashSet<>();
}

The service handles the workflow:

@Service
@Transactional
public class FriendshipService {

    private final UserRepository userRepository;
    private final FriendRequestRepository requestRepository;

    public void sendFriendRequest(String senderUsername, String targetUsername) {
        if (senderUsername.equals(targetUsername)) {
            throw new IllegalArgumentException("Cannot send friend request to yourself");
        }

        User sender = userRepository.findByUsername(senderUsername)
            .orElseThrow(() -> new UserNotFoundException(senderUsername));
        User target = userRepository.findByUsername(targetUsername)
            .orElseThrow(() -> new UserNotFoundException(targetUsername));

        // Check if already friends
        if (sender.getFriends().contains(target)) {
            throw new IllegalStateException("Already friends");
        }

        // Check for existing pending request
        boolean existingRequest = sender.getSentRequests().stream()
            .anyMatch(r -> r.getTargetUser().equals(target)
                       && r.getStatus() == FriendshipStatus.PENDING);
        if (existingRequest) {
            throw new IllegalStateException("Friend request already sent");
        }

        // Check if target already sent a request (auto-accept)
        Optional<FriendRequest> incomingRequest = sender.getReceivedRequests().stream()
            .filter(r -> r.getTargetUser().equals(target)
                     && r.getStatus() == FriendshipStatus.PENDING)
            .findFirst();

        if (incomingRequest.isPresent()) {
            acceptFriendRequest(senderUsername, target.getUsername());
            return;
        }

        FriendRequest request = new FriendRequest();
        request.setTargetUser(target);
        request.setStatus(FriendshipStatus.PENDING);
        request.setRequestedAt(LocalDateTime.now());

        sender.getSentRequests().add(request);
        userRepository.save(sender);
    }

    public void acceptFriendRequest(String accepterUsername, String requesterUsername) {
        // Use Cypher for atomic operation
        userRepository.acceptFriendRequest(accepterUsername, requesterUsername);
    }

    public void rejectFriendRequest(String rejecterUsername, String requesterUsername) {
        userRepository.rejectFriendRequest(rejecterUsername, requesterUsername);
    }
}

The repository handles the atomic state transition:

@Query("""
    MATCH (requester:User {username: $requesterUsername})-[r:FRIEND_REQUEST]->(accepter:User {username: $accepterUsername})
    WHERE r.status = 'PENDING'
    SET r.status = 'ACCEPTED', r.respondedAt = datetime()
    WITH requester, accepter
    MERGE (requester)-[:FRIENDS_WITH]->(accepter)
    MERGE (accepter)-[:FRIENDS_WITH]->(requester)
    """)
void acceptFriendRequest(String accepterUsername, String requesterUsername);

@Query("""
    MATCH (requester:User {username: $requesterUsername})-[r:FRIEND_REQUEST]->(rejecter:User {username: $rejecterUsername})
    WHERE r.status = 'PENDING'
    SET r.status = 'REJECTED', r.respondedAt = datetime()
    """)
void rejectFriendRequest(String rejecterUsername, String requesterUsername);

3. Querying Followers and Friends

Basic queries for counts and lists:

@Query("""
    MATCH (u:User {username: $username})<-[:FOLLOWS]-(follower:User)
    RETURN follower
    ORDER BY follower.username
    SKIP $skip LIMIT $limit
    """)
List<User> findFollowers(String username, int skip, int limit);

@Query("""
    MATCH (u:User {username: $username})-[:FOLLOWS]->(following:User)
    RETURN following
    ORDER BY following.username
    SKIP $skip LIMIT $limit
    """)
List<User> findFollowing(String username, int skip, int limit);

@Query("""
    MATCH (u:User {username: $username})-[:FRIENDS_WITH]-(friend:User)
    RETURN friend
    ORDER BY friend.username
    SKIP $skip LIMIT $limit
    """)
List<User> findFriends(String username, int skip, int limit);

For counts, use aggregation:

@Query("""
    MATCH (u:User {username: $username})
    OPTIONAL MATCH (u)<-[:FOLLOWS]-(follower)
    OPTIONAL MATCH (u)-[:FOLLOWS]->(following)
    OPTIONAL MATCH (u)-[:FRIENDS_WITH]-(friend)
    RETURN count(DISTINCT follower) as followerCount,
           count(DISTINCT following) as followingCount,
           count(DISTINCT friend) as friendCount
    """)
ConnectionCounts getConnectionCounts(String username);

4. Mutual Friends

Finding mutual friends between two users is where graph databases excel:

@Query("""
    MATCH (user1:User {username: $username1})-[:FRIENDS_WITH]-(mutual:User)-[:FRIENDS_WITH]-(user2:User {username: $username2})
    WHERE user1 <> user2
    RETURN mutual
    ORDER BY mutual.username
    """)
List<User> findMutualFriends(String username1, String username2);

@Query("""
    MATCH (user1:User {username: $username1})-[:FRIENDS_WITH]-(mutual:User)-[:FRIENDS_WITH]-(user2:User {username: $username2})
    WHERE user1 <> user2
    RETURN count(mutual) as count
    """)
int countMutualFriends(String username1, String username2);

The pattern (a)-[:FRIENDS_WITH]-(mutual)-[:FRIENDS_WITH]-(b) traverses through the mutual connection. The undirected relationship (-[:FRIENDS_WITH]- without arrows) matches regardless of which direction the edge was created.

5. REST Controller

Expose the functionality through REST endpoints:

@RestController
@RequestMapping("/api/users")
public class ConnectionController {

    private final FollowService followService;
    private final FriendshipService friendshipService;
    private final UserRepository userRepository;

    @PostMapping("/{username}/follow")
    public ResponseEntity<Void> follow(
            @PathVariable String username,
            @AuthenticationPrincipal UserDetails currentUser) {
        followService.follow(currentUser.getUsername(), username);
        return ResponseEntity.ok().build();
    }

    @DeleteMapping("/{username}/follow")
    public ResponseEntity<Void> unfollow(
            @PathVariable String username,
            @AuthenticationPrincipal UserDetails currentUser) {
        followService.unfollow(currentUser.getUsername(), username);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/{username}/friend-request")
    public ResponseEntity<Void> sendFriendRequest(
            @PathVariable String username,
            @AuthenticationPrincipal UserDetails currentUser) {
        friendshipService.sendFriendRequest(currentUser.getUsername(), username);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/friend-requests/{requesterUsername}/accept")
    public ResponseEntity<Void> acceptFriendRequest(
            @PathVariable String requesterUsername,
            @AuthenticationPrincipal UserDetails currentUser) {
        friendshipService.acceptFriendRequest(currentUser.getUsername(), requesterUsername);
        return ResponseEntity.ok().build();
    }

    @GetMapping("/{username}/friends")
    public ResponseEntity<List<UserDto>> getFriends(
            @PathVariable String username,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        List<User> friends = userRepository.findFriends(username, page * size, size);
        return ResponseEntity.ok(friends.stream().map(UserDto::from).toList());
    }

    @GetMapping("/{username}/mutual-friends/{otherUsername}")
    public ResponseEntity<List<UserDto>> getMutualFriends(
            @PathVariable String username,
            @PathVariable String otherUsername) {
        List<User> mutuals = userRepository.findMutualFriends(username, otherUsername);
        return ResponseEntity.ok(mutuals.stream().map(UserDto::from).toList());
    }
}

6. Blocking Users

Blocking prevents all interactions:

@Query("""
    MATCH (blocker:User {username: $blockerUsername})
    MATCH (blocked:User {username: $blockedUsername})
    MERGE (blocker)-[:BLOCKED]->(blocked)
    WITH blocker, blocked
    OPTIONAL MATCH (blocker)-[f:FOLLOWS]->(blocked) DELETE f
    OPTIONAL MATCH (blocked)-[f2:FOLLOWS]->(blocker) DELETE f2
    OPTIONAL MATCH (blocker)-[fr:FRIENDS_WITH]-(blocked) DELETE fr
    """)
void blockUser(String blockerUsername, String blockedUsername);

When querying relationships, exclude blocked users:

@Query("""
    MATCH (u:User {username: $username})-[:FRIENDS_WITH]-(friend:User)
    WHERE NOT (u)-[:BLOCKED]-(friend)
    RETURN friend
    """)
List<User> findFriendsExcludingBlocked(String username);

7. Conclusion

The relationship layer forms the core of a social network. Following is straightforward, while friendships require state management through the request-accept flow. Graph queries for mutual friends are concise and performant—what would be multiple joins in SQL becomes a single path pattern. The next post covers building activity feeds using these relationships.

댓글남기기