[Spring] 팔로우 기능 구현 (JPA Native Query)

peace w·2024년 4월 16일
0
post-thumbnail

팀 프로젝트를 진행하면서 팔로우 기능을 구현했다. 구현하면서 여러 시행착오를 겪었기에 기록해둔다.

테이블 작성 쿼리

CREATE TABLE follows (
                         id BIGINT AUTO_INCREMENT PRIMARY KEY,
                         to_user_id VARCHAR(255) NOT NULL, -- 팔로우를 넣는 사람
                         from_user_id VARCHAR(255) NOT NULL, -- 팔로우를 받는 사람
                         UNIQUE (to_user_id, from_user_id) -- 동일한 팔로우 관계를 중복으로 생성하지 못하게 함
);

맨 처음에 follower, following 으로 칼럼명을 작성했으나 작업 중에 너무 헷갈려서 이렇게 바꾸었다.

영어랑 실제 뜻이랑 반대긴 하나... 칼럼명을 또 바꾸려고 하니 모든 메서드를 수정해야해서 기능 구현에만 중점을 두었다..ㅠㅠ 덕분에 프로젝트를 시작할 때 구조를 잘 설계하는 것의 중요성을 느꼈다.

기존에 작성한 팔로워, 팔로잉 리스트 쿼리

레퍼지토리

특정 유저가 팔로우하고있는(또는 특정 유저를 팔로우 중인) 리스트를 먼저 뽑고,
리스트의 유저를 로그인한 유저가 팔로우하고 있는지를 따로 검증했다.

public interface FollowRepository extends JpaRepository<Follow, Long> {
 	// a가 팔로우한 유저의 리스트를 뽑기
    List<Follow> findByToUser(User toUser);

    // b를 팔로우한 유저의 리스트를 뽑기
    List<Follow> findByFromUser(User fromUser);
    }

	// a가 b를 팔로우하는지 확인하기
    boolean existsByToUserAndFromUser(User toUser, User fromUser);

서비스

@Service
@RequiredArgsConstructor
public class FollowService {
    private final FollowRepository followRepository;
    private final UserRepository userRepository;

	// 팔로우중인지 확인하기
    public boolean isFollowing(User toUser, User fromUser) {
        return followRepository.existsByToUserAndFromUser(toUser, fromUser);
    }

	// 로그인한 유저의 팔로우 상태 확인 + 마이페이지 유저가 팔로잉한 유저리스트를 뽑기
    public List<FollowDTO> findFollowingsByFollowerWithFollowState(User loggedInUser, User toUser) {
        List<FollowDTO> followingList = followRepository.findByToUser(toUser).stream()
                .map(follow -> followingMapper(follow, loggedInUser))
                .collect(Collectors.toList());
        return followingList;
    }

    // 로그인한 유저의 팔로우 상태 확인 + 마이페이지 유저를 팔로우한 유저리스트를 뽑기
    public List<FollowDTO> findFollowersByFollowingWithFollowState(User loggedInUser, User fromUser) {
        List<FollowDTO> followersList = followRepository.findByFromUser(fromUser).stream()
                .map(follow -> followerMapper(follow, loggedInUser))
                .collect(Collectors.toList());
        return followersList;
    }


    // 팔로우 상태를 함께 매핑하여 FollowDTO 객체 생성
    private FollowDTO followerMapper(Follow follow, User loggedInUser) {
        boolean isFollowing = isFollowing(loggedInUser, follow.getToUser()); // 내가 팔로우하고 있는지 판단

        return FollowDTO.builder()
                .id(follow.getId())
                .toUser(userInfoMapper(follow.getToUser()))
                .fromUser(userInfoMapper(follow.getFromUser()))
                .followState(isFollowing ? 1 : 0) // 팔로우 상태 설정
                .build();
    }

    private FollowDTO followingMapper(Follow follow, User loggedInUser) {
        boolean isFollowing = isFollowing(loggedInUser, follow.getFromUser()); // 내가 팔로우하고 있는지 판단

        return FollowDTO.builder()
                .id(follow.getId())
                .toUser(userInfoMapper(follow.getToUser()))
                .fromUser(userInfoMapper(follow.getFromUser()))
                .followState(isFollowing ? 1 : 0) // 팔로우 상태 설정
                .build();
    }

DTO

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FollowDTO {
    private Long id;
    private UserInfoResponse toUser;
    private UserInfoResponse fromUser;
    private int followState; // 내가 팔로우 중인지
}

시간에 쫓겨가며 프로젝트를 하고 필요하다 싶으면 그때 그때 추가한터라 변수명이 제멋대로다...^^

기능은 잘 돌아가지만 이렇게 작성하게되면 리스트에 있는 유저마다 팔로우하고 있는지를 검증하는 쿼리가 나오게 되므로 비효율적이다.
테스트중인 상태에서는 유저가 적었고, 팔로우 DB의 데이터 양도 많지 않아 괜찮았지만 팔로잉 팔로우를 100명 이상 하게되고 그런 유저가 100명이 넘는다면 DB의 부담은 엄청나질 수 밖에 없다.

그래서 한번에 조회하고자 쿼리를 변경했다.

변경한 팔로워, 팔로잉 리스트 쿼리

쿼리를 어떻게 바꿔야할지 고민하고 있었는데, 팀원의 도움을 받아 left join을 사용한 쿼리로 변경했다.

select f1.*,
		IF(f2.from_user_id IS NOT NULL, 1, 0) AS follow_state,
       IF(f1.from_user_id = 'test1', 1, 0) AS same_user_state
from follows f1
left join follows f2 on f1.from_user_id = f2.from_user_id and f2.to_user_id = 'test1' -- test1가 로그인 하고 있을 때 --
where f1.to_user_id = 'testtest1'; -- testtest1의 팔로워 리스트에서 test1이 팔로우하고 있는 사람인지 판단 --

SELECT f1.*, 
       IF(f2.from_user_id IS NOT NULL, 1, 0) AS follow_state,
       IF(f1.from_user_id = 'testtest1', 1, 0) AS same_user_state
FROM follows f1
LEFT JOIN follows f2 ON f1.from_user_id = f2.from_user_id AND f2.to_user_id = 'testtest1' -- test2가 로그인 하고 있을 때 --
WHERE f1.to_user_id = 'test1'; -- test1의 팔로잉 리스트에서 testtest1이 팔로우하고 있는 사람인지 판단 --

로그인한 유저와 같다면 same_user_state에 1로 표시되고
팔로워,팔로잉 리스트에 로그인한 유저가 팔로우하고 있는 사람이라면 follow_state에 1로 표시된다.

DBeaver에서 먼저 쿼리를 조회해보도록 하자.

test1유저의 팔로워리스트 조회

testtest1유저의 팔로잉리스트 조회

제대로 출력되는 게 확인되었으니 FollowRepository에 팔로잉,팔로워리스트 조회 쿼리를 수정했다.
Native Query를 사용해서 작성해주었다.

※ Spring Data JPA Native Query 작성 시 주의점

  • 네이티브 쿼리로는 if문이 작성이 안 되는 듯하다. 오류가 생겨서 case 문으로 바꾸어주었다.
  • 별칭(Alias) 으로 컬럼을 만들어서 사용할 경우, 서버에서 작성한 변수명과 통일시켜주자!
  • 여러 테이블을 조인한 경우에는 엔티티에 존재하지 않는 컬럼을 필요로 하므로, 인터페이스를 만들어 매핑시켜준다. (DTO로 바로 매핑되지 않으며, 하려고 하면 오류가 발생한다. 인터페이스를 서비스 단에서 DTO로 매핑해주자.)

스프링 사용 시, 스네이크 케이스로 작성한 테이블 컬럼도 카멜 케이스로 자동 변환해주는 기능 (예 : hello_java -> helloJava)에 익숙해져서 별칭도 그렇게 될거라고 생각했는데 계속 값이 null로 받아와져서 서비스 단에 문제가 생긴 줄 알고 계속 수정했는데 인터페이스에 설정한 변수명과 별칭이 달라서였다..


구현 중에 팔로잉,팔로워 리스트의 유저가 로그인 중인 사용자를 팔로우하고 있는지 확인하고, 팔로우중이라면 "나를 팔로우 중입니다." 라는 문구를 띄워주고 싶었다. 그래서 LEFT JOIN 을 하나 더 추가하여 쿼리를 작성했다.
LEFT JOIN만으로 구현하는게 효율적인 방법인지는 좀 더 알아봐야겠다.

필요한 값은 이러하다.
특정 유저(마이페이지 주인)의 팔로잉,팔로우 리스트

  1. 리스트의 유저 중에 로그인 한 유저와 같은 유저가 있는지 확인
  2. 리스트의 유저 중에 로그인 한 유저가 팔로우하는 유저가 있는지 확인
  3. 리스트의 유저 중에 로그인 한 유저를 팔로우하는 유저가 있는지 확인

레퍼지토리

@Query(value = "SELECT f1.id AS id, " +
            "f1.to_user_id AS toUser, " +
            "f1.from_user_id AS fromUser, " +
            "CASE WHEN f2.from_user_id IS NOT NULL THEN 1 ELSE 0 END AS followingState, " +
            "CASE WHEN f1.from_user_id = :loggedInId THEN 1 ELSE 0 END AS sameUserState, " +
            "CASE WHEN f3.from_user_id IS NOT NULL THEN 1 ELSE 0 END AS followedState " +
            "FROM follows f1 " +
            "LEFT JOIN follows f2 ON f1.from_user_id = f2.from_user_id AND f2.to_user_id = :loggedInId " +
            "LEFT JOIN follows f3 ON f1.from_user_id = f3.to_user_id AND f3.from_user_id = :loggedInId " +
            "WHERE f1.to_user_id = :myPageId", nativeQuery = true)
    List<FollowProjection> showFollowingList(@Param("loggedInId") String loggedInId, @Param("myPageId") String myPageId);

    @Query(value = "SELECT f1.id AS id, " +
            "f1.to_user_id AS toUser, " +
            "f1.from_user_id AS fromUser, " +
            "CASE WHEN f2.to_user_id IS NOT NULL THEN 1 ELSE 0 END AS followingState, " +
            "CASE WHEN f2.to_user_id = :loggedInId THEN 1 ELSE 0 END AS sameUserState, " +
            "CASE WHEN f3.to_user_id IS NOT NULL THEN 1 ELSE 0 END AS followedState " +
            "FROM follows f1 " +
            "LEFT JOIN follows f2 ON f1.to_user_id = f2.from_user_id AND f2.to_user_id = :loggedInId " +
            "LEFT JOIN follows f3 ON f1.to_user_id = f3.to_user_id AND f3.from_user_id = :loggedInId " +
            "WHERE f1.from_user_id = :myPageId", nativeQuery = true)
    List<FollowProjection> showFollowerList(@Param("loggedInId") String loggedInId, @Param("myPageId") String myPageId);


}

인터페이스 DTO 추가

public interface FollowProjection {
    Long getId();
    String getToUser(); // 팔로우 하는 사람
    String getFromUser(); // 팔로우 받는 사람
    Integer getFollowingState(); // 내가 팔로우 하는지
    Integer getSameUserState(); // 같은 유저인지
    Integer getFollowedState(); // 나를 팔로우 하는지
}

쿼리문으로 얻고자 하는 값들을 인터페이스로 선언한 dto에 getter로 선언해주어야한다.

서비스

// 팔로잉 리스트를 찾기
public List<FollowDTO> showFollowingListWithFollowState(String loggedInId, String myPageId) {
        List<FollowProjection> followingList = followRepository.showFollowingList(loggedInId, myPageId);
        return mapToFollowDTOList(followingList);
    }

// 팔로워 리스트를 찾기
    public List<FollowDTO> showFollowerListWithFollowState(String loggedInId, String myPageId) {
        List<FollowProjection> followerList = followRepository.showFollowerList(loggedInId, myPageId);
        return mapToFollowDTOList(followerList);
    }
    
    
    private List<FollowDTO> mapToFollowDTOList(List<FollowProjection> followList) {
        return followList.stream()
                .map(follow -> FollowDTO.builder()
                        .id(follow.getId())
                        .toUser(userInfoMapper(follow.getToUser()))
                        .fromUser(userInfoMapper(follow.getFromUser()))
                        .followingState(follow.getFollowingState())
                        .sameUserState(follow.getSameUserState())
                        .followedState(follow.getFollowedState())
                        .build())
                .collect(Collectors.toList());
    }
    
profile
더 성장하자.

0개의 댓글