본문 바로가기
데이터 접근 기술/JPA

JPQL Pagination

by oncerun 2022. 10. 30.
반응형

우선 페이징에 관한 여러 관련 지식들을 복습하자.

 

페이징을 하기 위해선 다음과 같은 정보가 필요하다. 

 

1. 한 페이지에 출력될 데이터 수

2. 한 화면에 출력될 페이지 수

3. 현재 페이지 번호 

 

우선 전체적인 페이지 수를 알아야 한다.

이를 위해 데이터를 몇 개씩 가져올지에 대한 변수를 정의한다.

public int createTotalPage(int limit){
 int totalData = getTotalData();
 int totalPage = totalData / limit;
 float remainder = totalData % limit;
 if ( remainder > 0 ) {
 	totalPage += 1;
 }
return totalPage;
}
public int getTotalData(...);

 

 총 데이터 개수에서 보일 데이터를 나누면 대략적인 페이지 수가 나옵니다. 

하지만 나머지를 고려하여 0보다 큰 경우 나머지를 담을 페이지를 하나 더 해주어야 합니다.

 

api를 통한 페이징을 처리하는 경우 해당 마지막 페이지보다 큰 페이지를 요청할 경우 마지막 데이터를 반환하는 것도 좋은 방법입니다.

 

페이지 네이션에도 다양한 종류가 있는 것 같다. 그중 내가 아는 것은 다음과 같은 용어로 불린다.

 

Offset-Based Pagination 

 

REST API에서 페이징을 통한 데이터를 요청할 때 요청 파라미터와 응답 데이터는 어떤 형식을 띠는 것이 좋을까?

 

Request

Name Type Description
page  int 현재 페이지
limit int 페이지에서 전달할 데이터 개수
...    

 

위 두 개의 요청 파라미터는 필수 값으로 보인다. 그 외는 요청하는 클라이언트에서 요구하는 조건에 따라 늘어날 것 같다.

뭐 특정 칼럼에 대한 정렬 정도?

 

Response

Name Type Description
result Array 조회데이터
currentPage int 현재페이지
totalDataCount int 총 데이터 수
totalPages int 총 페이지 수
requestDataSize int 요청 페이지 데이터 수

 

프런트 같은 경우도 총데이터와 보일 데이터 수만 알면 페이징을 만들 수 있기 때문에 이 정도면 적당한가?

 

이 방식은 가장 대중적으로 많이 알려져 있는 방법이다. 필자도 이 방법만 알고 있었다. 

 

찾아보니 오프셋 방식보다는 커서 방식을 성능상 선호한다고 한다. 

 

offset방법은  조회할 전체 데이터를 정렬한 후 limit로 내가 조회하고 싶은 만큼의 데이터 양을 정한 다음 offset으로 skip 할 데이터 양을 정하는 방법이다. 

 

그래서 페이지 번호가 들어왔을 때  ( page - 1)  * limit을 통해 offset을 구한다. 

 

offset이 0인 경우는 처음부터 찾고 쿼리에서는 자동적으로 제외된다. 

 

Spring Data JPA는 Pageable , Slice 인터페이스를 사용하면 정말 쉽게 페이징이 완료되고 쿼리 메서드를 통해 최대 크기 조절도 가능하다.

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.special-parameters

 

Spring Data JPA - Reference Documentation

Example 109. Using @Transactional at query methods @Transactional(readOnly = true) interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") void del

docs.spring.io

 

이 방식은 성능적으로 문제가 존재한다. offset은 데이터베이스가 정렬된 데이터를 SKIP 하는 쿼리인데 SKIP 한다는 전제조건은 결국 해당 데이터 수만큼 조회한다는 것이다. 

 

또한 실시간으로 데이터가 빠르게 추가되는 경우 offset based pagination은 적절하지 않다. 

 

처음 요청 사이즈만큼 데이터를 가져왔는데, 그 사이 데이터가 추가되었다면 두 번째 조회 시 중복된 데이터가 나타날 수 있다. 

 

다만 내부적으로 사용하여 데이터의 변화가 거의 없거나, 중복 데이터 노출될 가능성이 없는 경우, 누군가가 수많은 데이터를 스킵할 가능성이 없다면 offset-based pagination을 사용해도 무방할 것으로 보인다. 

 

하지만 퍼블릭 서비스 같은 경우 해당 이슈를 극복하기 위해서 Cursor Based Pagination이 나왔다. 

 

보통 Cursor는 데이터를 가리키는 포인터와 같은 개념으로 쓰인다. 이를 통해 현재 보고 있는 데이터에 기반해 페이징을 구현한다는 개념 같다. 

 

간단히 말하면 요청한 데이터의 마지막 행을 기반으로 다음 행들을 응답할 수 있도록 구현하는 것이다.

 

1. 첫 조회

 

아이템 테이블에서 10개의 데이터를 가져옵니다. 정렬 조건은 아이템 순서에 순차적으로 증가되는 칼럼 값이고 이는 오름차순입니다. 

select * from items order by item_order limit 10

 

2. 다음 유저가 스크롤을 내려 다음 데이터를 조회한다고 가정합니다. 

 

이때 커서는 item_order =10을 가리킬 것입니다. 

select * from item where item_order > 10
order by item_order limit 10

 

즉 커서 기반 페이징은 조건에 따라 두 개의 쿼리를 전송해야 합니다. 

 

이에 대한 변수를 뽑으면 item_order와 보일 개수가 달라질 수 있으니 limit 정도가 있겠네요

 

만약 순차적으로 사용되는 커서 기준 값이 없다면 커서에 사용될 적절한 값이 없다면 만들어야 합니다.

@GetMapping("/items")
@ResponseBody
public Result<Item> findAllItemOrderByItemOrderASC(
@RequestParam(value = "order", required = false) Integer itemOrder,
@RequestParam(value = "size", defaultValue = "10") Integer size) {

    return null;
}

 

item과 itemReviews라는 엔티티가 일대다로 연관되어있다고 생각하고 양방향 매핑을 했다고 가정합니다. 

 

이제 조회 성능에 대해 고민해야 합니다. item 입장에서 itemReview는 @OneToMany입니다.

@Query("select i from item i join fetch i.itemReviews)

 

이 경우 우리는 페이지 네이션을 적용할 수 없습니다. 이는 결과의 행의 수가 무조건 다에 맞춰서 나오기 때문이죠. 

따라서 우리는 지연 로딩을 이용하되 batchSize를 통해 조회 성능을 최적화해야 합니다. 

  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 400

 

다시 생각해보니 초기 요청 때문에 쿼리를 두 개 만드는 것은 비효율 적입니다. default 값을 0으로 하여 처리하죠

@GetMapping("/items")
@ResponseBody
public ItemDto findAllItemOrderByItemOrderASC(
@RequestParam(value = "itemOrder", defaultvalue = "0") Integer itemOrder,
@RequestParam(value = "size", defaultValue = "10") Integer size) {

        String jpql = "select i from item i where i.itemOrder > :itemOrder order by i.itemOrder";

        return em.createQuery(jpql, Item.class)
                .setParameter("itemOrder", itemOrder)
                .setMaxResults(size)
                .getResultList()
                .stream
                .map(ItemDto::new)
                .collect(Collectors.toList());
}

 

예시니까 빠르게 만들어봤습니다.  

 

결국 데이터베이스의 문법에 차이는 크게 없던 것 같습니다. 

 

JPQL을 사용해 커서 페이지 네이션을 구현하는 글은 찾기 힘들더군요, QueryDSL이 가장 많았습니다. 

 

저는 아직 JPQL을 사용하여 공부하는 단계이기 때문에 이 부분에서 어떤 문제가 있는지 찾아봐야겠습니다. 

 

 

실제로 일대다 관계에서 다에 관한 데이터가 필요한 경우는 해당 JPQL에는 fetch join을 통해 성능 최적화는 하지 못합니다. 하지만 batch_size라는 다른 옵션이 있다면 충분히 사용할 수 있을 것 같은 생각이 드네요.

 

이에 대한 문제점을 찾아 추가적으로 포스팅을 하고 너무 늦어 오늘은 마무리하고 자야겠습니다.

 

--ps

 

커서 페이징을 사용했을 때 클라이언트에게 꼭 필요한 응답 결과가 무엇인지도 같이 생각해봐야겠습니다.

반응형

'데이터 접근 기술 > JPA' 카테고리의 다른 글

값 타입  (0) 2022.11.20
OSIV  (0) 2022.10.31
벌크연산  (0) 2022.10.29
N+1 문제 해결  (0) 2022.10.29
나는 지금 JPQL이 필요하다! (2)  (0) 2022.10.26

댓글