컬렉션 조회 - Dto
- OrderItems는 Order와 1대다 관계이며, 지연로딩 전략을 사용하고 있는 Entity이다. 따라서 프록시를 초기화 때마다 쿼리문이 나감(성능 이슈)
- OrderItems도 Entity이기 때문에 외부에 그냥 노출이 되지 않게 조심해야함.
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() { // 쿼리가 많이 나가는 버전.
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> collect = orders.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<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
// order에 있는 OrderItems는 Entity.
// 이런 방식으로 외부에 노출하게 되면 OrderItems라는 Entity가 외부에 노출이 됨.
// order.getOrderItems().stream().forEach(o->o.getItem().getName()); // 프록시 초기화
// orderItems도 Dto로 변환해주어야함.
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();
}
}
컬렉션 조회 - Dto, fetch
- Dto는 위와 똑같음.
- distinct를 사용한 이유는 1대다 조인이 있으므로 DB row가 증가함. 그 결과 order 엔티티의 조회 수도 증가함. distinct를 사용해주면 중복되는 order Entity를 줄여줌.
- Id가 1인 Order에 Id가 2와 3인 orderItems가 2개가 있다고 가정하면 Order1 - orderItems2 ,
Order1 - orderItems3 이런 식으로 조회가 됨. - distinct를 사용하면 JPA에서 Id가 같은 order의 중복을 없애줌. \
- Id가 1인 Order에 Id가 2와 3인 orderItems가 2개가 있다고 가정하면 Order1 - orderItems2 ,
- 컬렉션 페치 조인은 1개만 사용할 수 있음. 컬렉션 둘 이상에 페치 조인을 사용하면 안 됨. 데이터가 부정합하게 조회될 수 있음.
- fetch를 사용하면 페이징 사용이 불가능함.
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() { // 쿼리가 많이 나가는 버전.
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o))
.collect(Collectors.toList());
return collect;
}
// 쿼리
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();
}
페이징과 한계 돌파
- ToOne(OneToOne, ManyToOne) 관계를 모드 페치조인 한다. (row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않음)
- 컬렉션은 지연 로딩으로 조회한다.
- 지연 로딩 성능 최적화를 위해 'hibernate.default_batch_fetch_size', '@BatchSize'를 적용한다
- hibernate.default_batch_fetch_size: 지연 로딩을 사용하는 엔티티에서 일괄 조회할 때 fetch size를 설정하는 옵션 (글로벌 옵션)
- @BatchSize: 개별 최적화
- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
- 장점
- 쿼리 호출 수가 1+N -> 1 + 1로 최적화
- 조인보다 DB 데이터 전송량이 최적화 된다.
- 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다
- 결론
- ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않음. 따라서 ToOne 관계는 페치 조인으로 쿼리 수를 줄여 해결하고, 나머지는 hibernate.default_batch_fetch_size 로 최적화하자
// controller
@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> collect = orders.stream().map(o -> new OrderDto(o))
.collect(Collectors.toList());
// 지연 로딩이기 때문에 프록시를 초기화할 때 원래는 쿼리가 날라가지만, batch_fetch_size로 데이터를 미리 가져와서 쿼리가 안 나감.
return collect;
}
// repository에 쿼리 작성
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o " + // ToOne 관계
" join fetch o.member m " + // ToOne 관계
" join fetch o.delivery d ", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
// application.yml
// 글로벌 설정
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
# show_sql: true
format_sql: true
default_batch_fetch_size: 100 // 한번에 가져올 데이터 수
개별로 설정하고 싶으면?
// ToOne, Entity 위에 어노테이션 사용
@BatchSize(size = 150)
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {
@BatchSize(size = 200) // ToMany, 컬럼 위에 어노테이션 사용
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
JPA에서 DTO 직접 조회
- Query: 루트 1번, 컬렉션 N번 실행
- ToOne(N:1, 1:1)관계들을 먼저 조회하고, ToMany(1:N)관계는 각각 별도로 처리
- ToOne 관계는 조인해도 데이터 row 수가 증가하지 않음.
- ToMany(1:N) 관계는 조인하면 row 수가 증가함
- row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회함.
OrderItemQueryDto
// OrderItemQueryDto
@Data
public class OrderItemQueryDto {
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
// OrderQueryDto
package jpabook.jpashop.repository.query;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@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;
}
}
// 쿼리
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.repository.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();
}
public List<OrderQueryDto> findOrders(){
return em.createQuery(
"select new jpabook.jpashop.repository.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();
}
// Controller
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueryDtos();
}
JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
- 쿼리: 루트1번, 컬렉션 1번
- ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem을 한꺼번에 조회
- MAP을 사용해서 매칭 성능 향상(O(1))
쿼리
// 쿼리
public List<OrderQueryDto> findOrders(){
return em.createQuery(
"select new jpabook.jpashop.repository.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();
}
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders(); // OrderDto, 이 때 쿼리 1번
List<Long> orderIds = result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
// V4와는 다른 점이 쿼리 마지막 부분에 in절 사용. 그리고 order의 id를 1개씩 넣는 게 아니라, List 형식으로 넣어주었음.
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new jpabook.jpashop.repository.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(); // 쿼리 2번
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream() // orderId와 OrderItems를 묶어줌.
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId)); // groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId());
result.forEach(o->o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
// 컨트롤러
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findAllByDto_optimization();
}
JPA에서 DTO 직접 조회 , 플랫 데이터 최적화
- 장점: 쿼리 1번
- 단점
- 쿼리는 한번이지만 조인으로 인해 DB에서 애프릴케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 보다 더 느릴 수 도 있다.
- 애플리케이션에서 추가 작업이 크다.
- 페이징 불가능
// controller
@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());
}
// 쿼리
public List<OrderFlatDto> findAllByDto_flat() {
return em.createQuery(
"select new jpabook.jpashop.repository.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();
}
// OrderQueryDto Dto
package jpabook.jpashop.repository.query;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.List;
@Data
@EqualsAndHashCode(of="orderId")
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;
}
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;
}
}
// flat dto
package jpabook.jpashop.repository.query;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@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 개발 고급 정리
엔티티 조회
- 엔티티를 조회해서 그대로 반환: 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
V6 관한 내용은 아직 완벽히 이해가 되지는 않아서 추후 공부 예정...
'JPA' 카테고리의 다른 글
[Data JPA] 벌크, @EntityGraph, Hint (1) | 2024.01.30 |
---|---|
[Data JPA] 기본적인 사용법과 Dto 조회하기, 파라미터 바인딩 (1) | 2024.01.26 |
[JPA API] 주문, 배송정보, 회원 조회 API (0) | 2024.01.24 |
[JPA API] 간단한 회원 등록, 수정, 조회 API (1) | 2024.01.23 |
[JPQL] Named 쿼리 / 벌크 연산 (0) | 2024.01.22 |