친구, 팔로워, 연결 관계 구현하기 Implementing Friends, Followers, and Connections
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.
댓글남기기