ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 엔티티의 1:N 관계에서의 QueryDsl fetch Join - (1) 페이지네이션
    Back-End/Querydsl 2022. 12. 21. 19:32
    728x90

    QueryDsl은 쿼리 빌더 프레임워크로써, 동적 쿼리 작성컴파일 시점에서의 쿼리 오류 검출이라는 큰 장점을 가지고 있다.

     

     

    (동적 쿼리 예시 및 QueryDsl로의 해결법 : https://jaehoney.tistory.com/185 )

    그래서 Spring Data JPA를 사용하는 개발자라면 굉장히 유용하게 사용할 수 있는 프레임워크이다.



    사내카페의 포스기 백엔드 서버를 개발하던 중이었다.

    팀 동료의 사정으로 팀 동료의 파트를 대신 개발을 하게 되었는데, 포스기에서 판매하는 상품을 조회하는 과정에서 쿼리가 수 십 방이 나가고 있었다.

    이유인 즉슨, 테이블의 구조가 다음과 같았는데,


    상품과 상품 이미지는 1:N, 상품 이미지와 이미지는 1:1의 구조를 갖고 있었다.

    그리고 메뉴를 조회하는 API에 대한 response로 image의 imagePath 또한 필요한데, 실제로 수행하는 로직은

    1. 샵의 상품을 모두 불러온다. (select i from items i where i.category = :categoryId)

    2. 1에서 불러온 모든 상품에 대해 getItemImages를 호출하고,

    + 추가로 각각에 대해 getImages를 호출하고(여기서 ItemImages를 사용하므로, 프록시 객체가 아닌 실제 레코드를 참조하기 위해
    ItemImages를 조회하는 쿼리가 나간다.)

    + 최종적으로 image entity의 imagePath컬럼을 조회 (여기서 images를 사용하므로, 마찬가지로 실제 레코드를 참조하기 위해 images
    를 조회하는 쿼리가 또 나간다.)

    하는 식으로 로직이 진행되고 있었다.

    즉, 1+N+N 쿼리가 나가고 있던 것이었다.

    이를 해결하기 위해 fetch Join을 수행하고자 했는데, JPQL이 아닌 QueryDsl을 활용하고자 했고

    결과적으로 다음과 같은 쿼리를 작성하여, fetch Join을 수행했다.

    List<Item> itemsWithImage = queryFactory
                    .selectFrom(item)
                    .where(item.category.id.eq(categoryId))
                    .leftJoin(item.itemImages, itemImage)
                    .fetchJoin()
                    .leftJoin(itemImage.image, image)
                    .fetchJoin()
                    .distinct()
                    .offset(offset)
                    .limit(limit)
                    .orderBy(item.name.asc())
                    .fetch();


    먼저 item과 itemImages를 leftJoin을 한다. (innerJoin은 특정 item의 itemImage가 없는 경우가 있을 수 있기 때문에 하지 않았다.)

    추가로, 결과에 한 번 더 left outer join을 수행했다.

    사실, item_image와 image는 1대1 관계이므로 inner join을 수행해도 됐지만 비즈니스 로직상 불가피하게 left join을 수행했다.

    실제 DB로 나간 쿼리

    이렇게 1+N+N개의 쿼리를 다음과 같이 한 방 쿼리로 줄일 수 있게 되었다.

    하지만, 또 다른 문제점이 있었다.

    1 대 다 관계에서 fetch Join을 사용하는 경우, 페이징이 정상적으로 작동하지 않는다는 문제였다.

    사실 자명한 부분이기는 하다. 왜냐하면 left join의 경우 '일' 쪽의 테이블의 특정 레코드를 참조하는 '다'쪽 테이블 레코드가 여러 개인 경우가 있을 때,

    '일'쪽의 데이터가 중복되더라도 모두 join을 해서 출력을 하므로 최종적인 결과가 일 쪽의 레코드 수보다 같거나 더 늘어나기 때문이다.

    그렇게 가져온 결과는 당연히 페이지네이션이 불가능하게 된다. (결과의 갯수가 실제 아이템의 개수 이상이기 때문)

    그런데, 앞서 방식대로 limit과 offset을 명시하여 페이지네이션을 수행하고자 하면 정상적으로 작동은 하지만,

    다음과 같은 warn 로그가 뜬다.

    firstResult/maxResults specified with collection fetch; applying in memory!


    즉, 해당 쿼리문에 맞는 레코드에 대해서, DB에서 페이지네이션을 진행한 후에 내가 원하는 갯수의 레코드만 가져오는 것이 아니라

    일단 모든 레코드를 인메모리로 불러온 후, 인메모리에서 페이지네이션을 진행한다는 문구이다.

    이러한 경우의 문제점은 만약 DB에 수십, 수백만건의 데이터가 들어가있다면 out of memory라는 끔찍한 오류가 발생할 위험이 있다는 것이었다.

    이 부분에 대한 해결방법은 다음 포스트를 참조하면 좋을 것 같다.



    댓글

Designed by Tistory.