읽기 전용 DTO 조회 시 JOIN의 Pagination 기법

읽기 전용 DTO 조회 시 JOIN의 Pagination 기법

JPA의 기본 사항을 정리한다.

읽기 세트 DTO + JOIN 시 Pagination 기법

JPA에서 추후 엔티티에 대한 수정이 필요없을 경우, Lazy Loading에 대한 Transaction 유지나 성능적인 면 (영속성 컨텍스트에 스냅샷 생성, 가비지 컬렉터 처리, 속도) 적인 면에서
읽기만 할 경우 JOIN + 프로젝션(DTO)를 사용하는 것이 좋다.
그러나 많은 읽기는 Pagination이 필요한 경우가 많다.
JOIN + 프로젝션(DTO)에서 Pagination을 사용하는 방법을 알아보자.

읽기 전용 세트 DTO 예시

//file: `Author Entity 예시`
@Entity
public class Author{
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private int age;
    
    @OneToMany(mappedBy = "author")
    private List<Book> books = new ArrayList<>();
}
//file: `Book Entity 예시`
@Entity
public class Book{
    @Id
    @GeneratedValue
    private Long id;
    private String title;
    private String isbn;
    
    @ManyToOne
    private Author author;
}
//file: `읽기 전용 DTO`
public interface AuthorBookDto{
    Stirng getName();//author 정보
    int getAge();//author 정보
    String getTitle();//Book 정보
    String getIsbn();//Book 정보
}

Author(부모) - Books(자식) 관계에서 Author의 정보와 Book의 정보를 함께 조회’만’ 해야 하는 상황이라면 어떻게 될까?

Join + Projection(DTO) + Page 객체사용

//file: `AuthorRepository`
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long>{
    
    @Query("SELECT a.name AS name, a.age AS age, b.title AS title, b.isbn AS isbn " +
            " FROM Author a LEFT JOIN a.books b" +
            " WHERE a.genre = ?1")
    Page<AuthorBookDto> findByViaQuerySimpleDto(Pageable pageable);
}
//file: `Service에서 사용`
public Page<AuthorBookDto> fetchPageOfAuthorsWithBooksDtoByGenre(int page, int size){
    
    Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC,"name"));
    Page<AuthorbookDto> pageOfAuthors = fetchPageOfAuthorsWithBooksDtoByGenre("Anthology",pageable);
    return pageOfAuthors;
        }

쿼리는 Author와 Books의 LEFT OUTER JOIN SELECT 하나, COUNT 하나로 2개의 SELECT문이 발생된다.
Projection을 통해 가져오기 때문에 Author - Book 1:1 관계로 가져온다.
예를들면 아래와 같다.

"content":[
        {
            "title" : "title1",
            "isbn" : "001-MJ"
            "age" : 23,
            "name" : "Mark Janel"
        },
        {
            "title" : "title2",
            "isbn" : "002-MJ"
            "age" : 23,
            "name" : "Mark Janel"
        }

동일한 Author 더라도 책에 따라 1개의 counting을 차지한다.


[성능 개선 1] COUNT(*) OVER() 윈도우 함수 사용으로 SELECT + COUNT 1개의 SELECT로 처리

STEP 1. Projection에 메서드 추가

getTotal을 @JsonIgnore 설정과 함께 추가해준다.

//file: `method추가된 읽기 전용 DTO`
public interface AuthorBookDto{
    Stirng getName();//author 정보
    int getAge();//author 정보
    String getTitle();//Book 정보
    String getIsbn();//Book 정보
    
    @JsonIgnore
    long getTotal();
}

STEP 2. COUNT(*) OVER() 네이티브 쿼리 호출

COUNT(*) OVER()를 total로 받기 위해 nativeQuery로 수정해준다.
Page객체가 아닌, List로 받는다는 점을 주의

//file: `AuthorRepository`
@Transaction(readOnly = true)
@Query(value = "SELECT a.name AS name, a.age AS age, b.title AS title, b.sibn AS isbn," +
        "COUNT(*) OVER() AS total" +
        "FROM author a LEFT JOIN book b" +
        "ON a.id = b.author_id WHERE a.genre = ?1", nativeQuery = true)
List<AuthorBookDto> fetchListOfDtoNative(String genre, Pageable pageable);

STEP 3. SERVICE 에서 사용 수정

List로 SELECT 1개만 발생하는 최적화 된 쿼리를 사용하여 가져오고 Page 객체를 List로 변경해준다.

//file: `Service에서 사용`
public Page<AuthorBookDTo> fetchPageOfAuthorsWithBooksDtoByGenre(int page, int size){
    
    Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC,"name"));
    List<AuthorbookDto> list = fetchListOfDtoNative("Anthology",pageable);

    return new PageImpl<>(list, pageable, list.isEmpty() ? 0 : list.get(0).getTotal());
}
--file: `발생 쿼리`
SELECT
    a.name AS name,
    a.age AS age,
    b.title AS title,
    b.isbn AS isbn,
    COUNT(*) OVER() AS total
FROM author a LEFT JOIN book b 
    ON a.id = b.author_id
WHERE a.genre = ?
ORDER BY a.name ASC LIMIT ? ?
    

[성능 개선 2] 추가 삭제가 매우 드문 경우, Slice 사용

신규 등록이나 삭제가 매우 드문 경우에 각 페이지를 호출할 때 마다 SELECT COUNT를 호출하는 것은 비효율적이다.
행 숫자가 오랫동안 고정된 상태로 유지되기 때문이다.
이 경우, 첫 번째 페이지를 가져올 때 단일 SELECT COUNT를 트리거 하고 페이지네이션에 Slice or List를 사용한다.

STEP 1. Repository Slice로 변경

@Transactional(readOnly = true)
@Query(value = "SELECT a.name AS name, a.age AS age, b.title AS title, b.sibn AS isbn," +
        "FROM author a LEFT JOIN book b" +
        "ON a.id = b.author_id WHERE a.genre = ?1")
Slice<AuthorBookDto> fetchSliceOfDto(String genre, Pageable pageable);

STEP 2. SERVICE 에서 사용 수정

Slice 타입은 size + 1의 결과를 가져온다.
첫 페이지인지 확인하는 isFirst(), 다음 페이지가 있는지 확인하는 hasNext()메서드를 제공한다.

public Slice<AuthorBookDto> fetchSliceOfAuthorsWithBooksDtoByGenre(int page, int size){
    Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC,"name"));
    return authorRepository.fetchSliceOfDto("Anthology", pageable);
        }

COUNT 자체가 없는 쿼리가 발생한다.

--file: `발생 쿼리`
SELECT
    a.name AS name,
    a.age AS age,
    b.title AS title,
    b.isbn AS isbn
FROM author a LEFT OUTER JOIN book b 
    ON a.id = b.author_id
WHERE a.genre = ?
ORDER BY a.name ASC LIMIT ? ?
    

[문제 상황 1] 결과 세트 잘림 방지

읽기 전용 DTO를 사용해서 가져오면 결과 세트가 잘릴 수 있다.
앞에서 봤듯이, Author - Book 1개씩 묶어서 가져오기 때문에, 예를 들면 Mark Janel이 쓴 책이 3권이 있는데 1,2권은 1페이지, 3권은 3페이지에 나올 수 있다.
이런 상황을 결과 세트 잘림이라고 한다.

"content":[
        {
            "title" : "title1",
            "isbn" : "001-MJ"
            "age" : 23,
            "name" : "Mark Janel"
        },
        {
            "title" : "title2",
            "isbn" : "002-MJ"
            "age" : 23,
            "name" : "Mark Janel"
        },//여기까지 1페이지 잘림
        {
            "title" : "title3",
            "isbn" : "003-MJ"
            "age" : 23,
            "name" : "Mark Janel"
        }//여기서 2페이지

💡그럼, 책의 개수가 아니라 저자의 개수로 counting하여 자를 순 없을까?

  • DENSE_RANK()란? : 조인하는 두 테이블을 A(Author),B(Book) 이라고 할 때, 각 저자별로 책을 나열하고, 같은 순서를 매긴다.

STEP 1. DENSE_RANK() Native 사용

일반적으로 복잡한 쿼리는 Native 쿼리를 사용한다.

//file: `AuthorRepository`
@Transactional(readOnly = true)
@Query(value = """SELECT * FROM (
                     SELECT *, DENSE_RANK() OVER (ORDER BY name, age) na_rank FROM(
                        SELECT a.name AS name, a.age AS age, b.title AS title, b.isbn AS isbn
                        FROM author a LEFT JOIN book b ON a.id = b.author_id
                        WHERE a.genre = ?1
                        ORDER BY a.name) ab ) ab_r
                      WHERE ab_r.na_rank > ?2 AND ab_r.na_rank <= ?3""", nativeQuery = true)
List<AuthorBookDto> fetchListOfDtoNativeDenseRank(String genre, int start, int end);

na_rank의 총 개수는 동일한 조건의 author 개수와 동일하다.

name | age | title | isbn | na_rank ——–|—–|——-|———|——— Alice | 30 | Book1 | ISBN001 | 1 Alice | 30 | Book4 | ISBN004 | 1 Bob | 25 | Book2 | ISBN002 | 2 Charlie | 35 | Book3 | ISBN003 | 3 name,age 기준으로 동일한 na_rank가 부여되었다.

STEP 2. Service 에서 사용법

//file: `Service`
public List<AuthorBookDto> fetchListOfAuthorsWithBooksDtoNativeDenseRank(int start, int end){
    List<AuthorBookDto> listOfAuthors = authorRepository.fetchListOfDtoNativeDenseRank("Anthology", start, end);
    return listOfAuthors;
}