본문 바로가기
Project

[newsfeed] Like 기능의 성능 개선하기

by 어떻게말이름이히힝 2024. 12. 30.

고민되는 코드는 CommentLike 부분으로

여러 검증을 위해 Post와 Comment, User까지 조회를 하고 있다.

아래는 내가 직접 작성한 코드인데 클라이언트로부터 PathVariable로 postId와 commentId를 받고 있다.

userId는 세션을 비교하고 있다.

해당 기능의 흐름은이렇다.

 

1. 해당되는 게시글 찾기

2. 해당되는 댓글 찾기

3. 댓글을 단 유저와 현재 접속하고 있는 유저가 동일한지 확인(본인의 게시글에 좋아요를 누를 수 없음)

4. 게시글을 작성한 유저와 친구인지 아닌지 확인(본인이면 넘어감)

5. comment가 해당 post에 달린게 맞는지 확인

6. 현재 유저가 이 코멘트에 좋아요를 누른 기록이 있는지 확인

6-1. 있다면 해당 행 제거, 댓글 엔티티에 있는 좋아요 카운트 감소

6-2. 없다면 데이터베이스에 추가, 댓글 엔티티에 있는 좋아요 카운트 증가 

@Transactional
    public String pushCommentLike(Long postId, Long commentId, Long userId) {
        Post post = postRepository.findPostByIdOrThrow(postId);

        Comment comment = commentRepository.findByIdOrElseThrow(commentId);

        //본인의 게시글에 좋아요를 누를 수 없음
        if (comment.getUser().getUserId().equals(userId)) {
            throw new CannotLikeOwnContentException();
        }

        verifyFriends(post, userId);

        //해당 포스트의 코멘트가 아닐때
        if (!comment.getPost().getId().equals(post.getId())) {
            throw new IdValidationNotFoundException(postId);
        }

        User currentUser = userRepository.findByUserIdOrElseThrow(userId);

        if (commentLikeRepository.existsByUserAndComment(currentUser, comment)) {
            CommentLike commentLike = commentLikeRepository.findByUser_UserIdAndComment_Id(currentUser.getUserId(), comment.getId());
            commentLikeRepository.delete(commentLikeRepository.findByIdOrElseThrow(commentLike.getId()));
            comment.setLikeCount(comment.getLikeCount() - 1);
            commentRepository.save(comment);
            return "좋아요 취소";
        }

        CommentLike commentLike = new CommentLike(currentUser, comment);
        commentLikeRepository.save(commentLike);

        comment.setLikeCount(comment.getLikeCount() + 1);
        commentRepository.save(comment);

        return "좋아요";
    }

 

 

기존에 사용하던 repository 코드

public interface CommentLikeRepository extends JpaRepository<CommentLike, Long> {

    long countByComment_Id(Long id);

    CommentLike findByUser_UserIdAndComment_Id(Long userId, Long commentId);

    boolean existsByUserAndComment(User user, Comment comment);

    default CommentLike findByIdOrElseThrow(Long commentId) {
        return findById(commentId).orElseThrow(()-> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

}

 

 

JPQL 패치 조인을 사용하여 N+1문제를 최소화

유저아이디와 댓글아이디를 통해 CommentLike를 찾는 쿼리에 패치조인 적용

public interface CommentLikeRepository extends JpaRepository<CommentLike, Long> {

    @Query("select COUNT(cl) FROM CommentLike cl WHERE cl.comment.id = :commentId AND cl.user.userId = :userId")
    long countByComment_Id(Long id);

    @Query("SELECT cl FROM CommentLike cl JOIN FETCH cl.user u JOIN FETCH cl.comment c WHERE c.id = :commentId AND u.userId = :userId")
    CommentLike findByUser_UserIdAndComment_Id(Long userId, Long commentId);

    @Query("SELECT CASE WHEN COUNT(cl)>0 THEN TRUE ELSE FALSE END FROM CommentLike cl WHERE cl.user=:user AND cl.comment=:comment")
    boolean existsByUserAndComment(User user, Comment comment);

    default CommentLike findByIdOrElseThrow(Long commentId) {
        return findById(commentId).orElseThrow(()-> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

}

 

소규모 데이터엔 첫번째 코드가 더 적합하나 데이터가 많을 경우 두번째 코드가 더 적합

 

1번 코드는 JPA 쿼리는 기본적으로 지연로딩을 사용해서 연관된 엔티티는 필요할때만 로드됨

하지만 지연로딩으로 인해 N+1문제가 발생할 수 있고

복잡한 쿼리는 메서드 네이밍 규칙으로 표기하기 어려움

 

2번 코드는 JPQL와 JOIN FETCH를 활용해서 불필요한 데이터베이스 호출을 줄이고 데이터를 한번에 가져올 수 있음

다만 간단하게 카운트를 센다거나 할 경우 즉시로딩이 필요 없다.

 

3차 코드

public interface CommentLikeRepository extends JpaRepository<CommentLike, Long> {

    long countByComment_Id(Long id);

    @Query("SELECT cl FROM CommentLike cl JOIN FETCH cl.user u JOIN FETCH cl.comment c WHERE c.id = :commentId AND u.userId = :userId")
    CommentLike findByUser_UserIdAndComment_Id(Long userId, Long commentId);

    boolean existsByUserAndComment(User user, Comment comment);

    default CommentLike findByIdOrElseThrow(Long commentId) {
        return findById(commentId).orElseThrow(()-> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

}

 

이러면 앞뒤 코드의 문제는 사라지지만

추가 최적화 방안이 있다. DTO를 사용해서 정말 필요한 정보만 조회하는 방법이다.

3번코드에선 모든 열을 불러왔지만 여기서는 필요한 열만 불러옴으로써 메모리 사용량을 줄인다.

@Query("SELECT new com.example.dto.CommentLikeDTO(cl.id, u.userId, c.id) " +
       "FROM CommentLike cl " +
       "JOIN cl.user u " +
       "JOIN cl.comment c " +
       "WHERE c.id = :commentId AND u.userId = :userId")
CommentLikeDTO findCommentLikeDTO(Long userId, Long commentId);

 

 

 

+ 즉시로딩을 사용하지말고 지연로딩을 사용하고, FETCH JOIN을 통해 문제를 해결해야함.

 

+ 여기서는 @ManyToOne을 사용한 단방향 관계로만 구성을 했지만

@OneToMany의 페이징의 경우는 또 다름.

저렇게 패치조인을 사용해서 페이징을 사용하게 되면 메모리에 한번에 불러와버려서 

이경우에는 BatchSize 방법을 쓰거나 @ManyToOne을 사용해야함.