Pagination(1)

하루·2025년 10월 15일

JAVA

목록 보기
7/8

배경

팀프로젝트에서 무한 스크롤을 구현해야 해야 한다. 그냥 SQL에서 select * 을 사용하면 될 줄 알았는데... table의 row양이 커지면 커질수록 이는 불가능한 선택지이다. 그래서 Pagination이라는 게 사용된다.

개념

Pagination은 위에서 말한 것 처럼 테이블의 모든 것을 한꺼번에 가지고 오는 것은 거의 불가능하고 비효율적이기 때문에, 나눠서 가져오는 것이다.

대용량 데이터를 한번에 가져오는 것이 비효율적이기에, 일정 단위로 나눠서 가져오는 방식이다.

대표적으로 Offset 방식과 Cursor 방식이 있다.
그리고 보통 Offset방식에는 Page객체를, Cursor방식에는 Slice 객체를 사용한다.
(꼭 그러란 법은 없지만, page에 필요한 추가적인 연산을 굳이 Cursor 방식에서 사용할 필요가 없다)

우리가 흔히 보는 페이지 1,2,3 마킹되어있는 웹페이지나, 무한 스크롤이 되는 웹 페이지나 앱에서 보통 Pagination은 필수로 들어간다. (아 그냥 무조건 어디든 들어간다)

1. Offset 방식 vs Cursor 방식

1-1. Offset

  • SQL의 LIMITOFFSET을 활용해서 특정 위치부터 데이터를 가져온다.

    100개의 데이터 중 10개씩 가져올 때, 첫 페이지는 OFFSET 0 LIMIT 10, 두번째는 OFFSET 10 LIMIT 10, 세번째는 OFFSET 20 LIMIT 10 등으로 요청한다.

  • 👍구현이 쉽고, 특정 페이지로 바도 이동이 가능하다.

  • OFFSET 값만큼의 행을 읽고 건너뛰는 작업을 수행하기 때문에, 결국 앞에 있는 모든 행을 읽어야 하므로 성능이 선형적으로 저하된다.

  • 👎실시간 데이터 추가/삭제 시 중복/누락 문제가 발생한다.

  • 👎그렇기에 동적 쿼리, 실시가으로 변하는 데이터에는 적합하지 않다.

1-2. Cursor

  • 마지막으로 조회한 데이터의 고유값(PK, 타임스탬프, 또는 둘의 조합)을 CURSOR로 이용해서, 그 이후 데이터를 가져오는 방식이다.
  • SELECT * FROM post WHERE id > :lastId ORDER BY id LIMIT 10
  • 👍대용량 데이터에서 성능 우수, 실시간 변동 데이터에도 누락 중복/누락 없음
  • 👎페이지 점프가 불가하다.
  • 👎구현이 복잡하다

2. Spring Data JPA로 구현

Spring Data JPA는 페이지네이션을 위한 강력한 추상화를 제공한다. Pageable, Page그리고 Slice, 이 3가지만 알면 된다.

미리 요약 :

  • Pageable : 페이지네이션 요청 정보를 담은 객체(몇 페이지, 몇개, 정렬 방식 등)
  • Page/Slice : 페이지네이션 응답 결과를 담은 객체

2-1. 요청 객체 : Pageable

Pageable 인터페이스는 클라이언트가 요청하는 페이징 및 정렬 정보를 담는 역할을 한다. 중요한 점! Spring Data JPA의 기본 구현체는 Pageable을 OFFSET 방식으로 해석한다는 것이다. (CURSOR 방식으로 사용하기 위해서는 EXTRA JOB이 필요하다)

Pageable 객체가 페이지 번호(page)와 페이지 크기(size)을 받으면 Spring Data JPA가 OFFSET과 LIMIT이 포함된 SQL문을 자동으로 생성한다.

// Pageable pageable = PageRequest.of(2, 10); 요청 시 생성되는 쿼리 예시
SELECT * FROM post ORDER BY created_at DESC LIMIT 10 OFFSET 20;

2-2. 응답객체(1) : Page (OFFSET 방식과 호흡 쫭)

Page는 전체 페이지 수, 전체 데이터 수 등 페이지네이션에 관한 모든 정보를 포함하는 객체이다.

  • getTotalElementsgetTotalPages을 통해 전체 데이터 수와 페이지 수를 알 수 있다. (프론트에서 쓰기 편함)
  • 하지만 이를 위해서 추가적인 COUNT쿼리가 실행되므로 비싸다고 할 수 있다.
  • 번호 기반 페이지네이션에서 쓰인다.

Page 사용 코드 (Offset 기반)

// Controller
public Page<Post> getPosts(@RequestParam int page, @RequestParam int size) {
    Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
    // JPA 기본 메서드인 findAll은 Pageable을 받아 Offset 방식으로 조회 후 Page 객체로 반환
    return postRepository.findAll(pageable);
}

2-3. 응답객체(2) : Slice (Cursor 방식과 호흡 쫭쫭)

Slice는 전체 데이터가 몇 개인지, 총 몇 페이지인지는 신경 쓰지 않고 현재 데이터 묶음과 다음 페이지 존재 여부(hasNext()) 에만 집중한다.

  • 전체 페이지 계산, 항목 수 계산이 없기에 성능이 좋다
  • 전체 데이터의 크기, 페이지 수를 몰라도 될 때 유용하다.
  • 무한 스크롤 또는 더보기 버튼과 같은 방식을 사용할 때 유용하다

만약 위 page 예시에서 public Slice...처럼 리턴 타입만 변경하면 된다고 생각하면 오산.

애초에 findAll(pageable) 메서드는 내부적으로 OFFSET 방식으로 동작하기에, SELECT * FROM post WHERE id > :lastId ...처럼 WHERE 절에 Cursor 조건을 포함한 커스텀 쿼리 메서드를 Repository에 직접 정의해야 합니다.

Slice는 그저 이렇게 조회한 결과를 무한 스크롤에 맞게 포장하는 '용도에 맞는 그릇' 일 뿐.

Slice 인터페이스 코드

public interface Slice<T> extends Streamable<T> {
    int getNumber();             // 현재 페이지 번호
    int getSize();               // 페이지 크기
    int getNumberOfElements();   // 현재 페이지의 데이터 개수
    List<T> getContent();        // 데이터 리스트
    boolean hasContent();        // 데이터 존재 여부
    Sort getSort();              // 정렬 정보
    boolean isFirst();           // 첫 페이지 여부
    boolean isLast();            // 마지막 페이지 여부
    boolean hasNext();           // 다음 페이지 존재 여부
    boolean hasPrevious();       // 이전 페이지 존재 여부
    Pageable getPageable();      // 현재 페이지 정보
    Pageable nextPageable();     // 다음 페이지 정보
    Pageable previousPageable(); // 이전 페이지 정보
    (오류떠서 생략) map(Function<? super T, ? extends U> converter); // 데이터 변환
}

Cursor방식 + Slice 구현 예시

1. Repository: Cursor를 위한 쿼리 메서드 정의
public interface PostRepository extends JpaRepository<Post, Long> {

 // 가장 최신 게시물(첫 페이지 가져옴)
 Slice<Post> findByOrderByIdDesc(Pageable pageable);
 
 // 클라이언트가 전달해준 lastID보다 작은 ID를 가진 게시물(다음 페이지)
 @Query("SELECT p FROM Post p WHERE p.id <: lastId ORDER BY p.id DESC")
 Slice<Post> findByIdLessThanOrderByIdDesc(@Param("lastId") Long lastId, Pageable pageable);
}
2. Service: 요청에 따라 적절한 메서드 호출
@Service
@RequiredArgsConstructor
public class PostService {
 private final PostRepository postRepository;
 
 public Slice<Post> getPostsByCursor(Long lastId, int size) {
  Pageable pageable = PageRequest.of(0, size);
  
  if (lastId == null) {
   return postRepository.findByOrderIdDesc(pageable);
  }else {
   return postRepository.findByIdLessThanOrderByIdDesc(lastId, pageable);
  }
 }
}
3. Controller: 클라이언트로부터 lastId 받기
@RestController
@RequiredArgsConstructor
public class PostController {
 private final PostService postService
 
 @GetMapping("/posts")
 public Slice<Post> getPosts(
   @RequestParam(required = false) Long lastId,
   @RequestParam(defaultValue = "10") int size) }
    return postService.getPostsByCursor(lastId, size);
   }
}

동작 흐름
1. 클라이언트 (첫 요청) : /posts?size=10으로 요청 -> lastId가 없으므로 findByOrderByIdDesc으로 최신 10개 반환
2. 클라이언트 (데이터 수신) : 데이터 100부터 91까지 받음 -> 마지막 id 91 기억
3. 클라이언트 (데이터 요청) :/posts?lastId=91&size-10으로 다음 페이지 요청
4. 서버 : lastId를 받았기에 findByIdLessThanOrderByIDDesc(91, ...) 을 호출
5. Repository에서 @SQL where절 사용

내 프로젝트 사용은 지금 보니까 내가 Page + Cursor를 사용해서, 다 변경하고 나서 정리해야겠당.

0개의 댓글