Project/Boilerplate

Cursor 페이지네이션에서의 정렬, 필터링

조용우 2025. 3. 10. 20:58

현재 모든 게시글을 불러올 때, 댓글 개수를 따로 조회하고 합치는 방식으로 구현되어 있음.
이 방식의 문제점과 해결 방법을 살펴보자.


1. 기존 방식의 문제점

현재는 게시글을 먼저 불러오고, 댓글 개수를 따로 조회하여 합치는 방식을 사용하고 있다.

기존 코드

@Query("SELECT p FROM Post p " +
    "LEFT JOIN FETCH p.user " +
    "WHERE (:cursor IS NULL OR p.id < :cursor) " +
    "ORDER BY p.id DESC " +
    "LIMIT :limit")
List<Post> findAllPostsByCursor(@Param("cursor") Long cursor, @Param("limit") int limit);
@Query("SELECT c.post.id, COUNT(c) FROM Comment c WHERE c.post.id IN :postIds GROUP BY c.post.id")
List<Object[]> findCommentCountsByPostIds(@Param("postIds") List<Long> postIds);

 

이 방식의 동작 방식

  1. findAllPostsByCursor(cursor, limit) → 페이지네이션된 게시글 리스트 조회
  2. findCommentCountsByPostIds(postIds) → 해당 게시글들의 댓글 개수를 별도 조회
  3. 두 결과를 매핑하여 각 게시글에 댓글 개수를 합쳐서 반환

문제점

  • 댓글 개수를 동적으로 불러와야 하기 때문에, 댓글이 추가/삭제될 경우 Cursor 정렬이 어긋날 가능성이 있음.
  • 예시(좋아요 50개 이상(필터) + 댓글 많은 순(정렬)) 기준으로 게시글을 가져오는 경우 정합성이 깨질 수 있음.
  • 추가적인 쿼리 실행이 필요하여 성능 부담이 증가함.

2. 해결 방법: Post 테이블에 comment_count 필드 추가

댓글 개수를 Post 테이블에 저장하고 댓글이 추가/삭제될 때 comment_count 필드를 업데이트하면,
게시글을 조회할 때 추가적인 쿼리 없이 댓글 개수를 가져올 수 있다.

추가적으로, 예시(좋아요 50개 이상 + 댓글 많은 순) 기준으로 게시글 가져오는 경우에도, Cursor 기반 정렬을 유지할 수 있다.


3. Post 엔티티 수정 (댓글 개수 필드 추가)

Post 엔티티 변경

@Column(name = "comment_counts")
@ColumnDefault(value = "0")
@Builder.Default
private Long commentCounts = 0L;

...

public void increaseCommentCounts() {
    commentCounts++;
}

public void decreaseCommentCounts() {
    if (commentCounts > 0) {
        commentCounts--;
    }
}

이 필드를 추가하면 Post 테이블에서 댓글 개수를 즉시 조회 가능

Post 엔티티에 필드 추가 및 메소드 생성

 

이제 댓글이 추가되면 increaseCommentCounts()가 호출되고, 삭제되면 decreaseCommentCounts()가 호출됨.

public CommentResponse create(Long userId, Long postId, String content, Long parentCommentId) {
    ...
    post.increaseCommentCounts();
    return CommentResponse.from(commentRepository.save(comment));
}

public void delete(Long userId, Long commentId) {
    ...
    post.increaseCommentCounts();
    commentRepository.deleteById(commentId);
}

 


6. PostService 리팩토링 (더 깔끔해진 버전)

이제 댓글 개수를 별도 쿼리로 불러올 필요 없이, 바로 Post 테이블에서 가져올 수 있음.

 

Offset 기반 페이지네이션

@Transactional(readOnly = true)
public Page<PostSummaryResponse> getAllPostsByPage(Pageable pageable) {
    return postRepository.findAllPostSummariesByPage(pageable)
        .map(PostSummaryResponse::from);
}

 

 

Cursor 기반 페이지네이션 (최적화된 방식)

@Transactional(readOnly = true)
public CursorResponse<PostSummaryResponse> getAllPostsByCursor(Long cursor, int size) {
    // 커서 기반 조회
    List<Post> posts = postRepository.findAllPostsByCursor(cursor, size + 1);

    // hasNext 확인을 위해 size + 1개를 조회했으므로, 실제 응답에는 size개만 포함
    boolean hasNext = posts.size() > size;
    if (hasNext) {
        posts = posts.subList(0, size);
    }

    // 다음 커서는 마지막 게시글의 ID
    Long nextCursor = hasNext && !posts.isEmpty() ? posts.getLast().getId() : null;
    return new CursorResponse<>(posts.stream().map(PostSummaryResponse::from).toList(), nextCursor, hasNext);
}

8. 최종 정리

기존 개선된 방식
댓글 개수를 따로 조회 Post 테이블에서 바로 commentCounts 조회
findCommentCountsByPostIds() 필요 ❌ 불필요
댓글 개수가 변하면 정렬이 어긋날 가능성 ✅ Cursor 정렬 유지 가능
별도 쿼리 실행으로 성능 부담 성능 최적화 (불필요한 쿼리 제거)

결론

  • 이제 댓글 개수를 별도로 조회할 필요 없이 Post 테이블에서 바로 가져올 수 있음.
  • Cursor 기반 페이지네이션에서도 댓글 개수 정렬이 유지되며, 데이터 정합성 문제가 해결됨.
  • 불필요한 쿼리를 줄여 성능을 최적화함 -> 하지만 댓글 쓰기/삭제 때 추가 쿼리가 생성되기에 TRADE OF