실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의
스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 본 강의는 자바 백엔드 개발의 실전 코스에 있는 활용2 강의 입니다. 스프링 부트와 J
www.inflearn.com
강의를 들으며 생각 정리 + "자바 ORM 표준 JPA 프로그래밍" 책 참고
지금까지 xxxtoOne 연관관계만 고려해서 주문 엔티티를 조회했다. 이번에는 컬렉션인 일대다 관계(OneToMany)를 조회하고, 최적화 하는 방법을 알아보자.
Order 엔티티는 OneToMany 관계로 OrderItem 엔티티와 연관되어 있고 OrderItem 엔티티는 ManyToOne 관계로 Item 엔티티와 연관되어 있다. 이들 전부를 조회할 것이다.
+) 이전 글에서 확장하는 개념이기 때문에 이전 글에 대한 충분한 이해가 필요하다.
2021.05.18 - [java/jpa] - API 개발 고급 - 지연 로딩과 조회 성능 최적화
API 개발 고급 - 지연 로딩과 조회 성능 최적화
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-API%EA%B0%9C%EB%B0%9C-%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94/ 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능..
gksdudrb922.tistory.com
주문 조회 V1: 엔티티 직접 노출
</api/OrderApiController.java>
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName();
order.getDelivery().getAddress();
List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream().forEach(o -> o.getItem().getName());
}
return all;
}
}
V1 단계에서는 역시 엔티티를 직접 노출하는 방법을 사용했다.
현재 Hibernate5Module을 사용하고 있기 때문에 초기화 된 프록시 객체에 대해서 Json 형식으로 나타낼 수 있다. 따라서 반복문을 통해 연관된 모든 엔티티들의 프록시를 초기화시켜준다.
+) 양방향 연관관계의 경우 무한 루프에 걸리지 않게 한 곳에 @JsonIgnore를 추가해야 한다.
머리에 박힐 정도로 강조하지만 이 방법은 엔티티를 직접 노출하므로 좋은 방법이 아니다.
주문 조회 V2: 엔티티를 DTO로 변환
</api/OrderApiController.java - 내용 추가>
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
@Getter
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItem> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems();
}
}
V2는 엔티티를 바로 반환하지 않고 필요한 정보들만 DTO로 변환해서 반환하는 방식이다.
그런데 실제로 json을 찍어보면 다른 필드들은 잘 찍히는데, orderItem만 null 값을 갖게 된다.
-> 이유는 지연 로딩 때문이다. OrderItem 역시 엔티티이기 때문에 order.getOrderItems()만으로는 프록시 객체를 초기화 할수 없다.
OrderItem 프록시 객체 초기화를 위해 생성자를 수정하자.
@Getter
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItem> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems();
orderItems.stream().forEach(orderItem -> orderItem.getItem().getName()); // 프록시 초기화
}
}
OrderItem 엔티티와 OrderItem에 연관된 Item까지 전부 초기화를 시킨다.
이제 OrderItem과 Item 엔티티 모두 JSON으로 잘 전송된다. 그러나 위 코드 역시 문제점이 있다.
바로 OrderItem 엔티티 전체를 노출한다는 것이다. OrderDto를 사용하기 때문에 안전할 것이라 생각할 수도 있는데 코드를 잘 보면 OrderDto 안에서 OrderItem 전체 엔티티를 그대로 노출시키는 것을 확인할 수 있다.
private List<OrderItem> orderItems;
이는 V1과 똑같은 문제를 야기한다. OrderItem이 수정되면 API 형식도 변할 수가 있는 등 엔티티가 API에 의존하게 된다.
즉, DTO 안에는 엔티티가 있으면 안 된다. OrderItem 조차도 DTO로 변경해야 하는 것이다. 그리고 API가 OrderItem의 일부 데이터만을 필요로 할 수 있기 때문에 요청 데이터에 맞는 DTO를 만들어야 한다.
OrderItemDto를 생성했을 때, 전체 코드를 다시 보자.
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
@Getter
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(Collectors.toList());
}
}
@Getter
static class OrderItemDto {
private String itemName; //상품 명
private int orderPrice; //주문 가격
private int count; //주문 수량
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
만약 API에서 상품 명, 주문 가격, 주문 수량만을 요청한다면 위와 같이 OrderItemDto를 만들면 된다.
이처럼 Order 입장에서 OrderItem과 같은 컬렉션을 조회하게 될 때, 이런 추가적인 DTO를 신경써줘야 한다.
<V2 문제점>
이제 이런 방식은 N+1 문제를 생각할 수 있어야 한다. OrderItem 컬렉션에 Item 엔티티까지 추가되었기 때문에 1건의 Order 조회로 인해 더 많은 추가 쿼리가 발생하게 될 것이다.
주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
</api/OrderApiController.java - 내용 추가>
@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;
}
리포지토리에 findAllWithItem() 메서드 추가
</repository/OrderRepository.java>
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
V2와 코드는 같다. 단지 메서드의 차이로(페치 조인) 쿼리가 1번만 나가기 때문에 성능을 향상시킬 수 있다.
<주의점>
1. distinct
JPQL에 select 절을 보면 distinct가 사용된 것을 볼 수 있다.
1대다 조인이 있는 경우 대개 데이터베이스에서는 row가 증가한다.
예제의 경우를 살펴보자. 예제는 Order는 2건이고 각 Order당 OrderItem이 2건씩 연관되어 있다.
즉, Order - OrderItem이 1:2로 1대다 연관관계를 맺고 있다.
페치 조인 결과 DB에 보내진 SQL 쿼리를 직접 찍어보면 다음과 같이 데이터 값이 나온다.
Order를 조회하기 때문에 2건이 나올 것으로 예상했지만, 결과적으로 OrderItem의 수만큼, 1대다에서 '다'만큼 row가 조회되는 것을 볼 수 있다. 그래서 Order 엔티티의 PK 값이 중복되어 나타난다.
-> 1대다 페치 조인시 distinct를 사용하는 이유가 바로 이런 중복 때문이다.
JPQL에 distinct를 추가하면 기본적으로 DB에 나가는 SQL 쿼리에 distinct를 추가해준다. 그런데 SQL 문법상 distinct는 row의 모든 컬럼 값들이 동일해야 중복으로 처리하기 때문에 예제의 경우를 중복이라 보지 않는다.
(한 Order에 대해 OrderItem이 두건이기 때문에 row가 정확히 같지 않다)
그래서 JPA는 같은 엔티티가 조회되면, 즉, 같은 PK 값을 갖는 엔티티가 조회되면 애플리케이션 측에서 중복을 걸러주는 기능을 추가하였다.
결론 : JPQL에서 distinct를 사용하면 결과적으로 중복 조회를 막아준다. (예제에서는 Order 2건만 조회된다)
2. 페이징 불가능
public List<Order> findAllWithItem() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.setFirstResult(1)
.setMaxResults(100)
.getResultList();
}
일대다 페치 조인에서 코드처럼 페이징을 사용할 수 없다. 실제로 SQL 쿼리에 limit이나 offset이 붙지 않는다.
일대다 페치 조인은 distinct를 사용하더라도 DB 측에서는 중복 처리를 하지 못하기 때문에 데이터가 뻥튀기(?) 되는 것을 볼 수 있었다.
여기서 페이징을 해서 Order 엔티티를 조회할 수 있을까?
-> 원하는 결과를 기대할 수 없다. 왜냐하면 위 코드에서는 두 번째 row부터 101번째 Order까지 페이징을 하고 싶은데 애초에 Order의 개수가 중복되어 틀어져버린 테이블에서 페이징을 할 수가 없는 것이다.
-> 그래서 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어와 메모리에서 페이징 해버린다. 이는 매우 위험하다. 데이터가 적으면 상관없겠지만 데이터가 많으면 성능 이슈와 메모리 초과 예외가 발생할 수 있다.
결론 : 일대다 페치 조인에서 일반적인 페이징 방식을 사용하면 안된다. (페이징의 한계 해결은 뒤에서 다루겠다)
3. 컬렉션 페치 조인은 1개만 사용할 수 있다
컬렉션 둘 이상에 페치 조인을 사용하면 안된다.
A -> B 컬렉션, C 컬렉션
A -> B 컬렉션 -> C 컬렉션
두 상황 모두 페치 조인을 사용하지 말자. 중복 처리가 애매해지고 데이터가 부정합하게 조회될 수 있다.
+) 컬렉션 페치 조인의 자세한 내용, 한계는 아래 포스팅을 참고하자.
https://gksdudrb922.tistory.com/58?category=961648
객체지향 쿼리 언어2 - 중급 문법
자바 ORM 표준 JPA 프로그래밍 - 기본편 스프링 핵심 원리 - 기본편 - 인프런 스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다. 초
gksdudrb922.tistory.com
+) 만약 둘 이상의 컬렉션을 조인하기 위해서는 아래서 설명할 JPA에서 DTO로 직접 조회 방법(V4, V5, V6)을 사용해야 한다.
주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파
컬렉션을 페치 조인하면 페이징이 불가능하다.
일대다 조인에서 '일'을 기준으로 페이징을 하는 것이 목적인데, 데이터는 '다'를 기준으로 row가 생성된다.
-> Order를 기준으로 페이징 하고 싶은데, OrderItem을 기준으로 데이터가 생성되는 것이다.
이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다.
-> 최악의 경우 장애로 이어질 수 있다.
여기까지 컬렉션 페치 조인시 페이징의 한계이다. 그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?
<한계 돌파>
1. 먼저 xxxToOne 관계는 모두 페치 조인한다. ToOne 관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다. 그리고 컬렉션은 지연 로딩으로 조회한다.
</api/OrderApiController.java - 내용 추가>
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
페이징 변수를 요청 파라미터로 받아온다. 조회 메서드는 findAllWithMemberDelivery() 사용
</repository/OrderRepository.java - 메서드 추가>
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
toOne 엔티티만 페치 조인하고 페이징을 사용했다.
<문제점>
Order, Member, Delivery 엔티티는 페치 조인했기 때문에 1번의 쿼리로 조회할 수 있지만, 여전히 OrderItem과 Item은 지연 로딩으로 1+N 문제가 발생한다.
쿼리 수
Order (+ Member, Delivery) (1 쿼리) - 2건 조회
OrderItem1 (1 쿼리)
Item1-1 (1 쿼리)
Item1-2 (1 쿼리)
OrderItem2 (1 쿼리)
Item2-1 (1 쿼리)
Item2-2 (1 쿼리)
-> 총 7건의 쿼리가 발생한다.
2. 지연 로딩 최적화를 위해 hibernate.default_batch_fetch_size를 적용한다. 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.
<application.yml>
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/jpashop
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
logging:
level:
org.hibernate.SQL: debug
.yml 파일에 "hibernate.default_batch_fetch_size: 100"을 추가한다.
+) default_batch_fetch_size는 global하게 적용시키는 방법이다.
개별로 적용시키려면 @BatchSize를 적용하면 된다.(컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)
이제 쿼리를 찍어보자.
<결과>
select
orderitems0_.order_id as order_id5_5_1_,
orderitems0_.order_item_id as order_it1_5_1_,
orderitems0_.order_item_id as order_it1_5_0_,
orderitems0_.count as count2_5_0_,
orderitems0_.item_id as item_id4_5_0_,
orderitems0_.order_id as order_id5_5_0_,
orderitems0_.order_price as order_pr3_5_0_
from
order_item orderitems0_
where
orderitems0_.order_id in (
?, ?
)
select
item0_.item_id as item_id2_3_0_,
item0_.name as name3_3_0_,
item0_.price as price4_3_0_,
item0_.stock_quantity as stock_qu5_3_0_,
item0_.artist as artist6_3_0_,
item0_.etc as etc7_3_0_,
item0_.author as author8_3_0_,
item0_.isbn as isbn9_3_0_,
item0_.actor as actor10_3_0_,
item0_.director as directo11_3_0_,
item0_.dtype as dtype1_3_0_
from
item item0_
where
item0_.item_id in (
?, ?, ?, ?
)
orderItem과 item 엔티티를 조회할 때, 설정한 batch_size 만큼(100) 한 번에 in 쿼리로 조회하게 된다.
쿼리 수
Order (+ Member, Delivery) (1 쿼리)
OrderItem1, 2 (1 쿼리)\
Item1-1, 1-2, 2-1, 2-2 (1 쿼리)
-> 총 3건의 쿼리가 발생한다.
<toOne 페치조인 + batch_size 설정의 장점>
1. 쿼리 호출 수가 1+N -> 1+1로 최적화 된다. (N번의 쿼리를 1번의 쿼리로 다 끌어오기 때문)
2. 컬렉션 페치 조인보다 DB 데이터 전송량이 최적화 된다.
- 컬렉션 페치 조인은 중복 데이터가 생기는데, 이 방법은 중복 데이터가 없다.
- DB 데이터가 많을수록 이 방법이 유리하다.
3. 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
결론 : ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않고 DB 중복도 없다. 따라서 ToOne 관계는 페치 조인으로 쿼리 수를 줄이고, 나머지 컬렉션은 hibernate.default_batch_fetch_size로 최적화하자.
<정리>
사실 성능 자체는 컬렉션 페치 조인이 좋을 수 있다. 그러나 페이징 상관 없이 DB의 데이터 양이 증가할수록 hibernate.default_batch_fetch_size를 사용하는 것이 더 좋을 수 있기 때문에 상황에 맞게 최적화 방법을 선택하자.
물론 페이징이 필요하면 컬렉션의 경우 반드시 hibernate.default_batch_fetch_size를 사용해야 한다.
(복잡하니까 toOne은 페치 조인, toMany(컬렉션)는 batch_size로 설정하는 것도 하나의 방법이다)
여기까지 제대로 이해했다면 조회 성능 최적화의 90%는 해결할 수 있다!
+) default_batch_fetch_size의 크기
default_batch_fetch_size의 크기는 적당한 사이즈를 골라야 한다. DB에 따라 최대 1000까지 지원하기 때문에 100 ~ 1000 사이를 선택하는 것을 권장한다.
그런데 1000으로 잡으면 IN 쿼리로 한번에 1000개를 불러오므로 DB에 순간 부하가 증가할 수 있다. 100이든 1000이든 전체 데이터를 로딩해야 하므로 메모리 사용량은 같다. 따라서 1000으로 설정하는 것이 성능상 가장 좋지만, 순간 부하를 어디까지 견딜 수 있는지 체크해야 한다.
결론 : DB나 애플리케이션이 버틸 수 있는 정도를 체크하고 가능한 큰 숫자를 사용하는 것이 좋다.
+) batch_size 최적화 전략
보통 관계형 데이터베이스들은 select * from x where in (?), select * from x where in (?, ?) 와 같은 preparedstatement는 미리 문법을 파싱해서 최대한 캐싱을 해둔다.
그런데, default_batch_fetch_size: 100인 상황이라면 최대 100개의 preparedstatement 쿼리를 만들어야 한다.
select * from x where in (?)
select * from x where in (?, ?)
select * from x where in (?, ?, ?)
select * from x where in (?, ?, ? ...)
이렇게 되면 DB 입장에서 너무 많은 preparedstatement 쿼리를 캐싱해야 하고, 성능도 떨어지게 된다.
그래서 하이버네이트는 이 문제를 해결하기 위해 내부에서 나름 최적화를 한다.
100 = 설정값
50 = 100/2
25 = 50/2
12 = 25/2
그리고 1~10까지는 자주 사용하니 모두 설정
이런식으로 잡아둔다.
그러면 기존에 100개의 preparedstatement 모양을, 1~10, 12, 25, 50, 100 해서 총 14개의 모양으로 최적화할 수 있다.
이 상태에서 18개의 데이터를 조회한다면 preparedstatement를 12, 6 이렇게 나누어서 실행하게 된다.
select * from x where in (?*12)
select * from x where in (?*6)
180개 데이터라면 preparedstatement를 100, 50, 25, 5로 나눈다.
select * from x where in (?*100)
select * from x where in (?*50)
select * from x where in (?*25)
select * from x where in (?*5)
추가로 다음과 같은 속성으로 최적화 전략을 제어할 수 있다.
spring.jpa.properties.hibernate.batch_fetch_style: legacy //기본
spring.jpa.properties.hibernate.batch_fetch_style: dynamic //최적화X, 권장하지 않음
(위에서 말한 전략이 기본 전략이다.(legacy))
주문 조회 V4: JPA에서 DTO 직접 조회
지금까지 엔티티를 조회해서 DTO로 변환하는 과정을 거쳤는데, 이번에는 DTO로 직접 조회하는 방법을 알아보자.
</api/OrderApiController.java - 내용 추가>
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueryDtos();
}
DTO 리스트를 조회해 바로 반환한다. 이 때, 3가지 객체를 추가한다.
1. OrderQueryDto
리포지토리 메서드인 findOrderQueryDtos() 메서드에서 DTO에 직접 접근할텐데, 기존에 사용했던 컨트롤러의 이너 클래스 DTO를 그대로 사용하면 리포지토리에서 컨트롤러를 의존하기 때문에 리포지토리 계층에 DTO를 따로 위치시킨다.
</repository/order/query/OrderQueryDto.java>
@Data
public class OrderQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemQueryDto> orderItems;
public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
생성자에 API를 통해 보내고자 하는 데이터를 하나하나 설정한다.
2. OrderItemQueryDto
OrderQueryDto에서도 OrderItem 엔티티를 그대로 사용하지 않기 위해 OrderItem DTO를 만든다.
</repository/order/query/OrderItemQueryDto.java>
@Data
public class OrderItemQueryDto {
@JsonIgnore
private Long orderId;
private String itemName;
private int orderPrice;
private int count;
public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
this.orderId = orderId;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
}
(orderId는 나중에 사용하기 위해 미리 추가해뒀다)
3. OrderQueryRepository
그냥 OrderRepository는 순수 엔티티를 다루는 용도이기 때문에 DTO 전용 리포지토리를 따로 만든다.
</repository/order/query/OrderQueryRepository.java>
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
public List<OrderQueryDto> findOrderQueryDtos() {
List<OrderQueryDto> result = findOrders();
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto.class)
.getResultList();
}
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id = :orderId", OrderItemQueryDto.class)
.setParameter("orderId", orderId)
.getResultList();
}
}
조금 복잡한 구조인데 자세히 분석해보면 충분히 이해할 수 있다.
1. 리포지토리가 findOrderQueryDtos() 메서드를 호출한다.
2. 먼저 findOrders() 메서드를 통해 ToOne 엔티티들을 조인한 값들을 포함한 List<OrderQueryDto>를 반환한다.
ToMany(컬렉션) 관계는 데이터를 증가시키기 때문에 join하지 않고 따로 메서드를 만들 것이다.
(실제로 OrderQueryDto 생성자에서는 OrderItem이 포함되어 있지 않다)
3. List<OrderQueryDto>에서 각 OrderQueryDto마다 findOrderItems() 메서드를 호출해서 List<OrdeItemQueryDto>를 OrderQueryDto의 필드에 저장한다.
결론 : row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.
<한계>
이 방식 역시 N+1 문제가 발생한다.
findOrders() -> query 1번, N건 조회
findOrderItems() -> query N번
주문 조회 V5: JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
DTO 직접 조회시 V4에서 발생한 N+1 문제를 해결해보자.
</api/OrderApiController.java - 내용 추가>
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findAllByDto_optimization();
}
새로운 메서드 추가
</repository/order/query/OrderQueryRepository.java - 내용 추가>
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders();
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
private List<Long> toOrderIds(List<OrderQueryDto> result) {
List<Long> orderIds = result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
return orderIds;
}
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id in :orderIds", OrderItemQueryDto.class)
.setParameter("orderIds", orderIds)
.getResultList();
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
return orderItemMap;
}
상당히 복잡하다. 천천히 들여다보자.
1. findOrders() 메서드로 OrderItem 정보가 없는 Order를 조회하는 것까지는 V4와 같다.
2. toOrderIds() 메서드는 OrderQueryDto의 id 리스트를 반환하는 메서드다. 이 리스트를 인자로 findOrderItemMap() 메서드를 호출한다.
3. findOrderItemMap() 메서드는 IN 쿼리를 사용해 ID 리스트에 해당하는 모든 OrderItem 정보들을
Map<ID, List<OrderItemQueryDto>> 형식으로 반환한다.
4. 이제 ID 값에 맞게 OrderQueryDto.set(List<OrderItemQueryDto>)으로 DTO를 완성한다.
쿼리는 Order 1번, 컬렉션 1번, 총 2번의 쿼리가 발생한다. Map을 사용해 메모리에서 매칭함으로써 성능을 향상시킨다.
결론 : ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계를 한꺼번에 조회
+) 이런 복잡한 방식이 마냥 편하지만은 않다. 또한 페치 조인 방식에 비해 재사용성도 줄어든다. 그래도 컬렉션을 페치 조인하는 것보다는 약간의 성능 향상이 있기 때문에 상황에 맞게 적절한 트레이드오프를 선택하면 되겠다.
주문 조회 V6: JPA에서 DTO로 직접 조회, 플랫 데이터 최적화
이번엔 쿼리 한 번에 전부 조회하는 방법을 알아보자.
모든 정보를 담고 있는 새로운 DTO를 만든다.
</repository/order/query/OrderFlatDto.java>
@Data
public class OrderFlatDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private String itemName;
private int orderPrice;
private int count;
public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, String itemName, int orderPrice, int count) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
}
</api/OrderApiController.java - 내용 추가>
@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
return flats.stream()
.collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
)).entrySet().stream()
.map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
.collect(toList());
}
findAllByDto_flat() 메서드로 조회
(flats.stream 부분은 OrderFlatDto를 OrderQueryDto 형태로 바꿔주기 위해 추가한 부분인데 상당히 복잡하기 때문에 나중에 필요할 때 찾아서 보면 좋을 듯 하다)
</repository/order/query/OrderQueryRepository.java - 내용 추가>
public List<OrderFlatDto> findAllByDto_flat() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
" from Order o" +
" join o.member m" +
" join o.delivery d" +
" join o.orderItems oi" +
" join oi.item i", OrderFlatDto.class)
.getResultList();
}
일대다 조인이 있기 때문에 데이터가 중복되서 조회될 것이다. 이를 컨트롤러에서 flats.stream을 통해 중복을 걸러내서 OrderQueryDto에 알맞게 매칭하는 것이다.
+) 중복을 구분하기 위해 OrderQueryDto에 @EqualsAndHashCode(of = "orderId") 애노테이션을 추가해준다.
<장점>
쿼리 한번에 조회 가능하다.
<단점>
1. 쿼리는 한번이지만 조인으로 인해 DB에서 중복 데이터가 추가되므로 상황에 따라 V5보다 느릴 수 있다.
2. 애플리케이션에서 추가 작업이 크다.(flats.stream())
3. 일대다 조인에서 '일' 단위로 페이징이 불가능하다.(Order 단위로 페이징 불가)
사실 QueryDSL을 사용하면 더 손쉽게 최적화가 가능하기 때문에 V6는 참고 정도로 알아둔다.
API 개발 고급 정리
정리
- 엔티티 조회
- 엔티티를 조회해서 그대로 반환: V1
- 엔티티 조회 후 DTO로 변환: V2
- 페치 조인으로 쿼리 수 최적화: V3
- 컬렉션 페이징과 한계 돌파: V3.1
- 컬렉션은 페치 조인시 페이징이 불가능
- ToOne 관계는 페치 조인으로 쿼리 수
- 최적화 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size , @BatchSize 로 최적화
- DTO 직접 조회
- JPA에서 DTO를 직접 조회: V4
- 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화: V5
- 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환: V6
권장 순서
- 엔티티 조회 방식으로 우선 접근
- 페치조인으로 쿼리 수를 최적화
- 컬렉션 최적화
- 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화
- 페이징 필요X 페치 조인 사용
- 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
- DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate
+) 엔티티 vs DTO
1. 엔티티 조회 방식은 페치 조인, hibernate.default_batch_fetch_size 같이 코드를 거의 수정하지 않고, 성능 최적화를 시도할 수 있다. 반면에 DTO를 직접 조회하는 방식을 성능 최적화를 하는데 많은 코드를 변경해야 한다.
또한, 엔티티 페치 조인으로도 대부분의 문제들을 충분히 해결할 수 있다. 솔직히 엔티티 페치 조인으로도 성능이 안나오면 트래픽이 정말 많은건데, 보통 이 단계까지 오면 캐시를 쓰는 방식을 취하기도 한다. 게다가 요즘은 네트워크 성능도 좋아서 DTO 방식이라고 급격한 성능 변화가 있지 않다.
결론 : 엔티티 방식(페치조인, hibernate.default_batch_fetch_size)을 주로 사용하자.
2. 개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야한다. 보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고간다.
엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서 성능 최적화를 할 수 있다.
반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 줄타기를 해야 한다.
+) DTO 조회 방식의 선택지
만약 DTO 조회 방식을 선택해야 한다면 V4, V5, V6 각각 장단을 고려해서 선택해야 한다. 단순하게 쿼리가 1번 실행된다고 V6가 항상 좋은 방법인 것은 아니다.
<V4>
V4는 코드가 단순하다. 특정 주문 한건만 조회하면 이 방식을 사용하면 좋다.
ex)
Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.
<V5>
V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우 이 방식을 사용해야 한다.
ex)
Order 데이터가 1000건인데, V4 방식을 그대로 사용하면 쿼리가 총 1+1000번 실행된다. V5 방식으로 최적화 하면 쿼리가 총 1+1번만 실행된다. 운영 환경에서 약 100배 이상의 성능 차이가 날 수 있다.
+) 참고로 DB에 따라서 1000개 이상의 IN 쿼리를 지원하지 않기 때문에, 1000건 이상의 데이터에 대해서는 애플리케이션에서 일정 데이터 단위로 짤라서 쿼리를 보내야 한다.
<V6>
V6는 쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페이징이 불가능하다. 실무에서는 페이징 처리가 거의 필요하기 때문에 선택하기 어려운 방법이다. 그리고 데이터가 많으면 중복 전송이 증가해 V5와 비교해서 성능 차이도 미비하다.
-> 보통 DTO 직접 조회는 V5 방식을 많이 사용한다.
'java > jpa' 카테고리의 다른 글
[JPA] 쿼리 파라미터 로그 남기기 (0) | 2021.05.23 |
---|---|
[JPA] API 개발 고급 - 실무 필수 최적화 (1) | 2021.05.21 |
[JPA] API 개발 고급 - 지연 로딩과 조회 성능 최적화 (0) | 2021.05.18 |
[JPA] API 개발 고급 - 준비 (0) | 2021.05.18 |
[JPA] API 개발 기본 (0) | 2021.05.17 |