이번 프로젝트에서 댓글-대댓글을 한꺼번에 페이징해야할 일이 생겼다. 한 번 쿼리문을 작성해봅시다...!
테이블 구성은 다음과 같다.

어디서나 흔하게 볼 수 있는 댓글의 엔티티 구조! 처음엔 대댓글을 가져올 때 편하도록 양방향 매핑을 할까 고민을 했으나 성능을 고려하여 단방향 관계로 남겨두었다. 추가로 요구사항은 댓글의 최대 깊이를 1로 제한하고 있고 모바일 앱 기준으로 무한 스크롤 방식의 페이지네이션을 설계하려고 한다.
이제 한 번의 쿼리문으로 이 엔티티에서 댓글-대댓글을 페이지네이션을 하려고 하는데... 어떻게 쿼리문을 구성해야할지 열심히 고민하던 도중 아래 블로그에서 힌트를 얻을 수 있었다.
댓글,대댓글 페이지네이션 구현하기
프로젝트 하던중에 게시판을 만들어야 했다post 테이블과 postComment 테이블이 있고Comment 는 다음과 같다댓글 그리고 대댓글만 제공하기로 하였고replyCommentId는 값이 없으면 부모댓글(depth=0)값이 있
velog.io
1. 시간 순(id 기준) 페이지네이션
FROM community_comments p
LEFT JOIN community_comments r
ON r.parent_id = p.comment_id
간단하게 생각해보면 부모 댓글의 pk와 자식 댓글의 fk를 JOIN하여 쿼리하는 방식이 있다. 하지만 우리가 원하는건 페이지네이션! 대댓글도 날짜별로 정렬하려면 추가적인 정보가 필요하다. 단순하게 시간 순(id 기준)으로 페이지네이션을 진행한다고 했을 때 부모 댓글 A의 자식 댓글이 부모 댓글 B 보다 늦게 달리고 이미 부모 댓글 B까지 응답이 나갔다면 A의 자식 댓글은 누락되는 문제가 발생한다. 마지막으로 본 댓글만으로 페이지네이션을 할 경우 올바르게 구현하기 힘들다.
* LEFT JOIN을 사용하는 이유: 자식 댓글이 없는 부모 댓글도 포함해야하기 때문!
2. 기준을 두 개를 잡자!
부모 댓글 - 자식 댓글을 JOIN 후 마지막으로 본 부모 댓글, 자식 댓글을 모두 받기!
AND (
(:primaryOffset IS NULL) -- 조건 1
OR ( p.comment_id > :primaryOffset ) -- 조건 2
OR ( p.comment_id = :primaryOffset AND r.comment_id > COALESCE(:subOffset, 0) ) -- 조건 3
)
조건1: 첫 페이지 요청시 사용 (primaryOffset이 NULL이면 모든 댓글 조회 시작)
조건2: 이전에 본 마지막 부모 댓글 이후의 새로운 부모 댓글들 (완전히 새로운 부모 댓글 그룹으로 넘어가는 경우)
조건3: 같은 부모 댓글 내에서 아직 보지 않은 자식 댓글들
* COALESCE는 매개변수를 왼쪽부터 확인해서 NULL이 아닌 첫 번째 값을 반환하는 함수
해당 조건으로 쿼리한다면 정상적으로 부모 댓글 - 자식 댓글을 페이지네이션 할 수 있다!
결과 쿼리문
@Query(value = """
SELECT
p.관련한 필드,
r.관련한 필드
FROM community_comments p
LEFT JOIN community_comments r
ON r.parent_id = p.comment_id
AND r.deleted_at IS NULL
LEFT JOIN users pu
ON pu.uid = p.user_id
LEFT JOIN users cu
ON cu.uid = r.user_id
WHERE p.post_id = :postId
AND p.parent_id IS NULL
AND p.deleted_at IS NULL
AND (
(:primaryOffset IS NULL)
OR ( p.comment_id > :primaryOffset )
OR ( p.comment_id = :primaryOffset AND r.comment_id > COALESCE(:subOffset, 0) )
)
ORDER BY p.comment_id ASC, r.comment_id ASC
LIMIT :limit
""", nativeQuery = true
)
List<CommunityCommentDTO.CommentCursorProjection> findCommentByCursor(
@Param("postId") Long postId,
@Param("primaryOffset") Long primaryOffset, // 마지막으로 본 parent.comment_id
@Param("subOffset") Long subOffset, // 동일 parent 아래 마지막으로 본 child.comment_id
@Param("limit") int limit // 페이지 크기
);
유저 정보를 조회해야하기 때문에 조인이 정말 많이 들어간 괴랄한 쿼리문이 완성되었다...
아직 미숙하기 때문에 혹시 왜 이렇게 쿼리문을 구성했지 하는 부분, 개선할 사항이나 피드백은 언제나 환영입니다! 편하게 댓글 주세요!!
'Computer Science > Spring Boot' 카테고리의 다른 글
| JPA N+1 문제 (0) | 2025.09.02 |
|---|---|
| Spring MVC와 Servlet (0) | 2025.08.29 |
| 스프링 컨테이너와 빈(Bean) - Lifecycle Callback과 Scope (0) | 2025.08.24 |
| 스프링 컨테이너와 빈(Bean) - 싱글톤과 의존관계 주입 방법 (0) | 2025.08.21 |
| 스프링 컨테이너와 빈(Bean) - ApplicationContext 동작 과정 (0) | 2025.08.17 |