https://changbroblog.tistory.com/213
게시글의 댓글을 어떻게 불러와야 할까? - N+1 문제의 늪(1)
게시글에 달린 댓글을 조회하는 기능을 구현하게 되었다.단순히 생각하면 별 것 아닌 기능처럼 보이지만, 막상 구현에 들어가 보니 생각보다 고민할 부분이 많았다.왜냐하면 "대댓글" 때문이다.
changbroblog.tistory.com
전 포스팅에 이어서 n+1을 어떤 식으로 해결했는지 보자
내가 선택한 방법은 JPQL + fetch join 조합이다!
필요한 연관 엔티티를 한 번에 join해서 가져오는 쿼리를 직접 작성했다
@Query("""
SELECT c
FROM comments c
JOIN FETCH c.user
LEFT JOIN FETCH c.parentComment
WHERE c.post = :post
ORDER BY c.createdAt
""")
List<Comment> findAllByPostWithUserAndParent(@Param("post") Post post);
다들 아시겠지만 쿼리를 대충 설명하면
SELECT c FROM comments c -> 댓글 테이블 (comments)에서 모든 댓글 c를 선택
JOIN FETCH c.user -> 댓글과 관련된 사용자 정보를 함께 가져오기 위해 join fetch 사용
LEFT JOIN FETCH c.parentComment -> 대댓글의 부모 댓글도 미리 조회
(부모 댓글이 없는 최상위 댓글일 수도 있으니 LEFT JOIN!!!)
INNER JOIN을 쓰면 parentComment가 null인 댓글은 제외된다!
WHERE c.post = :post -> 게시물이 특정 게시물과 일치하는 댓글들을 필터링
ORDER BY c.createdAt -> 오래된 댓글이 가장 위로 되게 정렬
대충 이해가 되시나요?
사실 QueryDSL과 JPQL 사이에서 고민을 했지만
QueryDSL은 너무 과하다고 생각이 들었다.
복잡하지 않고 단순 조회 + 한 번 쓰는 쿼리 에는 에서는 JPQL이 QueryDSL보다 훨씬 빠르고 직관적이라는 판단이 들었다.
이렇게 해서 N+1 문제는 해결했지만
이젠 댓글 데이터들을 들고 와서 부모 댓글 밑에 자식 댓글들을 묶는 방식으로 가공해서 클라이언트에게 줘야겠죠?

저는 이런 식으로 구현을 했습니다.
public List<CommentResponseDto> getCommentsByPostId(Long postId) {
// 1. 댓글 + 작성자 + 부모 댓글 fetch join으로 모두 조회
List<Comment> comments = commentRepository.findAllByPostWithUserAndParent(post);
// 2. 로그인 유저가 좋아요 누른 댓글 ID 조회
List<Long> commentIds = comments.stream().map(Comment::getId).toList();
Set<Long> likedIds = commentLikeRepository.findAllByCommentIdInAndUser(commentIds, user)
.stream()
.map(like -> like.getComment().getId())
.collect(Collectors.toSet());
// 3. 댓글을 DTO로 변환해서 Map에 저장
Map<Long, CommentResponseDto> dtoMap = new HashMap<>();
for (Comment comment : comments) {
boolean liked = likedIds.contains(comment.getId());
dtoMap.put(comment.getId(), CommentResponseDto.from(comment, liked));
}
// 4. 댓글 - 대댓글 트리 구성
List<CommentResponseDto> result = new ArrayList<>();
for (Comment comment : comments) {
Long parentId = comment.getParentComment() != null ? comment.getParentComment().getId() : null;
if (parentId == null) {
result.add(dtoMap.get(comment.getId())); // 최상위 댓글
} else {
dtoMap.get(parentId).getChildren().add(dtoMap.get(comment.getId())); // 대댓글
}
}
return result;
}
이게 정답인진 모르겠습니다.
제가 한 방식은
1. 들고 온 Comment List를 일단 CommentResponseDto로 변환해서 Map에 담기
(key: commentId -> value: commentResponseDto)
2. 댓글 - 대댓글 관계 구성
여기가 좀 어려웠는데
차근차근 설명해 볼게요!
Long parentId = comment.getParentComment() != null ? comment.getParentComment().getId() : null;
parentId == null -> 부모 댓글이 없다 = 최상위 댓글
parentId != null -> 부모 댓글이 있다 = 대댓글
만약 parentId == null이라면 (부모가 없는 댓글)
result.add(dtoMap.get(comment.getId()));
얘는 최상위 댓글이기 때문에 최종 반환할 result 리스트에 바로 추가
만약 parentId != null (대댓글)
CommentResponseDto parentDto = dtoMap.get(parentId);
부모 댓글의 DTO를 dtoMap에서 꺼내고
parentDto.getChildren().add(dtoMap.get(comment.getId()));
부모 DTO의 childern 리스트에 추가
이렇게 반복이 끝나면
result 리스트에 최상위 댓글들이 포함되어 return
대댓글은 최상위 댓글 각각의 .getChildren() 안에 들어가 있다!
이해가 되시나요??

많이 헷갈리긴 합니다..
댓글과 대댓글 구분해서 response 보내는 거 확인하고
마무리할게요!