본문 바로가기
Spring/JPA 2

4. API 개발 고급 - 컬렉션 조회 최적화

by wch_t 2024. 1. 9.
목표. OneToMany 관계 최적화 하기
     Order를 조회할 때, OneToMany 관계인 OrderItem(컬렉션)을 조회할 때 성능을 최적화하는 방법

 

정리. 컬렉션 페이징 조회 최적화

1. ToOne 관계를 모두 fetch join 한다.
2. 컬렉션은 지연 로딩으로 조회한다. (fetch join X)
3. 지연 로딩 최적화를 위해서 hibernate.default_batch_fetch_size / @BatchSize 를 적용한다.
     - hibernate.default_batch_fetch_size : 글로벌 설정(application.yml)
     - @BatchSize : 개별 설정

     → 컬렉션이나 프록시 객체를 설정한 size만큼, 한번에 IN Query로 조회한다.

 

 

1. V1 : 엔티티 직접 노출

 

 

 

2. V2 : 엔티티를 조회해서 DTO로 변환

["1 + N" 문제]

- order 1번에

- member, delivery, orderItem (개수 * order 조회 수)번

- item (개수 * orderItem 조회 수)번

SQL 쿼리가 실행된다.

 

cf. 영속성 컨텍스트에 있는 entity의 경우, SQL을 실행하지 않는다.

 

 

 

3. V3 : 엔티티를 조회해서 DTO로 변환 + "fetch join 최적화"

장점

     1) 1번의 SQL로 데이터를 가져온다.

 

     2) distinct

          - SQL에 distinct 키워드를 추가한다.

          - 같은 entity가 조회되면, 애플리케이션에서 중복을 걸러준다.

 

단점

     - 페이징 불가능

          - 모든 데이터를 DB에서 읽어와서, 메모리에서 페이징을 한다. (메모리가 다운될수도 있으므로, 매우 위험)

 

 

cf. Hibernate6부터 distinct를 자동 적용해준다.

 

 

 

 

+. V3-1 : V3 페이징 조회 가능

장점

     1) 컬렉션 fetch join은 페이징이 불가능하지만, 이 방법은 페이징이 가능하다.

 

     2) 페이징을 하기 위한 join을 썼을 때의 쿼리 호출 수가 1 + N 에서 1 + 1 로 최적화 된다.

 

     3) join보다 DB 데이터 전송량이 최적화 된다.

          - Order와 OrderItem을 join하면 Order가 OrderItem만큼 중복해서 조회된다.

          - 위 방법은 각각 전송하므로, 전송해야 할 중복 데이터가 없다.

             → 정규화 된 데이터를 받는다.

 

 

결론

     → ToOne 관계는 fetch join 해도 페이징에 영향을 주지 않는다.

          따라서 ToOne 관계  fetch join으로 쿼리 수를 줄이고,

          ToMany(컬렉션) 관계  hibernate.default_batch_fetch_size로 최적화하는 것이 좋다. (fetch join 대신에 지연 로딩을 유지)

          

 

 

 

 

 

4. JPA에서 DTO로 직접 조회

기존

   : Order Entity를 조회한 뒤, OrderDto로 변환하여 반환한다.

 

현재

   : Order Entity를 조회하지 않고, 바로 OrderDto를 조회하여 반환한다.

OrderApiController

 

- OrderQueryRepository를 생성한다.

   이 때, 기존 OrderRepository와 구분하여 repository.order.query 폴더를 생성하여, OrderQueryRepository와 그 와 관련된 객체인 OrderQueryDto, OrderItemQueryDto를 관리해준다.

(기존 OrderApiController의 OrderDto를 참고하지 않고 새로 OrderQueryDto를 만든 이유는 repository가 controller를 참조하는 의존관계의 순환을 피하기 위함이다.)

OrderQueryRepository

 

1) findOrders() → 1:N 관계를 제외한 나머지 조회

   : ToOne 관계는 join으로 최적화하기 쉬으므로 한 번에 조회한다. (fetch join X)

 

 

2) findOrderItems() 1:N 관계 조회

   : ToMany 관계는 최적화하기 어려우므로, findOrderItems() 같은 별도의 메서드로 조회한다.

 

     컬렉션(OrderItem) 같은 경우는, OrderItem과 연관 관계가 있는 entity들도 정의를 해주어야 한다.

      이 때, 루프를 돌면서

      만들고자 하는 API 형식에 맞게끔 OrderItemQueryDTO를 별도로 만들어주어 정의한다. (추가 쿼리)

      set으로 각 OrderQueryDto 컬렉션에  OrderItemDto를 주입한다.

 

 

 

5. JPA에서 DTO로 직접 조회 + 컬렉션 조회 최적화

→ 일대다 관계인 컬렉션은 IN 절을 활용해서, 메모리에 미리 조회하여 최적화한다.

 

기존

   : 여전히 Order에 OrderItem (toMany)를 직접 넣어줄 때 1+N 쿼리 문제가 발생한다.

 

현재

   : OrderId를 이용한 in 쿼리로 OrderItem을 한꺼번에 조회한다.

     추가로 Map을 사용하여 성능을 향상시킨다.

 

 

OrderApiController

 

 

 

OrderQueryRepository

 

1. 먼저 findOrders()를 통해 Order를 조회한다.

 

2. OrderItem을 in 쿼리로 한 번에 조회하기 위해서 List<Long> orderIds 를 리스트화 한다.

 

3. OrderItem을 in 쿼리로 한 번에 조회한다.

     → List<OrderQueryDto> orderItems

 

4. <orderId, List<orderItemDto>> Map 으로 전환한다.

Why?

 

5. Order에 key인 orderId로 value인 orderItemDto 컬렉션 추가한다.

 

 

 

6. JPA에서 DTO로 직접 조회 + 플랫 데이터 최적화

→ Join 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환

 

1) 1 : N (Order : OrderItems) 에서 1의 데이터가 N에 맞게 중복으로 들어간다.

 

 

 

 

2) OrderQueryDto, OrderItemQueryDto 객체를 참조하지 못함,,

 

 

 

 

트래픽이 많다. 캐시를 써서 해결을 해야지, DTO를 써야하나?

redis, local에 dto 캐싱

 

엔티티를 캐시하면 안되고, 무조건 dto로 변환해서 캐시를 해야한다.