친구, 팔로워, 연결 관계 구현하기 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.
친구, 팔로워, 연결 관계 구현하기
스키마가 준비되었으니 다음 단계는 관계 메커니즘을 구현하는 것이다. 사용자 팔로우, 친구 요청 보내기, 수락하기, 그리고 상호 연결 쿼리를 다룬다.
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에서 여러 조인이 필요한 것이 단일 경로 패턴이 된다. 다음 포스트에서는 이러한 관계를 사용하여 활동 피드를 구축하는 것을 다룬다.
댓글남기기