먼저 주문(Order) 엔티티는 2개 이상의 연관관계 필드들이 존재한다. 이에 대해서 주문 조회시 Member의 정보와 Delivery 정보 정도만 응답해준다고 가정하자.
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByCriteria(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); // Lazy 강제 초기화
order.getDelivery().getAddress(); // Lazy 강제 초기화
}
}
위의 코드의 문제는 우선 엔티티에 존재한다. 엔티티를 직접 드러내서 사용하기 때문에 양방향 연관관계에 대해서 문제가 발생하는데, 무한루프 문제이다.
List안에는 Order가 존재하고 Order에는 Member가 존재한다. 다시 Member는 Orders를 가진다. 이렇게 조회에 대해서 무한루프가 걸려 엄청난 양의 조회정보가 나오게 된다. 모두 동일한 정보가 계속 루프를 돌며 나타나는 것이다.

이를 보완하기 위해 양방향 중에 한 곳에 @JsonIgnore 를 달아주어야 한다. (하지만 엔티티에 수정을 가한다니 이미 꺼림칙하다.)
또한 리스트를 반환 타입으로 잡았기 때문에 {}으로 시작해야하는 json 스펙에도 이전처럼 문제가 발생한다.
V1의 문제는 엔티티 자체를 반환타입에 썼기 때문에 문제가 된다. 엔티티는 객체이고 이를 json으로 바꾸는 과정에서 엔티티(반환) 필드에는 다른 엔티티가 들어있고 다른 엔티티가 다시 반환타입에 해당하는 엔티티가 들어있어 무한루프가 발생하는데 이를 방지하는 방법으로 엔티티를 수정할 것이 아니라 역시 Dto를 도입한다.
@GetMapping("/api/v2/simple-orders")
public Result ordersV2() {
List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());
List<SimpleOrderDto> collect = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return new Result<>(collect);
} // N + 1 문제 존재
Result에 Dto를 담은 List를 주어 이를 개선할 수 있었지만 쿼리를 확인해보면 N+1문제가 발생하고 있다는 것이 보인다.
기본적으로 fetch = LAZY이기 때문에 order를 첫 쿼리로 조회하고 나서 order의 다른 필드를 활용할 때 연관 엔티티가 존재하면 또 다시 쿼리를 날리기 때문이다.
V2에서 남은 문제를 해결하기 위해 fetch join을 사용한다. 그럴려면 orderRepository에 존재하는 findAllByCriteria()가 아닌 다른 방식을 사용해야 한다.
그렇기에 리포지토리에 fetch join을 사용한 조회 로직을 하나 더 만들어낸다.
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class
).getResultList();
}
이렇게 jpql을 활용해주면 order를 가져올 때 member와 delivery를 같이 가져오기에 작은 영역의 EAGER 형태를 만들어줄 수 있다.
@GetMapping("/api/v3/simple-orders")
public Result ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> collect = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return new Result<>(collect);
}
리포지토리에서 불러오는 메서드만 달리해서 쿼리 수를 줄여 N+1에 대한 최적화가 가능해졌다.
Dto 자체를 리포지토리 로직에 담아 가장 필터링이 잘 된 구조로 DB에서 곧바로 DTO에 맞추어 가져오는 것이다.
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class
).getResultList();
}
select 시에 엔티티가 아닌 커스텀한 Dto 객체를 jpql문에 키워드로 주어 최대한 필터링해서 가져오는 방식이다. 쓰지 않는 데이터를 가져오지 않다보니 더 최적화했다고 볼 수 있다.
V4가 더 최적화되었다니 그러면 V4가 더 좋은 선택인가 싶지만, V3(패치 조인 + 엔티티) , V4(조인 + Dto) 둘 사이에서의 선택은 trade-off가 존재한다.
V4는 Dto로 바로 가져오는 것이기에 재사용성이 매우 적다. 영속성 컨텍스트에서의 관리도 되지 않을 것이다. 하나의 로직에 대한 최대한의 최적화이기 때문이다. 반면 V3은 엔티티를 가져오기 때문에 재사용성이 높고 일관적이다.
보통은 V4의 선택에 있어서 V3보다 현저히 좋아지는 효용을 기대하기는 어렵다. 보통 db 쿼리에서의 무거움은 join에 달려있기 때문이다.
그렇기에 쿼리 수를 현저히 줄여주는 V2 -> V3 방식에서 최대 효용을 보여주는 최적화가 이루어지고 V3 -> V4 성능 향상은 크지 않다.
그러므로 보통적으로는 V3의 형식을 채택하는 것이 옳다고 볼 수 있다.
또한 Dto를 활용한 쿼리문이 담긴 메서드가 보통의 Repository에 들어가는 것이 유지보수 관점에서 좋지 않다. 그렇기에 이렇게 최적화를 위한 특수한 쿼리문을 다룰 동일 주제의 리포지토리를 하나 더 만들고, 이를 다른 경로에서 관리되도록 하는 것이 모범적인 방법이다.
쿼리 방식 선택 권장 순서
1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
2. 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.