상품/ 레퍼런스 댓글 관련 기능을 구현하기 위해 Jpa 연관관계 설정과 조회 로직을 구성하였다.
그러나 실제로 API를 호출해본 결과, 단순 조회임에도 불구하고 예상보다 많은 수의 쿼리가 발생하는 것을 확인할 수 있었으며,
사용자의 데이터 양이 증가할수록 쿼리 수가 급격히 늘어나 성능 저하로 이어지는 구조임을 확인할 수 있었다.
N+1 문제란?
-> 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상
기존 코드 방식
public List<CommentResponse> getComments(Long userId, CommentableType type, Long commentableId) {
// 1. 부모 댓글 목록 조회 (쿼리 1회)
List<Comment> parentComments = commentRepository.findParentCommentsByCommentable(type, commentableId);
// 2. 부모 댓글 목록을 순회 (N회 반복)
return parentComments.stream()
.map(parent -> {
// 2-1. 부모 댓글 좋아요 상태 조회 (쿼리 N회 발생)
boolean isLiked = commentLikeRepository.existsByCommentIdAndUserId(parent.getId(), userId);
// 2-2. 해당 부모의 자식 댓글 조회 (쿼리 N회 발생)
List<Comment> children = commentRepository.findChildCommentsByParentId(parent.getId());
// 2-3. 자식 댓글 목록 순회 (총 N * M회 반복)
List<Boolean> childLikedList = children.stream()
.map(child -> commentLikeRepository.existsByCommentIdAndUserId(child.getId(), userId)) // 쿼리 N * M회 발생
.collect(Collectors.toList());
return commentConverter.toDtoWithChildren(parent, userId, isLiked, children, childLikedList);
})
.collect(Collectors.toList());
}
| 단계 | 동작 | 쿼리 횟수 (N=부모댓글 수, M=자식댓글 수) |
| 1. 부모 댓글 조회 | findParentCommentsByCommentable | 1회 |
| 2. 부모 좋아요 상태 조회 | existsByCommentIdAndUserId(parent.getId(), ...) | N회 |
| 3. 자식 댓글 조회 | findChildCommentsByParentId(parent.getId()) | N회 |
| 4. 자식 좋아요 상태 조회 | existsByCommentIdAndUserId(child.getId(), ...) | N * M회 |
| 총 쿼리 수 | 1 + 2N + NM |
- 응답 속도 저하: 댓글 개수가 늘어날수록 쿼리 수가 선형적으로 증가하여 응답 시간이 기하급수적으로 느려진다. (예: 부모 10개, 자식 총 50개면 약 1 + 20 + 50 = 71번 쿼리 실행)
- DB 부하 증가: 짧은 시간 안에 수많은 단순 쿼리가 DB에 집중되어 전체 서비스 성능을 저하시킨다.
총 쿼리 횟수: $1 + N + N + M = 1 + 2N + M... 회
이러한 비효율적인 방식이 N+1 문제이다.
예를 들어, 게시글에 부모 댓글 10개(N=10)와 총 50개의 자식 댓글(M=50)이 있다면 약 71번의 쿼리가 실행되어 성능이 매우 저하된다.
JpaRepository 에서 @Query를 통해 Fetch join을 사용하면, 프록시 객체를 사용하는 대신에 한번에 조회하여 N+1 문제를 해결할 수 있다.
배치 조회(Batch Fetching) 란?
- 데이터를 하나씩 개별적으로 가져오는 대신, 필요한 모든 데이터를 몇번의 큰 쿼리로 한번에 가져와 메모리 상에서 조합하는 전략이다.
@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.commentableType = :type AND c.commentableId = :id AND c.parentComment IS NULL ORDER BY c.createdAt DESC")
List<Comment> findParentCommentsByCommentable(@Param("type") CommentableType type, @Param("id") Long id);
@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.parentComment.id = :parentId ORDER BY c.createdAt ASC")
List<Comment> findChildCommentsByParentId(@Param("parentId") Long parentId);
// 여러 부모 댓글 ID에 속하는 모든 자식 댓글을 한 번에 조회. (N+1 문제 방지)
@Query("SELECT c FROM Comment c JOIN FETCH c.user JOIN FETCH c.parentComment WHERE c.parentComment.id IN :parentIds ORDER BY c.createdAt ASC")
List<Comment> findChildCommentsByParentIds(@Param("parentIds") List<Long> parentIds);
단계별 처리 과정
- 데이터 준비: 쿼리 3회로 parentComments, childrenComments, likedCommentIds (Set)를 가져온다.
- 구조화 (Map 생성): childrenComments를 부모 ID를 키로 하는 Map<Long, List<Comment>>으로 변환하여, 각 부모 댓글에 해당하는 자식 댓글을 O(1) 시간에 찾을 수 있도록 준비한다.
- 반복문 조합:
- for (Comment parent : parentComments) 반복문을 실행한다. (N회)
- 좋아요 확인: likedCommentIds.contains(parent.getId())를 통해 메모리(Set) 상에서 즉시 좋아요 여부를 확인한다. (DB 쿼리 0회)
- 자식 댓글 변환: childrenByParentId Map에서 자식 댓글 리스트를 가져와서, 다시 반복문을 돌며 DTO로 변환하고 좋아요 여부를 메모리에서 확인한다. (DB 쿼리 0회)
- 최종 DTO를 리스트에 담아 반환한다.
핵심은 데이터베이스 접근 횟수를 줄이는 것이다.
결과
| 단계 | 동작 | 쿼리 횟수 |
| 1. 부모 댓글 조회 | findParentCommentsByCommentable | 1회 |
| 2. 모든 자식 댓글 일괄 조회 | findChildCommentsByParentIds(parentIds) | 1회 |
| 3. 모든 댓글의 좋아요 상태 일괄 조회 | findLikedCommentIdsByUserAndCommentIds(...) | 1회 |
| 4. Java 메모리에서 조합 | (반복문 사용) | 0회 |
| 총 쿼리 수 | 3회 (댓글 수와 관계없이 고정) |

이 방식은 댓글 수에 상관없이 총 3번의 쿼리만 실행되도록 보장하여 N+1 문제를 해결 할 수 있다!

'project' 카테고리의 다른 글
| [프로젝트] 주식 체결 알림 기능 추가 - 2 (1) | 2025.01.23 |
|---|---|
| [프로젝트] 주식 체결 알람 기능 추가 - 1 (2) | 2025.01.15 |