친구, 팔로워, 연결 관계 구현하기
SERIES
Neo4j와 Spring Boot로 소셜 네트워크 만들기
친구, 팔로워, 연결 관계 구현하기
스키마가 준비되었으니 다음 단계는 관계 메커니즘을 구현하는 것이다. 사용자 팔로우, 친구 요청 보내기, 수락하기, 그리고 상호 연결 쿼리를 다룬다.
1. 팔로우 서비스
팔로우는 가장 단순한 관계다—한 사용자가 승인 없이 다른 사용자를 팔로우한다.
@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);
}
}팔로워 수가 많을 때 더 나은 성능을 위해 직접 Cypher 쿼리를 사용한다:
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. 친구 요청 워크플로우
친구 관계는 요청-수락 흐름이 필요하다. 관계 엔티티가 상태를 추적한다:
@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
}친구 요청을 포함하도록 User 엔티티를 업데이트한다:
@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<>();
}서비스가 워크플로우를 처리한다:
@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);
}
}리포지토리가 원자적 상태 전환을 처리한다:
@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. 팔로워와 친구 쿼리
기본 카운트와 목록 쿼리:
@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);카운트에는 집계를 사용한다:
@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. 상호 친구
두 사용자 간의 상호 친구를 찾는 것은 그래프 데이터베이스가 뛰어난 영역이다:
@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);패턴 (a)-[:FRIENDS_WITH]-(mutual)-[:FRIENDS_WITH]-(b)는 상호 연결을 통해 탐색한다. 무방향 관계(-[:FRIENDS_WITH]- 화살표 없이)는 엣지가 어느 방향으로 생성되었든 매칭된다.
5. REST 컨트롤러
REST 엔드포인트를 통해 기능을 노출한다:
@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. 사용자 차단
차단은 모든 상호작용을 방지한다:
@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);관계를 쿼리할 때 차단된 사용자를 제외한다:
@Query("""
MATCH (u:User {username: $username})-[:FRIENDS_WITH]-(friend:User)
WHERE NOT (u)-[:BLOCKED]-(friend)
RETURN friend
""")
List<User> findFriendsExcludingBlocked(String username);7. 결론
관계 계층은 소셜 네트워크의 핵심을 형성한다. 팔로우는 간단하지만, 친구 관계는 요청-수락 흐름을 통한 상태 관리가 필요하다. 상호 친구를 위한 그래프 쿼리는 간결하고 성능이 좋다—SQL에서 여러 조인이 필요한 것이 단일 경로 패턴이 된다. 다음 포스트에서는 이러한 관계를 사용하여 활동 피드를 구축하는 것을 다룬다.