Project/Boilerplate

게시글 Pagination 적용 (1 / 2 - 리스트형 게시판)

조용우 2025. 3. 10. 15:31

현 상황

현재 데이터베이스에는 다음과 같은 데이터 존재

  • 유저(User): 50명
  • 게시글(Post): 10,000개
  • 댓글(Comment): 각 게시글당 5개씩, 총 50,000개

기존 성능 문제

 

기존 방식으로 전체 게시글을 조회하면 총 15,791ms 소요

 

또한, 10,000개의 게시글 모두 불러옴

 

기존 쿼리 분석

기존에는 LEFT JOIN FETCH를 사용하여 게시글과 모든 댓글을 한 번에 불러오는 방식

@Query("SELECT DISTINCT p FROM Post p " +
    "LEFT JOIN FETCH p.comments c " +
    "LEFT JOIN FETCH c.user " +
    "LEFT JOIN FETCH p.user")
List<Post> findAllWithComments();

 

문제점

  • 모든 댓글을 불러와서 프론트에서 Comment[].length를 확인하여 개수를 표시
  • 불필요한 정보(댓글 내용, 작성자 정보 등)까지 포함됨 → 데이터 전송량 증가
  • 게시글 1개당 댓글이 5개이므로, 중복된 게시글 데이터가 5배 이상 조회됨 → 성능 저하

최적화 방향

  1. 댓글 개수는 별도의 쿼리로 조회 (필요한 데이터만 불러오기)
  2. 작성자 정보는 PostUserResponse로 간소화 (username, name만 포함)
  3. 페이징을 적용하여 데이터 전송량 최소화

1. DTO 설계

게시글 응답을 위한 DTO

게시글 정보와 댓글 개수를 포함하는 PostSummaryResponse를 정의

@Getter
@AllArgsConstructor
@Builder
public class PostSummaryResponse {

    private Long id;
    private String title;
    private String content;
    private int likes;
    private PostUserResponse user;
    private long commentCount;
    private LocalDateTime createdAt;
    private LocalDateTime modifiedAt;

    public static PostSummaryResponse from(Post post, Long commentCount) {
        return PostSummaryResponse.builder()
            .id(post.getId())
            .title(post.getTitle())
            .content(post.getContent())
            .likes(post.getLikes())
            .user(PostUserResponse.from(post.getUser()))
            .commentCount(commentCount)
            .createdAt(post.getCreatedAt())
            .modifiedAt(post.getModifiedAt())
            .build();
    }
}

작성자 정보 DTO

게시글 목록에서는 username과 name만 필요하므로 최소한의 정보만 포함

@Getter
@Builder
public class PostUserResponse {

    private Long id;
    private String username;
    private String name;

    public static PostUserResponse from(User user) {
        return PostUserResponse.builder()
            .id(user.getId())
            .username(user.getUsername())
            .name(user.getName())
            .build();
    }
}

 

이유:

  • author 필드를 따로 추가할 수도 있지만, 작성자 클릭 시 프로필 페이지 이동을 위해 username을 함께 보냄.
  • 불필요한 User 엔티티 전체 조회를 방지하여 성능 최적화.

2. 댓글 개수만 조회하는 최적화된 쿼리

기존에는 모든 댓글을 가져왔지만, 이제 COUNT()를 활용하여 개수만 가져오기

@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);

변경된 점

  • 기존: LEFT JOIN FETCH를 사용하여 모든 댓글을 조회 → 데이터 과다 로딩 문제 발생
  • 개선: COUNT()를 사용하여 댓글 개수만 조회 → 데이터 전송량 및 조회 시간 감소

3. 최적화된 게시글 조회 쿼리

게시글 목록 조회 시 LEFT JOIN FETCH를 최소화하여 불필요한 데이터 로딩 방지

@Query("SELECT DISTINCT p FROM Post p " +
    "LEFT JOIN FETCH p.user u " +
    "ORDER BY p.createdAt DESC")
Page<Post> findAllPostSummaries(Pageable pageable);

변경된 점

  • p.user는 LEFT JOIN FETCH로 가져오지만, comments는 조회하지 않음
  • 페이징을 적용하여 한 번에 불러오는 데이터 개수 제한 (Pageable 사용)

4. 서비스 코드 적용

@Transactional(readOnly = true)
public Page<PostSummaryResponse> getAllPosts(Pageable pageable) {
    Page<Post> posts = postRepository.findAllPostSummaries(pageable);
    List<Long> postIds = posts.stream().map(Post::getId).toList();

    // 현재 조회 중인 게시글들에 대한 댓글 개수 조회
    List<Object[]> commentCounts = commentRepository.findCommentCountsByPostIds(postIds);
    Map<Long, Long> commentCountMap = commentCounts.stream()
        .collect(Collectors.toMap(
            obj -> (Long) obj[0],  // postId
            obj -> (Long) obj[1]   // commentCount
        ));

    return posts.map(post -> {
        long commentCount = commentCountMap.getOrDefault(post.getId(), 0L);
        return PostSummaryResponse.from(post, commentCount);
    });
}

변경된 점

  • 게시글을 먼저 조회한 후, 해당 postIds에 대해서만 댓글 개수 조회 → 성능 최적화
  • 불필요한 데이터 로딩 없이 PostSummaryResponse로 변환하여 반환

5. 컨트롤러에서 페이징 적용

@GetMapping
public ResponseEntity<Page<PostSummaryResponse>> getAllPosts(
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "10") int size
) {
    return ResponseEntity.ok(
        postService.getAllPosts(PageRequest.of(page, size))
    );
}

변경된 점

  • 기본 페이지 크기는 10개로 설정 (size=10)
  • PageRequest.of(page, size)를 사용하여 Pageable 적용
  • 페이징된 Page<PostSummaryResponse> 객체를 그대로 반환

최종 성능 개선 결과

기존 방식 vs 최적화 후

전체 조회 시간 15,791ms  9ms 
조회 데이터 크기 게시글의 모든 요소 포함 게시글 + 간소화 유저정보 + 댓글 개수만 조회
페이징 적용 ❌ (전체 데이터 로딩) ✅ (페이지 단위로 불러옴)

결과 화면:


다음 목표

Cursor를 이용한 Infinity Scroll 방식의 페이징을 구현할 예정

게시글 Pagination 적용 (2 / 2 - Infinity Scroll 게시판)