섹션3이 LAZY 로딩으로 연관된 특정 Entity를 조회할 때의 최적화와 관련된 장이었다면,
섹션4는 LAZY 로딩으로 연관된 컬렉션을 조회할 때의 최적화와 관련된 장이다.
먼저 [섹션4] 에서 조회할 데이터의 구조를 보고 , 이를 최적화해 나가는 방향에 대해 각 버전으로 설명한다.
- 우리의 예제 애플리케이션에서 사용되는 데이터의 구조
- 이런 ERD 하에 , [섹션 3]에서는 Order를 조회하면서 연관된 Member,Delivery 를 어떻게 최적화 하여 조회할 것인가에 대해 공부했음.
- 이번 [섹션4]는 Order를 조회하면서 연관된 OrderItem 컬렉션 조회는 어떻게 하는것이 좋으며 - 나아가 Order와 연관된 Member,Delivery,OrderItem컬렉션을 모두 조회할 때 -
성능 최적화를 하면서도 && 페이징은 어떻게 수행할 것인가 에 대해 공부할 것
[API 요구사항]
: 모든 주문에 대하여 , 주문과 관련한 정보를 조회하기
- 즉 Order를 조회하면서
- Order와 관련된 Member, Delivery, OrderItem 컬렉션 , Item 을 함께 접근하여 , 이들을 통해 주문과 관련한 정보를 추출해야 함.
이제 위 요구사항에 따라 API를 버전을 높여가며 최적화 해보자.
[버전1]
: Entity를 직접 Response에 노출
- 실제로는 사용하면 안되는 방법
-> 왜냐하면 Entity 정보가 달라지면 , API 스펙이 변하기 때문- 그럼에도 굳이 Entity를 직접 노출해야 한다면 , Entity를 무사히 JSON으로 변환시켜야 하므로
-> JSON 변환시 양방향 연관관계에 의해 무한루프에 걸리지 않도록 한곳에 @JsonIgnore를 추가해야 하고
-> JSON으로 무사히 변환 되도록 , Hibernate5Module을 스프링 빈으로 추가해야 한다.
이를 구현한 코드는 아래와 같다.
/**
* <Order 정보를 통해 , 모든 OrderItem 및 Item 조회 API>
*
* [버전1]
* : Entity를 직접 노출
*
* [문제점]
* : 당연히 Entity가 직접 노출되니깐, Entity가 변하면 -> API 스펙이 변한다
*
* => 사실 이렇게 API를 만들면 안된다는 것도 알고, 이렇게 안만들 거니깐, 읽고 넘어가자
* */
@GetMapping("/api/v1/orders")
public List<Order> ordersV1(){
List<Order> all = orderRepository.findAll();
for (Order order : all) {
order.getMember().getName(); // Order Entity와 Lazy 로딩으로 연관된 Member Entity를 강제로 가져오게 함 (강제 초기화)
order.getDelivery().getAddress(); // Order Entity와 Lazy 로딩으로 연관된 Delivery Entity를 강제로 가져오게 함 (강제 초기화)
List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream()
.forEach(o -> o.getItem().getName()); // Order Entity와 Lazy 로딩으로 연관된 각각의 OrderItem Entity들을 강제로 가져오게 함 (강제 초기화)
}
return all;
}
[버전2]
: 필요한 정보를 DTO에 담아서 반환하자
- 이번 버전에서는 Entity를 그대로 Response에 노출시키지 않고, 필요한 필드값만 추출하여 DTO에 담아서 응답을 보낸다.
- 실제로도 Entity와 API 스펙을 독립적으로 분리시키기 위해 이러한 DTO를 사용하여 API를 개발해야 한다는 점을 명심하자.
- 그러나 버전2의 한계점은 성능이 나오지 않는다는 점이다.
-> 조회한 Order Entity에서 연관된 Entity (Member, Delivery) , 연관된 컬렉션 (List< OrderItem >) 에 접근할 때
-> LAZY 로딩으로 인해 의도지 않은 쿼리가 나가게 되는데
-> 이때 너무 많은 쿼리가 나가서 성능이 나오지 않는다.
-> 따라서 버전 3에서는 페치조인을 써서 , 연관된 Entity 및 컬렉션을 한방쿼리로 조회함으로 써 , 성능을 높이도록 개선해 보이겠다.
어쨋든 버전2로 개선한 API 코드는 아래와 같다.
/**
* <Order 정보를 통해 , 모든 OrderItem 및 Item 조회 API>
*
* [버전2]
* : DB에서 꺼내온 Entity를 대상으로 -> 필요한 정보만을 꺼내 DTO에 담은 후 -> DTO를 반환
*
* */
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2(){
List<Order> orders = orderRepository.findAll();
List<OrderDto> orderDtoList = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return orderDtoList;
}
@Data
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(oi -> new OrderItemDto(oi))
.collect(Collectors.toList());
}
}
@Data
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();
}
}
<버전2 에서 주목할 점>
: Entity를 DTO로 변환하는 버전2에서 주목할 점이 있다.
바로 DTO안의 값을 , "온전히 Entity에 독립적이게" 만들어야 한다는 점이다.
-> 버전2에서 Entity를 DTO로 변환하는 방식을 보면
-> DTO의 생성자로 Entity를 받아서, 필요한 정보만을 DTO에 담는 방식을 취하고 있다.이때 주목해야 할 점은 Order와 연관된 OrderItem,Item의 정보를 어떤 방식으로 DTO에 담을 것인가 이다.
-> 여기서 만약 OrderDTO의 생성자 안에서 Order를 받아 단순히 OrderItem 및 Item을 그대로 DTO에 넣는다면
-> OrderDTO안에는 OrderItem이라는 Entity가 그대로 남아있게 되고
-> 추후에 OrderItem 이 변할 경우 API 스펙이 변하게 된다.따라서 위의 코드와 같이 Order와 연과된 OrderItem 또한 별도의 DTO로 감싸서 필요한 정보만을 추출하여 OrderDTO안에 담는 방식을 써야 한다.
-> 핵심은 Entity와 DTO를 완전히 분리하기!
[버전3]
: 예고한 대로 , 페치 조인을 써서 조회할 Entity와 연관된 Entity 및 컬렉션을 한방쿼리로 가져오자
- 즉 Order를 조회하면서 , 이와 연관된 Member, Delivery, OrderItem컬렉션, Item을 페치조인을 써서 한방쿼리로 가져오는 방법이다.
- 이때 컬렉션 페치조인의 경우 , Order 입장에서 row수가 늘어나는 데이터 뻥튀기 문제가 발생하므로
-> distinct 키워드를 JPQL에 사용하므로써 중복된 Entity를 제거해줘야 한다.
이를 아래와 같은 코드로 구현하였다.
/**
* <Order 조회>
* : Fetch Joing으로 연과된 모든 Entity들을 함께 조회하도록
*
* [주의]
* 1) 컬렉션이 아닌, Entity의 경우, 둘 이상의 Entity와 함께 페치 조인 가능
* 2) 단 컬렉션과 페치조인할 경우, 데이터 뻥튀기 문제 때문에 둘 이상의 컬렉션과 페치 조인 불가능
* 3) 단 1)과 2)는 동시에 가능 -> 즉 둘 이상의 Entity의 && 하나의 컬렉션과는 동시에 페치 조인 가능.
* 4) 심지어 페치조인 하는 OrderItem들과 연관된 각각의 Item들도 함께 페치 조인으로 조회해올 수 있음!! (이런것 까지 되다니.. )
* */
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();
}
/**
* <Order 정보를 통해 , 모든 OrderItem 및 Item 조회 API>
*
* [버전3]
* : Entity를 조회하여 DTO에 담을 때 , Fetch Join으로 Entity를 조회하여 - 한 쿼리안에 필요한 Entity들을 최대한 많이 가져오자
*
* - 단 이때 OneToOne, ManyToOne 뿐만 아니라, OneToMany의 Order-OrderItems 가 있으니, Fetch Join시 데이터 뻥튀기를 주의하자
*
* */
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3(){
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> orderDtoList = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return orderDtoList;
}
버전 3에 대한 구현 코드를 작성하면서 ,
페치조인을 사용할 때 주의할 사항을 아래와 같이 정리하였다.
<버전3에서 주목할 부분>
1. Entity 페치조인시 , 한번에 여러 Entity를 함께 페치조인 할 수 있다.
그러나 컬렉션 페치조인시에는 "데이터 뻥튀기" 문제로 인해 한번에 하나의 컬렉션하고만 페치조인이 가능하다.
-> 실제로 Member와 Delivery를 한번에 페치조인하여 가져옴
- 둘 이상의 Entity와 , 하나의 컬렉션을 , 함께 페치조인으로 조회할 수 있다.
-> 실제로 Member와 Delivery 및 OrderItem컬렉션을 함께 페치조인 으로 가져오고 있다.
- 별칭을 조회용으로만 사용한다면, 페치 조인의 대상에 별칭을 부여하여 이를 활용할 수 있다.
-> 실제로 OrderItem에 별칭을 부여하여 , OrderItem과 연관된 Item을 함께 페치조인으로 조회하고 있다.
-> 이는 실제로 여러 OrderItem과 연관된 각각의 Item을 함께 InnerJoin으로 가져오게 되며
-> 이런것 까지 가능할 줄은 몰랐다!!
- 단 , 페치조인의 대상에 별칭을 붙였을 때 이를 조회용으로만 사용해야지 /
on, where 문에서 필터링 조건으로 사용하면 안된다.
(엔티티와 DB간 일관성이 깨지기 때문)
- 컬렉션 페치조인시 데이터 뻥튀기 문제를 해결하고자 JPQL에 distinct 키워드를 추가한다
- 이는 번역된 SQL에도 그대로 distinct가 추가되지만
이에 따라 SQL로 조회되는 row들간의 중복이 제거되지는 않는다.
(왜냐하면 모든 컬럼이 같아야 distinct에 의해 제거되는데,
모든 컬럼이 같지는 않기 때문)- 오직 JPQL 레벨에서만 조회되는 Entity들간의 중복을 제거해주는 효과가 있다.
- 그래서 컬렉션 페치조인시에는 페이징이 불가능하다
-> 왜냐하면 SQL 레벨의 중복된 row들이 제거가 안되니
-> 이 중복된 row들을 대상으로 페이징을 해봤자 의미가 없기 때문
(Order을 대상으로 페이징 해야 하는데,
OrderItem을 대상으로 페이징 하는 꼴)- 또한 컬렉션 페치조인시 페이징을 강제로 수행하면 하이버네이트는 해당 엔티티으이 모든 row를 메모리에 올려놓고 메모리상에서 페이징을 수행하는데
-> 이때 메모리를 초과하면 , 메모리가 뻑갈 수 있다..
-> 그래서 결론적으로 컬렉션 페치조인시 페이징을 하면 안된다!
즉 연관된 컬렉션을 함께 페치조인으로 가져오면 성능 최적화가 끝날 줄 알았지만,
페이징이 안되는 문제가 존재하였다.
따라서 섹션4에서는 결론적으로 성능최적화도 되면서 && 페이징이 가능한 버전3.1의 방법을 궁극적으로 배우게 된다.
실제로 이 방법을 미리 알았더라면 지난학기 졸업 프로젝트 때 더 효율적인 코드를 작성 할 수 있을 것 같았는데 아쉽다는 생각이 들었으며, 이후 프로젝트 때 이 방법을 적극 활용해야겠다고 생각했다.
[버전3.1]
: 페이징과 한계 돌파
"연관된 Entity들 및 컬렉션을 최적화 하여 조회하면서 && 동시에 페이징도 가능하게 하자"
- Step1. 일단 연관된 Entity들은 페치조인으로 조회하자 + 그리고 이때 페이징을 수행하자
-> 컬렉션을 페치조인 하지 않는다면 SQL 레벨에서 중복된 row가 생기지는 않는다.
-> 따라서 정상적으로 조회하고자 하는 Entity 에 대하여 페이징을 수행할 수 있게 되고
-> 바로 이 시점에 페이징을 수행해야 한다.
- 실제로 Order와 연관된 Member 및 Delivery를 페치조인으로 함께 가져오면서 페이징을 하고 있다.
- 나아가 이때 별칭을 사용하여 Member와 연관된 다른 Entity를 함께 페치조인 할 수 도 있는데
-> 그렇게 해도 SQL 레벨의 row의 길이가 길어질 뿐, row의 수가 늘어나지는 않기 때문이다!
- Step2. 이후 연관된 컬렉션은 LAZY 로딩 조회로 쿼리를 날려 가져오되, BatchSize를 설정하여 한번에 많이 가져오도록 최적화 하자!
- BatchSize를 100으로 설정하면, 실제 SQL 쿼리에서 IN절을 통해 한 쿼리에 많은 컬렉션 Entity들을 조회해 올 수 있게 된다.
-> 따라서 이 BatchSize를 설정하는 옵션은 디폴트로 켜놓는다고 생각하자!
-> BatchSize는
-> hibernate.default_batch_fetch_size를 통해 글로벌 설정을 할 수도 있고
-> @BatchSize 를 통해 특정 컬렉션에 개별 적용할 수 도 있다.
- 이때 이 BatchSize는 맥시멈 1000을 넘기지 않는게 좋다
-> DB별로 1000을 넘길 경우 오류를 일으킬 수도 있기 때문이다.
-> 또한 1000을 넘기지 않더라도 BatchSize 설정을 통해 DB에서 순간적으로 가져오는 데이터가 너무 많아 DB에 순간 부하를 줄 수 있으니,
-> 시행착오를 통해 적절한 값을 설정하는 것이 좋다(100~1000이하 값)
이 페이징과 한계돌파 방법을 기반으로 최적화 한 코드는 아래와 같다.
/** < applicaion.yml 파일에서 BatchSize를 글로벌 설정함> */
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
# show_sql: true
format_sql: true
default_batch_fetch_size: 100
/**
* <Order 조회>
*
* 1) 단 ManyToOne, OneToOne으로 연관된 Member와 Delivery도 함께 페치 조인
* (이떄 Member나 Delivery에 대해 ~ToOne으로 연관된 얘들도 별칭 사용으로 함꼐 페치조인 가능)
*
* 2) 또한 이떄는 row 수가 늘어나지 않으니깐 , 페이징을 시도한다
* */
public List<Order> findAllWithMemberDeliveryPaging(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();
}
/**
* <Order 정보를 통해 , 모든 OrderItem 및 Item 조회 API>
*
* [버전3.1]
* : 페이징과 한계돌파
*
* - Order와 ManyToOne/OneToOne 관계인 Member와 Delivery의 경우 페치조인으로 같이 가져와도 데이터 뻥튀기가 안됨. so Member와
* Deliery는 함께 페치조인으로 가져오면서 && 데이터뻥튀기가 안되는 지금 페이징을 함
* - 그 이후 연관된 각 Order별 OrderItem들은 그냥 지연로딩에 의해 추가적으로 나가는 쿼리에 의해 가져옴
* 단 이떄 @BatchSize값을 설정하여 , 한번에 X개의 Order에 대한 OrderItem들을 미리 가져오게 하므로써 (내부적으로 SQL IN절이 사용됨)
* 지연로딩에 의한 1+N 문제를 1+X 까지 최적화 시킬 수 있음.
*
* */
@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> orderList = orderRepository.findAllWithMemberDeliveryPaging(offset, limit);
List<OrderDto> orderDtoList = orderList.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return orderDtoList;
}