ysk(0soo)
Lifealong
ysk(0soo)
전체 방문자
오늘
어제
  • 분류 전체보기 (238)
    • Java (50)
      • whiteship-java-study (11)
      • Java (28)
      • time (6)
    • Spring (68)
      • JPA (15)
      • Spring (1)
      • SpringBoot (1)
      • SpringMVC (6)
      • Spring Security (22)
      • Jdbc (1)
      • RestDocs (14)
      • log (6)
    • Kotlin (3)
    • Web (2)
      • nginx (1)
    • Database (14)
      • MySQL (5)
      • PostgreSQL (1)
      • SQL (1)
      • Redis (4)
    • C, C++ (0)
    • Git (1)
    • Docker (2)
    • Cloud (3)
      • AWS (3)
    • 도서, 강의 (0)
      • t5 (0)
    • 기타 (7)
      • 프로그래밍 (1)
    • 끄적끄적 (0)
    • CS (14)
      • 운영체제(OS) (2)
      • 자료구조(Data Structure) (9)
    • 하루한개 (12)
      • 우아한 테크코스-10분테코톡 (12)
    • 스터디 (12)
      • 클린 아키텍처- 로버트마틴 (2)
      • JPA 프로그래밍 스터디 (10)
    • 테스트 (34)
      • JUnit (19)
      • nGrinder (2)
      • JMeter (0)
    • Infra (3)
    • 프로그래머스 백엔드 데브코스 3기 (0)
    • 디자인 패턴 (3)
    • Issue (4)
    • system (1)
      • grafana (0)
      • Prometheus (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록
  • github

공지사항

인기 글

태그

  • tree
  • 구조화된 동시성
  • FilterSecurityInterceptor
  • VirtualThread Springboot
  • 동일성
  • 가상 스레드
  • querydsl
  • node exporter basic auth
  • jpa
  • mysql
  • java
  • 가상 스레드 예외 핸들링
  • AccessDecisionVoter 커스텀
  • AuthenticationException
  • StructuredConcorrency
  • DataJpaTest
  • 트랜잭션
  • 정규표현식
  • scope value
  • restdocs custom
  • 동시성 제어
  • nginx basic auth
  • restdocs enum
  • AccessDecisionManager
  • nGrinder
  • junit5
  • UserDetailsService
  • 인가(Authorization) 처리
  • LocalDateTime
  • 동등성

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
ysk(0soo)

Lifealong

Spring/JPA

Spring Data Jpa Cursor based Pagenation (커서 기반 페이지네이션) 예제 (JpaRepository 커서 기반 페이지네이션

2022. 12. 14. 16:58

환경

  • Spring boot 2.7.6
  • hibernate 5.6.4
  • QueryDsl를 사용하면 더 편리하겠지만, Spring Data Jpa Repository Interface만을 이용하여 조회하는 예제이다.
  • Repository는 Interface이고, Interface에 페이징을 계산하는 비즈니스 연산을 넣는것은 옳지 않다고 생각되어 Service Class에서 연산하여 Repository에 위임한다.

 

Post라는 게시글의 cursor를 postId로 가정하고, postId와 createdAt의 역순으로 조회하는 커서 기반 페이지네이션 예이다.

이 때, 다음 게시물이 있는지 없는지 여부를 표현하기 위해 응답객체에 hasNext를 추가했다.

 

  • Post Class
@Entity
@Table(name = "posts")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    @NotBlank
    @Size(min = 1)
    private String title;

    @Column(nullable = false)
    @NotBlank
    private String content;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    private Long createdBy;

}
  • 예제를 간단하게 하기 위해 연관관계 및 AbstractCreatedColumn(BaseEntity 역할)을 지우고 그냥 필드로 넣었다.
  • PostService
@Service
public class PostService {

    private final PostRepository postRepository;

    @Transactional(readOnly = true)
    public PostResponses findAllByIdCursorBased(Long cursorId, int pageSize) {

        Pageable pageable = PageRequest.of(0, pageSize + 1);

        List<Post> posts = findAllByCursorIdCheckExistsCursor(cursorId, pageable);

        boolean hasNext = hasNext(posts.size(), pageSize);

        return new PostResponses(
            toSubListIfHasNext(hasNext, pageSize, posts).stream()
                .map(PostResponse::new)
                .collect(Collectors.toList()),
            cursorId, hasNext);
    }

    private List<Post> findAllByCursorIdCheckExistsCursor(Long cursorId, Pageable pageable) {
        return cursorId == null ? postRepository.findAllByOrderByIdDesc(pageable)
            : postRepository.findByIdLessThanOrderByIdDescCreatedAtDesc(cursorId, pageable);
    }

    private boolean hasNext(int postsSize, int pageSize) {
        if (postsSize == 0) {
            throw new EntityNotFoundException(Post.class);
        }

        return postsSize > pageSize;
    }

    private List<Post> toSubListIfHasNext(boolean hasNext, int pageSize, List<Post> posts) {
        return hasNext ? posts.subList(0, pageSize) : posts;
    }

}
  • PostRepository
public interface PostRepository extends JpaRepository<Post, Long> {

    List<Post> findAllByOrderByIdDescCreatedAtDesc(Pageable pageable);

    List<Post> findByIdLessThanOrderByIdDescCreatedAtDesc(@Param("id") long id, Pageable pageable);

}
  • 쿼리메소드가 끝이다.
  • findAllByOrderByIdDescCreatedAtDesc : 모든것을 조회하되 Id랑 CreatedAt 을 Desc 로 정렬
  • findByIdLessThanOrderByIdDescCreatedAtDesc : Id 보다 작은것을 조회하되, Id랑 CreatedAt 을 Desc 로 정렬

코드 설명

private List<Post> findAllByCursorIdCheckExistsCursor(Long cursorId, Pageable pageable) {
        return cursorId == null ? postRepository.findAllByOrderByIdDescCreatedAtDesc(pageable)
            : postRepository.findByIdLessThanOrderByIdDescCreatedAtDesc(cursorId, pageable);
    }

커서 기반 페이지네이션은, 커서가 없을때는 (커서가 null일때) 최근데이터 (혹은 가장 오래된 데이터)를 조회해야 한다.

그래서 cursorId가 null일때 와 null이 아닐때 를 나누어 쿼리하도록 했다.

  • findAllByOrderByIdDescCreatedAtDesc 과 findByIdLessThanOrderByIdDescCreatedAtDesc는 쿼리 메소드 네이밍으로 만든 JpaRepository 메서드이다.

 

findAllByOrderByIdDescCreatedAtDesc 의 실행 쿼리 - cursorId가 null일때

Hibernate: 
    select
        post0_.id as id1_0_,
        post0_.created_at as created_2_0_,
        post0_.created_by as created_3_0_,
        post0_.content as content4_0_,
        post0_.title as title5_0_,
        post0_.user_id as user_id6_0_ 
    from
        posts post0_ 
    order by
        post0_.id desc,
        post0_.created_at desc limit ?

 

findByIdLessThanOrderByIdDescCreatedAtDesc의 실행 쿼리 = cursorId가 null이 아닐 때

Hibernate: 
    select
        post0_.id as id1_0_,
        post0_.created_at as created_2_0_,
        post0_.created_by as created_3_0_,
        post0_.content as content4_0_,
        post0_.title as title5_0_,
        post0_.user_id as user_id6_0_ 
    from
        posts post0_ 
    where
        post0_.id<? 
    order by
        post0_.id desc,
        post0_.created_at desc limit ?

 

HasNext

@Service
public class PostService {

    private final PostRepository postRepository;

    @Transactional(readOnly = true)
    public PostResponses findAllByIdCursorBased(Long cursorId, int pageSize) {

        Pageable pageable = PageRequest.of(0, pageSize + 1);

        List<Post> posts = findAllByCursorIdCheckExistsCursor(cursorId, pageable);

        boolean hasNext = hasNext(posts.size(), pageSize);

        return new PostResponses(
            toSubListIfHasNext(hasNext, pageSize, posts).stream()
                .map(PostResponse::new)
                .collect(Collectors.toList()),
            cursorId, hasNext);
    }

      ...

    private boolean hasNext(int postsSize, int pageSize) {
        if (postsSize == 0) {
            throw new EntityNotFoundException(Post.class);
        }

        return postsSize > pageSize;
    }
        ...
}

  • Pageable pageable = PageRequest.of(0, pageSize + 1); 이부분이 다음 페이지가 있는지 알 수 있는 핵심이다
    • 1개가 아니여도 일부러 몇개를 더 조회해서, 요청한 pageSize 와 비교를 해서 다음 페이지가 있는지 여부를 알 수 있다.
    • 데이터 조회한 갯수가 요청한 페이지 수 보다 많다면 다음 페이지가 존재
    • 데이터 조회한 갯수가 요청한 페이지 수 보다 적다면 다음 페이지가 존재하지 않고 마지막 페이지 인것이다.
  • postsSize는 조회한 데이터 List의 Size인데, 조회한 데이터가 없다면 404를 응답하기위해 예외를 던졌다.
  • 만일 예외를 던지지 않으려면, 그냥 false를 리턴해도 된다.

 

toSubListIfHasNext

private List<Post> toSubListIfHasNext(boolean hasNext, int pageSize, List<Post> posts) {
        return hasNext ? posts.subList(0, pageSize) : posts;
}
  • 다음 페이지가 있는지 여부(hasNext)를 알기 위해 일부러 몇개 더 조회했으니, 다음 페이지가 있다면 요청한 페이지 사이즈 보다 데이터가 많을것이다.
  • 그러므로 요청한 페이지 수만큼 데이터를 맞춰서 돌려줘야 한다.
  • 현재 예제에서 클라이언트가 만약 10개를 요청했고, 데이터가 10개보다 많다면, 11개를 조회했으니 요청한 10개만큼 잘라서 돌려줘야 한다.
  • 데이터가 클라이언트 가 요청한 수 보다 작다면, 그냥 조회 한 만큼 돌려주면 되는것이다.
  • 이 때 정렬 순서에 따라 잘라야 하는 위치가 달라질 수 있으니까 꼭 테스트를 해봐야 한다.
저작자표시 비영리 (새창열림)

'Spring > JPA' 카테고리의 다른 글

Jpa 쿼리 파라미터 로그 확인방법 - With DataJpaTest p6spy  (1) 2022.12.17
영속성 전이(Cascade) ManyToOne 시 주의할점 - 상위 엔티티 삭제 문제  (1) 2022.12.15
JPA Entity의 Field Data Type은 primitive, Wrapper 중 어떤것을 사용해야 할까?  (0) 2022.12.10
@NotNull vs @Column(nullable = false), JPA에서 INSERT 전에 Null 검사하는 방법  (0) 2022.12.09
JpaRepository에서 save시 select 쿼리가 먼저 실행되는 이유  (0) 2022.12.07
    'Spring/JPA' 카테고리의 다른 글
    • Jpa 쿼리 파라미터 로그 확인방법 - With DataJpaTest p6spy
    • 영속성 전이(Cascade) ManyToOne 시 주의할점 - 상위 엔티티 삭제 문제
    • JPA Entity의 Field Data Type은 primitive, Wrapper 중 어떤것을 사용해야 할까?
    • @NotNull vs @Column(nullable = false), JPA에서 INSERT 전에 Null 검사하는 방법
    ysk(0soo)
    ysk(0soo)
    백엔드 개발을 좋아합니다. java kotlin spring, infra 에 관심이 많습니다. email : kim206gh@naver.com github : https://github.com/devysk

    티스토리툴바