💻 뚝딱뚝딱/북북클럽
[개발일지 #041] 리팩토링 - (3) Follow 도메인 (단위 테스트, Postman 테스트 포함)
뚜루리
2025. 6. 16. 15:23
728x90
320x100
🎯 오늘의 개발 내용 (요약)
- Follow 엔티티에서 User와의 JPA 연관관계 제거
- FollowCommandService, FollowQueryService로 서비스 분리
- FollowCommandController, FollowQueryController로 서비스 분리
🛠️ 개발내용
Follow
- 기존의 연관관계가 되어있었으나 MSA 환경에서 적절하게 사용하기 위해 연관관계를 제외하였다.
/**
* 사용자 간 팔로우 관계를 나타내는 엔티티
*/
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Follow {
/** 팔로우 고유 ID */
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 팔로우를 거는 사용자 ID (팔로워) */
@Column(nullable = false)
private Long followerId;
/** 팔로우 대상 사용자 ID (팔로잉) */
@Column(nullable = false)
private Long followingId;
/**
* Follow 엔티티 생성 정적 팩토리 메서드
*/
public static Follow of(Long followerId, Long followingId) {
Follow follow = new Follow();
follow.followerId = followerId;
follow.followingId = followingId;
return follow;
}
}
FollowCommandService
- UserSerivce와 마찬가지로 FollowSerivce도 분리함.
/**
* 팔로우/언팔로우 등 상태 변경을 담당하는 서비스 (Command)
*/
@Service
@RequiredArgsConstructor
@Transactional
public class FollowCommandService {
private final FollowRepository followRepository;
/**
* 팔로우 요청
*/
public FollowActionResponse follow(Long followerId, Long followingId) {
validateFollowable(followerId, followingId);
if (followRepository.existsByFollowerIdAndFollowingId(followerId, followingId)) {
throw new FollowException(FollowErrorCode.ALREADY_FOLLOWING);
}
Follow follow = Follow.of(followerId, followingId);
followRepository.save(follow);
return new FollowActionResponse(followingId, true);
}
/**
* 언팔로우 요청
*/
public FollowActionResponse unfollow(Long followerId, Long followingId) {
Follow follow = followRepository.findByFollowerIdAndFollowingId(followerId, followingId)
.orElseThrow(() -> new FollowException(FollowErrorCode.FOLLOW_NOT_FOUND));
followRepository.delete(follow);
return new FollowActionResponse(followingId, false);
}
/**
* 자기 자신을 팔로우하는 경우 예외 처리
*/
private void validateFollowable(Long followerId, Long followingId) {
if (followerId.equals(followingId)) {
throw new FollowException(FollowErrorCode.FOLLOW_ACCESS_DENIED);
}
}
}
FollowCommandServiceTest
package com.bookbookclub.bbc_user_service.follow.service;
import com.bookbookclub.bbc_user_service.follow.dto.FollowActionResponse;
import com.bookbookclub.bbc_user_service.follow.entity.Follow;
import com.bookbookclub.bbc_user_service.follow.exception.FollowException;
import com.bookbookclub.bbc_user_service.follow.repository.FollowRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class FollowCommandServiceTest {
@Mock
private FollowRepository followRepository;
@InjectMocks
private FollowCommandService followCommandService;
@Test
void 팔로우_정상_요청() {
Long from = 1L, to = 2L;
when(followRepository.existsByFollowerIdAndFollowingId(from, to)).thenReturn(false);
FollowActionResponse result = followCommandService.follow(from, to);
verify(followRepository).save(any(Follow.class));
assertThat(result.isFollowing()).isTrue();
assertThat(result.targetUserId()).isEqualTo(to);
}
@Test
void 자기_자신에게_팔로우_요청시_예외() {
assertThatThrownBy(() -> followCommandService.follow(1L, 1L))
.isInstanceOf(FollowException.class);
}
@Test
void 이미_팔로우한_경우_예외() {
when(followRepository.existsByFollowerIdAndFollowingId(1L, 2L)).thenReturn(true);
assertThatThrownBy(() -> followCommandService.follow(1L, 2L))
.isInstanceOf(FollowException.class);
}
@Test
void 언팔로우_정상_요청() {
Follow follow = Follow.of(1L, 2L);
when(followRepository.findByFollowerIdAndFollowingId(1L, 2L)).thenReturn(Optional.of(follow));
FollowActionResponse result = followCommandService.unfollow(1L, 2L);
verify(followRepository).delete(follow);
assertThat(result.isFollowing()).isFalse();
}
@Test
void 존재하지_않는_팔로우_언팔로우시_예외() {
when(followRepository.findByFollowerIdAndFollowingId(1L, 2L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> followCommandService.unfollow(1L, 2L))
.isInstanceOf(FollowException.class);
}
}
FollowQueryService
/**
* 팔로우 상태 조회, 목록 조회를 담당하는 서비스 (Query)
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FollowQueryService {
private final FollowRepository followRepository;
private final UserReader userReader;
/**
* 팔로워 목록 조회
*/
public List<FollowResponse> getFollowers(Long userId) {
List<Long> followerIds = followRepository.findFollowerIdsByUserId(userId);
return toFollowResponseList(userId, followerIds);
}
/**
* 팔로잉 목록 조회
*/
public List<FollowResponse> getFollowings(Long userId) {
List<Long> followingIds = followRepository.findFollowingIdsByUserId(userId);
return toFollowResponseList(userId, followingIds);
}
/**
* 팔로우 여부 확인
*/
public boolean isFollowing(Long fromUserId, Long toUserId) {
return followRepository.existsByFollowerIdAndFollowingId(fromUserId, toUserId);
}
/**
* 유저 ID 목록을 FollowResponse로 변환 (맞팔 여부 포함)
*/
private List<FollowResponse> toFollowResponseList(Long me, List<Long> targetIds) {
return targetIds.stream()
.map(targetId -> {
UserResponse user = userReader.getUserResponse(targetId);
boolean isMutual = followRepository.existsByFollowerIdAndFollowingId(targetId, me);
return FollowResponse.from(user, isMutual);
})
.toList();
}
}
FollowQueryServiceTest
@ExtendWith(MockitoExtension.class)
class FollowQueryServiceTest {
@Mock
private FollowRepository followRepository;
@Mock
private UserReader userReader;
@InjectMocks
private FollowQueryService followQueryService;
private UserResponse user10;
private UserResponse user11;
private UserResponse user20;
@BeforeEach
void setUp() {
user10 = new UserResponse(10L, "a@a.com", "닉네임1", "USER", "소개", "img1");
user11 = new UserResponse(11L, "b@b.com", "닉네임2", "USER", "소개", "img2");
user20 = new UserResponse(20L, "c@c.com", "닉네임3", "USER", "소개", "img3");
}
@Test
void 팔로워_목록_조회() {
Long userId = 1L;
List<Long> followers = List.of(10L, 11L);
when(followRepository.findFollowerIdsByUserId(userId)).thenReturn(followers);
when(userReader.getUserResponse(10L)).thenReturn(user10);
when(userReader.getUserResponse(11L)).thenReturn(user11);
when(followRepository.existsByFollowerIdAndFollowingId(anyLong(), anyLong()))
.thenAnswer(invocation -> {
Long from = invocation.getArgument(0);
Long to = invocation.getArgument(1);
return from.equals(11L) && to.equals(1L); // 11번만 맞팔 처리
});
List<FollowResponse> result = followQueryService.getFollowers(userId);
assertThat(result).hasSize(2);
assertThat(result.get(0).userId()).isEqualTo(10L);
assertThat(result.get(0).isMutual()).isFalse();
assertThat(result.get(1).userId()).isEqualTo(11L);
assertThat(result.get(1).isMutual()).isTrue();
}
@Test
void 팔로잉_목록_조회() {
Long userId = 1L;
List<Long> followings = List.of(20L);
when(followRepository.findFollowingIdsByUserId(userId)).thenReturn(followings);
when(userReader.getUserResponse(20L)).thenReturn(user20);
when(followRepository.existsByFollowerIdAndFollowingId(20L, 1L)).thenReturn(true); // 맞팔
List<FollowResponse> result = followQueryService.getFollowings(userId);
assertThat(result).hasSize(1);
assertThat(result.get(0).userId()).isEqualTo(20L);
assertThat(result.get(0).isMutual()).isTrue();
}
@Test
void 팔로우_여부_조회() {
when(followRepository.existsByFollowerIdAndFollowingId(1L, 2L)).thenReturn(true);
boolean result = followQueryService.isFollowing(1L, 2L);
assertThat(result).isTrue();
}
}
1) 팔로잉
2) 언팔로잉 (실패시)
3) 언팔로잉 (성공시)
4) 팔로잉목록조회
5) 팔로우목록조회
6) 팔로우 여부 확인
728x90
320x100