본문 바로가기
Back-end/JPA 개념

API 조회 성능 최적화 - 컬렉션(Fetch Join, distinct)

by devraphy 2022. 6. 2.

0. 개요

- 이전 포스팅에서 DTO 변환을 이용한 컬렉션 조회 방법을 알아보았다.

- DTO 변환 방법을 사용하여 컬렉션 조회 시, 다수의 SQL 문이 발행된다는 문제점을 확인하였다.

- 이 문제를 해결하기 위해 Fetch Join을 이용한 컬렉션 조회 방법을 알아보자. 

 

 

1. DTO 변환 + Fetch Join

a) Fetch Join 쿼리 작성

- 우선 Fetch Join을 이용하는 쿼리를 작성해보자.

- 쿼리는 다음과 같다. 

 

public List<Order> findAllWithItem() {
        return em.createQuery("select o from Order o " +
                "join fetch o.member " +
                "join fetch o.delivery " +
                "join fetch o.orderItems oi " +
                "join fetch oi.item i", Order.class).getResultList();
}

 

- 위의 쿼리에는 한 가지 문제점이 있다. 

- 바로 Order 테이블과 OrderItems 테이블을 Join 하는 부분이다. 

 

- 현재 DB에는 2개의 주문이 있고 주문당 2개의 물품이 할당되어 있다.

- 즉, Order 테이블에는 2개의 데이터가 존재하고 OrderItems 테이블에는 4개의 데이터가 존재한다.

 

 

- 4개의 데이터(= OrderItems)를 기준으로 2개의 데이터(= Order)를 Join 하면 결과적으로 4개의 데이터가 파생된다.

 

 

 

b) 문제 확인

- 위의 쿼리를 그대로 실행해보면 다음과 같은 결과를 API로부터 얻게 된다.

 

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;
    
    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o)).collect(Collectors.toList());
        return result;
    }
}

 

[
   {
     "orderId":4,
     "name":"userA",
     "orderDate":"2022-06-02T15:14:01.414705",
     "orderStatus":"ORDER",
     "address":{"city":"Seoul","street":"Gangnam","zipcode":"11111"},
     "orderItems":[{"itemName":"JPA1 Book","orderPrice":10000,"count":1},
                  {"itemName":"JPA2 Book","orderPrice":20000,"count":2}]},
   {
     "orderId":4,
     "name":"userA",
     "orderDate":"2022-06-02T15:14:01.414705",
     "orderStatus":"ORDER",
     "address":{"city":"Seoul","street":"Gangnam","zipcode":"11111"},
     "orderItems":[{"itemName":"JPA1 Book","orderPrice":10000,"count":1},
                  {"itemName":"JPA2 Book","orderPrice":20000,"count":2}]
   },
   {
     "orderId":11,
     "name":"userB",
     "orderDate":"2022-06-02T15:14:01.491492",
     "orderStatus":"ORDER",
     "address":{"city":"Busan","street":"Haeundae","zipcode":"22222"},
     "orderItems":[{"itemName":"Spring1 Book","orderPrice":20000,"count":3},
                  {"itemName":"Spring2 Book","orderPrice":40000,"count":4}]
   },               
   {
     "orderId":11,
     "name":"userB",
     "orderDate":"2022-06-02T15:14:01.491492",
     "orderStatus":"ORDER",
     "address":{"city":"Busan","street":"Haeundae","zipcode":"22222"},
     "orderItems":[{"itemName":"Spring1 Book","orderPrice":20000,"count":3},
                  {"itemName":"Spring2 Book","orderPrice":40000,"count":4}]
   }
]

 

- 이처럼 동일한 데이터가 중복되어 반환된다. 

- 이 결과가 잘못된 것이 아니다. RDB의 특성상, 위의 결과처럼 4개의 결과가 반환되는 것이 올바른 것이다.

- 다만, 개발자는 중복 없이 Order 테이블의 2개 데이터와 매칭 하는 2개의 데이터만을 원하는 것이다.

 

 

c) 해결 방법 - distinct 키워드 사용 

- distinct 키워드를 사용하면 다음과 같은 결과를 반환한다.

 

public List<Order> findAllWithItem() {
        return em.createQuery("select distinct o from Order o " +
                "join fetch o.member " +
                "join fetch o.delivery " +
                "join fetch o.orderItems oi " +
                "join fetch oi.item i", Order.class).getResultList();
}

 

[
  {
    "orderId":4,
    "name":"userA",
    "orderDate":"2022-06-02T15:24:48.526842",
    "orderStatus":"ORDER",
    "address":{"city":"Seoul","street":"Gangnam","zipcode":"11111"},
    "orderItems":[{"itemName":"JPA1 Book","orderPrice":10000,"count":1},
                  {"itemName":"JPA2 Book","orderPrice":20000,"count":2}]
  },
  {
    "orderId":11,
    "name":"userB",
    "orderDate":"2022-06-02T15:24:48.604724",
    "orderStatus":"ORDER",
    "address":{"city":"Busan","street":"Haeundae","zipcode":"22222"},
    "orderItems":[{"itemName":"Spring1 Book","orderPrice":20000,"count":3},
                  {"itemName":"Spring2 Book","orderPrice":40000,"count":4}]
  }
]

 

- 이처럼 개발자가 원하던 대로 중복 데이터 없이 결과를 반환하는 것을 확인할 수 있다.

 

 

d) fetch join의 유일한 단점 - 페이징 사용 불가

- Fetch Join을 사용하면 페이징을 사용할 수 없다.  

- Fetch Join은 Join 대상이 다수(= Many)이므로 결과 데이터의 수가 증가한다.

- 이로 인해 데이터의 순서가 틀어지게 되어 어떤 데이터가 첫 번째 데이터인지 그 순서를 알 수 없다.

- 즉, 데이터의 순서가 틀어지므로 페이징을 적용할 수 없다. 

 

- Hibernate의 경우 fetch join을 사용하더라도 내부 메모리를 이용하여 페이징 작업을 수행한다.

- 다만, fetch join의 결과가 많다면 최악의 경우 OutOfMemory가 발생할 수 있다.

 

 

2. JPA의 Distinct 키워드

a) 원래는 중복 데이터가 아니다. 

- 다음 사진을 살펴보자.

 

 

- 위의 사진에서 반환된 4개의 데이터는 DB의 distinct로 걸러질 수 없는 데이터다.

- RDB는 하나의 행을 하나의 데이터로 취급하기에, 행 자체의 값이 동일해야 중복으로 처리된다.

 

 

- 즉, 위의 사진처럼 distinct 키워드를 사용해도 RDB에서는 이를 중복으로 처리하지 않는다.

- 그렇다면 JPA에서는 이를 어떻게 중복 데이터로 처리하는 것일까? 

 

 

b) JPA의 distinct는 중복 객체를 처리한다.

- JPA는 ORM 기술이다. 그러므로 객체 중점적으로 모든 것을 처리한다.

- 그러므로 DB에서 반환되는 데이터 또한 동일하게 객체 단위로 처리한다.

- 즉, 영속성 Context에서 중복 객체를 인식하는 것이다. 

 

 

- 위의 사진은 distinct를 사용하지 않은 경우, JPA에서 반환하는 객체의 주소 값이다.

- JPA는 id(= primary key) 값이 동일한 경우, 동일한 객체로 판단한다.

- 즉, JPA의 distinct는 중복 객체를 필터링하는 역할을 수행하는 것이다. 

 

 

c) 결론

- JPA의 Distinct 키워드는 단순 RDB의 distinct와 동일한 역할(= 행 간의 중복 데이터 필터링)뿐만 아니라, 

   중복 객체를 필터링하는 역할을 추가적으로 수행한다.

 

댓글