If at first you don't succeed, try again

[트러블슈팅] 커서 기반 무한 스크롤의 댓글 수, 좋아요 수를 조회하는 쿼리에서 발생했던 성능 문제(feat. N+1) 본문

개발/트러블슈팅

[트러블슈팅] 커서 기반 무한 스크롤의 댓글 수, 좋아요 수를 조회하는 쿼리에서 발생했던 성능 문제(feat. N+1)

웅지니어링 2025. 6. 4. 17:26

* 문제 상황

public List<PostResponse> findPostsInfiniteScroll(final CustomUserDetails userDetails, boolean tagExist, Long lastPostId, final Integer pageSize) {
    ...
    return posts.stream()
            .map(post -> {
                Long commentCount = commentRepository.findByPostId(post.getPostId());
                Long likeCount = likeCountRepository.findByPostId(post.getPostId());
                Long likeYn = likeRepository.findByPostIdAndUserId(post.getPostId(), post.getUser().getUserId());
                return PostResponse.from(post, commentCount, likeCount, likeYn);
            })
            .toList();
}

게시글 피드 조회를 위해 무한 스크롤을 구현 중이었는데,

반복문을 통해 게시물, 댓글 수, 좋아요 수, 좋아요 여부를 가져오고 있었다.

구현한 후 서버 실행을 하고, show-sql을 통해 쿼리문을 보며 이 코드는 효율적이지 못하다는걸 알 수 있었다.

성능적으로 DB에 부하를 일으킨다. 왜일까?

 

* 원인

우선 해당 코드의 동작 흐름은 이렇다.

  1. List<Post> posts = postRepository.findAllByPostIds(postIds); 를 통해 쿼리가 1번 실행된다.
  2. Long commentCount = commentRepository.findByPostId(post.getPostId()); 를 통해 쿼리가 반복 횟수(n)만큼 실행된다.
  3. Long likeCount = likeCountRepository.findByPostId(post.getPostId()); 를 통해 쿼리가 반복 횟수(n)만큼 실행된다.
  4. Long likeYn = likeRepository.findByPostIdAndUserId(post.getPostId(), post.getUser().getUserId()); 를 통해 쿼리가 반복 횟수(n)만큼 실행된다.

즉 게시물이 N개 일 때, 3N + 1만큼 실행된다는 뜻이다.

게시물이 10000개 존재한다고 가정한다면, 30001번 실행된다.

조회 로직 API 1번 호출에 실행되는 쿼리가 30000개가 넘는다? 비효율적이다.

 

* 해결 방안

이를 해결하기 위해 반복문을 실행하기 전, IN 조건으로 일괄적으로 데이터를 조회했다(Bulk).

그리고 여기에 JPA에서 지원하는 Projection을 사용한다.

Projection은 클래스 타입을 지정해서 필요한 필드만 추출할 수 있도록 제공해준다.

또한 여러 Projection 객체를 만들어 select 시에 추출하는 필드를 변경할 수 있다.

public interface CommentCountProjection {
    Long getPostId();
    Long getCommentCount();
}
public interface LikeCountProjection {
    Long getPostId();
    Long getLikeCount();
}
public interface LikeProjection {
    Long getPostId();
    Boolean getLikeYn();
}

이렇게 원하는 쿼리 결과를 주입한다. 그리고 Map 형식으로 매핑해준다.

public List<PostResponse> findPostsInfiniteScroll(final CustomUserDetails userDetails, boolean tagExist, Long lastPostId, final Integer pageSize) {
        ...
        Map<Long, Long> commentCountMap = commentRepository.countsByPostIds(postIds).stream()
                .collect(Collectors.toMap(CommentCountProjection::getPostId, CommentCountProjection::getCommentCount));
                
        Map<Long, Long> likeCountMap = likeCountRepository.findLikeCountByPostIds(postIds).stream()
                .collect(Collectors.toMap(LikeCountProjection::getPostId, LikeCountProjection::getLikeCount));
                
        Map<Long, Boolean> likeYnMap = likeRepository.findLikeYnByPostIdsAndUserId(postIds, userId).stream()
                .collect(Collectors.toMap(LikeProjection::getPostId, LikeProjection::getLikeYn));

        return posts.stream()
                .map(post -> {
                    Long commentCount = commentCountMap.getOrDefault(post.getPostId(), 0L);
                    Long likeCount = likeCountMap.getOrDefault(post.getPostId(), 0L);
                    Boolean likeYn = likeYnMap.getOrDefault(post.getPostId(), false);
                    return PostResponse.from(post, commentCount, likeCount, likeYn);
                })
                .toList();
    }

반복문 내의 Map에서 get하는 형식이므로 사실상 실행되는 쿼리는 4개인 셈이다.

따라서 Projection을 통해 3N + 1 → N + 1개로 쿼리 실행 개수를 줄여 성능을 개선할 수 있다.