💻 뚝딱뚝딱/북북클럽

[개발일지 #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