본문 바로가기
Backend/Spring

[Spring/Java] Paging이란? Page 객체 vs Slice 객체 (+ 코드)

by persi0815 2024. 7. 7.

1. Paging이란? 

Paging은 대량의 데이터를 효율적으로 처리하고 사용자에게 필요한 양만큼씩 나누어 제공하는 기법이다.

 

데이터베이스 쿼리에서 페이징을 구현하면, 전체 데이터 집합을 작은 부분(페이지)으로 분할하여 한 번에 적은 양의 데이터만을 가져오게 된다. 이렇게 하면 메모리 사용을 줄이고, 네트워크 트래픽을 최소화하며, 사용자에게 더 빠른 응답 시간을 제공할 수 있다.


2. Page 객체 vs Slice 객체

두 객체 모두 Spring Data JPA에서 사용되고, 페이징 기능을 제공하지만, 약간의 차이가 있다.

 

Page 객체

전체 페이지 수, 현재 페이지 번호, 페이지 당 항목 수, 총 항목 수, 현재 페이지에 포함된 데이터 목록 등 페이징 관련 정보를 포함한다.

  • 페이지에 대한 추가 정보를 포함한다(예: 전체 페이지 수, 총 항목 수).
  • 특정 페이지로 이동하거나 페이지 번호를 기반으로 항목을 가져올 수 있다.
  • 페이징된 결과의 전체 크기를 계산하기 위해 전체 쿼리가 필요하므로, 추가적인 성능 비용이 발생할 수 있다.
  • 페이지 번호를 1, 2, 3, 4... 등으로 명시하여 데이터를 구분하는 경우에 적합하다. 

 

Slice 객체

전체 페이지 수나 총 항목 수에 대한 정보를 제공하지 않고, 현재 페이지에 포함된 데이터 목록과 다음 페이지가 있는지 여부에 대한 정보만 제공한다.

 

  • 전체 크기를 계산하지 않으므로, Page 객체보다 성능이 더 좋을 수 있다. (count 쿼리가 하나 더 적다)
  • 다음 페이지가 있는지 여부만 제공하여, 필요한 경우 더 많은 데이터를 가져올 수 있다.
  • 전체 데이터의 크기나 총 페이지 수를 알 필요가 없는 경우 유용하다.
  • 무한 스크롤(Infinite Scroll) 또는 "더 보기" 버튼과 같은 방식으로 페이지가 명시되지 않고 순차적으로 데이터를 로드하는 경우에 적합하다. 

3. 코드를 통해 이해하기

 

Page 객체

Controller

@GetMapping("/list") 
public ApiResponse<AssignmentListResDto> list(
        @RequestParam(name = "page") Integer page
){
    List<Assignment> assignmentsByUser = assignmentService.findAll();
    Page<Assignment> assignments = assignmentService.convertAssignmentToPage(assignmentsByUser, page, 5);

    return ApiResponse.onSuccess(SuccessCode.ASSIGNMENT_LIST_VIEW_SUCCESS, AssignmentConverter.assignmentListResDto(assignments));
}

 

5개의 데이터를 하나의 page에 놓도록 고정했고, 페이지 번호는 파라미터로 요청받도록 했다. 

* 페이지 번호는 0부터 시작하는 것으로 설정했다.

 

Service

@Transactional
public Page<Assignment> convertAssignmentToPage(List<Assignment> assignments, Integer page, Integer pageSize){
    int start = page * pageSize; // 현재 페이지의 시작 과제
    int end = Math.min(start + pageSize, assignments.size()); // 현재 페이지의 마지막 과제
    start = Math.min(start, assignments.size() - 1); // 계산된 start이 과제 전체 개수보다 같거나 큰 경우 처리
    List<Assignment> pagedPlans = assignments.subList(start, end); // 리스트의 특정 범위에 해당하는 부분 리스트를 반환

    return new PageImpl<>(pagedPlans, PageRequest.of(page, pageSize), assignments.size());
}

 

Spring Data JPA에서 사용되는 PageImpl 클래스의 생성자를 호출하여 Page 객체를 생성하여 리턴하는 모습을 볼 수 있다. pageable은 현재 페이지와 한 페이지에 들어갈 데이터 개수를 이용해 PageRequest를 통해 만들었다. 

PageImpl(List<T> content, Pageable pageable, long total)
  • content (List<T>): 페이지에 포함된 실제 데이터 목록
  • pageable (Pageable): 페이지 정보(예: 현재 페이지 번호, 페이지 크기 등)를 포함하는 객체
  • total (long): 전체 데이터의 수
public PageImpl(List<T> content, Pageable pageable, long total) {

    super(content, pageable);

    this.total = pageable.toOptional().filter(it -> !content.isEmpty())
            .filter(it -> it.getOffset() + it.getPageSize() > total)
            .map(it -> it.getOffset() + content.size())
            .orElse(total);
}
  • super(content, pageable)호출: PageImpl의 상위 클래스 생성자(Chunk)를 호출하여 기본 페이지 정보를 설정
  • 전체 데이터의 수(total)를 계산하고 설정
    • pageable 객체를 Optional로 변환
    •  content가 비어있지 않은 경우에만 다음 필터를 적용
    • 현재 페이지의 시작 위치(it.getOffset())와 페이지 크기(it.getPageSize())를 합한 값이 total보다 큰 경우(= 현재 페이지 범위가 전체 데이터를 초과하는 경우)에만 다음 맵핑을 적용
    • 위의 필터 조건을 만족하면, 현재 페이지의 시작 위치와 content의 크기를 더한 값을 반환 (-> total값 재설정)
    • 의 조건을 만족하지 않으면, 원래의 total 값을 반환

 => 이렇게 주어진 content, pageable, total 값을 사용하여 PageImpl 객체를 초기화한다.

 

특히, total 값을 재계산하는 로직은 페이지 오프셋과 크기, 실제 content의 크기를 기반으로 한다. 해당 로직은 페이지 범위가 전체 데이터 크기를 초과할 경우, 적절한 total 값을 설정하여 페이지 정보를 정확히 유지하는 역할을 한다. 이렇게 함으로써 클라이언트는 유효한 페이지 범위 내에서 데이터를 요청할 수 있으며, 페이지 끝 범위를 올바르게 조정할 수 있다.

예시) 

전체 데이터 수(total)가 100개이고, page가 9이며 pageSize가 10이라면,
요청된 페이지 범위는 90~99이지만 실제로는 데이터가 부족하여 마지막 페이지가 완전히 채워지지 않을 수 있다. 
it.getOffset() = 90 (9 * 10)
it.getPageSize() = 10
total = 100

만약 content.size()가 5라면 (데이터가 5개만 남아 있는 경우)
it.getOffset() + content.size() = 90 + 5 = 95
위 조건에 따라 total 값이 95로 재설정되어 페이지 범위가 전체 데이터 범위를 초과하지 않도록 보장한다. 

 

 

Slice 객체

Controller

@Operation(summary = "전체 게시판 목록 정보 조회 메서드", description = "type, way에 따라 게시판 목록을 조회하는 메서드입니다.")
@ApiResponses(value = {
        @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "BOARD_2004", description = "게시판 목록 조회가 완료되었습니다.")
})
@Parameters({
        @Parameter(name = "type", description = "조회하고 싶은 게시물 타입, EMOTION: 감정, ACTIVITY: 봉사, CHAT: 잡담, ALL: 전체"),
        @Parameter(name = "way", description = "정렬 방식,  like: 좋아요순, time: 최신순"),
        @Parameter(name = "scrollPosition", description = "데이터 가져올 시작 위치. 0부터 시작. scrollPosition * fetchSize가 첫 데이터 주소"),
        @Parameter(name = "fetchSize", description = "가져올 데이터 크기(게시물 개수)")
})
@GetMapping("/list")
public ApiResponse<BoardListResDto> getListBoards(
        @AuthenticationPrincipal CustomUserDetails customUserDetails,
        @RequestParam(name = "type") String type,
        @RequestParam(name = "way") String way,
        @RequestParam(name = "scrollPosition", defaultValue = "0") int scrollPosition,
        @RequestParam(name = "fetchSize", defaultValue = "1000") int fetchSize
){
    User user = userService.findUserByUserName(customUserDetails.getUsername());
    BoardType boardType = BoardType.valueOf(type);
    List<Board> boards = boardService.getBoardList(boardType, way, scrollPosition, fetchSize);
    return ApiResponse.onSuccess(SuccessCode.BOARD_LIST_VIEW_SUCCESS, BoardConverter.boardListResDto(boards));
}

 

 

위에서 말했듯이 page 객체와 달리,  전체 페이지 수나 총 항목 수에 대한 정보가 필요가 없다. 어디서부터 얼만큼씩 가져올지만 고려하면 된다. 이 둘 다 요청 받도록 했다. 

 

Service

public List<Board> getBoardList(BoardType boardType, String way, int scrollPosition, int fetchSize) {
    Slice<Board> boardSlice = boardRepository.findByBoardTypeAndWay(boardType, way, PageRequest.of(scrollPosition, fetchSize));
    return boardSlice.getContent();
}

 

위에서 받은 게시물 타입, 정렬 방식, 데이터 가져올 시작점, 한번에 가져올 데이터 개수를 받아 Slice 객체를 만든다. 

이전에 pageable은 현재 페이지와 한 페이지에 들어갈 데이터 개수를 이용해 PageRequest를 통해 만들었다. 그때와 동일

한 방식이라 생각하면 된다. 

 

Repository

위와는 달리 특정 종류의 데이터를 특정 방식으로 정렬하여 특정 위치부터 특정 개수만큼 slice 객체에 담아오고 싶었기에 JPQL를 이용하여 쿼리를 작성했다. 

@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {

    @Query("SELECT b FROM Board b WHERE (:boardType = 'ALL' OR b.boardType = :boardType) ORDER BY " + // type이 'ALL'이면 모든 게시판 보이게
            "CASE WHEN :way = 'like' THEN b.likeCount END DESC, " +
            "CASE WHEN :way = 'like' THEN b.createdAt END DESC, " + // 좋아요 수가 같을 때 시간 내림차순 정렬
            "CASE WHEN :way = 'time' THEN b.createdAt END DESC")
    Slice<Board> findByBoardTypeAndWay(@Param("boardType") BoardType boardType, @Param("way") String way, Pageable pageable);
}

 

Spring Data JPA에서 @Query 애노테이션을 사용하여 JPQL 쿼리(사용자 정의 쿼리)를 정의해주었다. 

 

  • :boardType: 'ALL'이면 모든 게시판을 선택하고, 그렇지 않으면 특정 boardType을 선택한다. 
  • :way: 'like' 또는 'time'에 따라 좋아요 수 또는 작성 시간을 기준으로 정렬한다.

 

그리고, pageable 객체를 파라미터로 넘겨 페이징 정보를 전달하여 Spring Data JPA가 메서드의 반환 타입과 쿼리 결과를 바탕으로 Slice 객체를 자동으로 생성하도록 했다. 

 

 

SliceImpl<>은 사용하지 않았지만, 설명하자면

public SliceImpl(List<T> content, Pageable pageable, boolean hasNext) {
    super(content, pageable);
    this.hasNext = hasNext;
}

* hasNext는  boolean 으로 다음 페이지가 있는지 여부를 나타낸다. 나머지 파라미터는 pageImpl과 유사하다. 

 

이렇듯 페이징된 데이터의 결과 중 다음 페이지가 있는지 여부만을 포함하고, 전체 데이터 수를 제공하지 않기에 pageImpl처럼 복잡한 로직은 필요없다. (slice 객체를 사용하는 무한 스크롤의 경우, 전체 페이지 수, 전체 데이터 수 알 필요 없음)