@GetMapping("/api/v1/orders")
public List<Order> ordersV1(){
List<Order> all = orderRepository.findAll(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;
}
<이건 기본적으로 추가해줘야하는!>
@Bean
Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}
강제로 프록시 초기화를 시켜주어, orderItems 각 객체마다, orderItem 프록시를 초기화 시켜주어 name에 값을 불러온다.
데이터는 잘 불러와지나, 각각 프록시 강제 초기화마다 sql 문을 날리는 것을 확인할 수 있음.
엔티티를 직접 노출하기 때문에, api 스펙이 Entity의 영향을 받게 됨
일단 DTO 를 만들자
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2(){
List<Order> all = orderRepository.findAll(new OrderSearch());
List<OrderDto> collect = all.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
return collect;
}
@Data
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();
order.getOrderItems().stream().forEach(o -> o.getItem().getName());
orderItems = order.getOrderItems();
}
}
이제 Entity의 변경에 Api 스펙이 변경되는 일은 없다!
(컴파일 에러로 잡을 수 있게 됨)
@Data
는 api 끝단에서 역할!
DTO 안에 Entity 가 있는 것은 좋지 않음!
직접적으로 1차 Api 스펙에는 영향을 주지 않지만, 내부 Entity 속성이 그대로 노출됨
(OrderItem, Item)
- 엔티티에 대한 의존을 완전하게 끊어야함!!
귀찮더라도,List< OrderItem >
을 전부 DTO 로 변환해 주어야함
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems; // DTO로 수정
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
// DTO 로 수정
orderItems = order.getOrderItems().stream().map(o -> new OrderItemDto(o)).collect(Collectors.toList());
}
}
// 새로운 DTO
@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();
}
}
원하는 정보들만 뽑아내서 DTO로 만들 수 있음!!
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3(){
List<Order> all = orderRepository.findAllWithItem();
List<OrderDto> collect = all.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
return collect;
}
<OrderRepository.java>
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).getResultList();
}
이처럼 fetch join
하면 될 것 같음!!
(이 사진도 다가 아님)
정말 어마무시한 쿼리문 하나가 나가는 것을 볼 수 있다!!
n 번의 쿼리를 날리면서 성능을 잡아먹다가, 한번으로 최적화된 모습!
Fetch Join
의 고질적인 문제인, 테이블 데이터 중복!!
데이터를 받아보면 티가 난다! 같은 id 값이 두번 들어옴!!
Database 입장에서는 table join 이 되어버린 것임! (데이터 뻥튀기)
아이러니하게도 같은 참조값을 가지기는 함! jpa 가 자체적으로 같은 객체로 만들지만, 알아서 하나로 보내주지는 않음!!
기억나는가? 어떤걸 써야하는지? 바로Distinct
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();
}
이제 중복없이 잘 나온다!!
SQL 문에서의
distinct
와 무엇이 다를까?
DB Query 에서의 행동이 다르다기 보다는! (이거는distinct
가 있으나 없으나 같음)
JPA 에서 자체적으로 버려주는 기능임!!! (들어오는 데이터는 뻥튀기 된 채로 들어옴)
치명적인 단점 하나가 있음!!!!!!!!!!!!!!!!!!!!!
진정한 의미의페이징
이 불가능해진다
전부다 받아온 다음에, 그 데이터를 기반으로 페이징처럼 출력을 함!!
(hibernate에서도 warn 창을 띄워줌)
일대다의 경우엔 오히려
fetch join
을 사용하지 않는 것이 좋음!
페이징도 하고, 컬렉션 엔티티를 함꼐 조회하려면 어떻게 해야할까!!
(아주 비장하게) 지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 강력한 방법을 소개하겠다!
기존에 @xToOne
은 전부 fetch join
나머지는 하나씩 프록시 초기화 시키면서 들고오는 거 대신, batch 로 들고오는 방법으로!!
<application.yaml>
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
그러면 sql query 문이 세번만 일어남!!
100개의 프록시 초기화를 동시에 진행한다고 생각하면 됨! (lazy 걸려있는 모든 프록시들)
batch 사이즈 설정만으로도!
1 / n / n ======>>>> 1 / 1 / 1 처럼 구현할 수 있음 (batch 사이즈에 따라 약간 차이)
- 각각 프록시 초기화를 사용한다면, query sql 효율성이 떨어지고,
- 모두 fetch join 을 사용한다면, 메모리 이슈 / 페이징 이슈가 생김
1) batch를 활용해서 쿼리 듬성듬성 날리기
2)@xToOne
관계에만fetch join
사용!
2-1) join을 안하기 때문에,@OneToMany
일때 fetch join 은 데이터 뻥튀기가 일어나기에, 데이터 전송량 자체를 줄여주는 효과도 있음!! (이거 만개라고 생각하면, 데이터 양이 어마무시함)
꼭 글로벌하게 설정할 필요는 없음
@BatchSize(size = 100)
이런식으로 지정 가능
@xToOne
관계는fetch join
해도 페이징에 영향을 주지 않는다.
따라서@xToOne
관계는fetch join
으로 쿼리수를 줄이고- 나머지는 batch size 를 설정하여 최적화!!
OrderRepository
에 구현하는 것이 아닌,
새롭게 OrderQueryRepository
를 만들어서 따로 관리!!
<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;
}
}
<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;
/**
* collection 을 바로 주입 받을 수가 없음
* 해당 DTO Constructor 는 jpql 에서 사용됨.
*/
public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address/*, List<OrderItemQueryDto> orderItems*/) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.orderItems = orderItems;
}
}
<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<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();
}
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();
}
}
Query 문 : 루트 1번, 컬렉션 N번 (for 문 돌면서)
ToOne 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리한다.
row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화하기 어려우므로findOrderItems()
같은 별도의 메소드로 조회
이런 방식은 N+1 문제를 야기한다.
public List<OrderQueryDto> findAllByDto_optimization(){
List<OrderQueryDto> result = findOrders();
List<Long> orderIds = result.stream().map(o -> o.getOrderId()).collect(Collectors.toList());
List<OrderItemQueryDto> orderItemQueryDtos = 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 = orderItemQueryDtos.stream().collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
result.forEach(o -> o.setOrderItems((orderItemMap.get(o.getOrderId()))));
return result;
}
in
을 활용해서 한방에 다 가져오기!
OrderItemQueryDto
에 orderId
를 넣은 이유가 여기에 있었음!!
v4 와 다른 점은 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;
}
}
plat 형태로 DTO를 설정하여, 직접 jpql 을 설정하면, query 문은 한방에 나간다! (페이징은 x)
public List<OrderQueryDto> findAllByDto_flat(){
List<OrderFlatDto> orderFlatDtoList = 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();
return orderFlatDtoList.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());
}
추가적으로 DB 쪽 아닌, 코드 단에서 OrderQueryDto
형태로 매핑을 해주어 변환해주면 된다!
쿼리 한방에 원하는 기능을 할 수 있게 됨!!
OrderQueryDto
에 해당 어노테이션을 달아줘야,
매핑이 정상 작동!!
V5 (컬렉션 조회 최적화) vs V6 (플랫데이터 최적화)
- V5는 쿼리 2번, V6는 쿼리 1번
- V6는 쿼리가 1번이지만 조인으로 인해 중복 데이터가 추가되므로, 상황에 따라 더 느릴 수도 있음!
- 애플리케이션 추가 작업이 큼! + 페이징 x
큰일 난다!
엔티티 스펙이 변함에 따라 API 스펙이 변함
이때 문제는, 성능이 안나올 때가 있음!
fetch join 으로 하면, 페이징이 힘들때가 있음
@xToOne
관계는 페치 조인으로 쿼리 수 최적화@BatchSize
로 최적화쿼리문을 많이 날려야함.
in
jpql 문을 활용하여 쿼리 한방으로 '다' 를 불러옴
어플리케이션 단에서 매핑하여 원하는 형태로 사용
개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기!
보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고감
DTO 조회 방식의 선택지
각 v4, v5, v6 는 엔티티로 했다면 어노테이션으로 해결되는 문제들
DTO 직접 조회를 하다보면, 거의 V5를 하게 됨!