🧙
이전 포스팅인 API 개발 고급 - 지연로딩(LAZY)과 조회 성능 최적화에서 만든 주문내역에는
주문번호
, 주문자 정보
, 주문날짜와 주문상태
, 주소
이렇게 xToOne 관계만 존재했고 xToOne을 조회하는 방법과 최적화 시키는 방법을 공부 했어
이번에는 주문 정보를 가져올 때 주문 상품과 그 상품의 정보까지 조회하고 싶어서 상품정보(OrderItem & Item)
를 추가로 조회하려고 해!
Order와 OrderItem,Item은 xToMany
관계이기 때문에 xToMany 관계
를 조회하고 최적화 시키는 방법을 공부할거야.
참고로 `xToMany 관계`를 `컬렉션` 이라고 부른다
이번에도 글이 쫌 긴데 꼭 끝까지 다 읽고 이해하자!!
본격적으로 시작하기전에 먼저 알고 넘어갈 것
- 주문상품ID : id(@Id)
- 상품 : item(@ManyToOne)
- 주문 : order(@ManyToOne)
- 주문가격 : orderPrice
- 주문 수량 : count
생성메서드
- createOrderItem(상품, 가격, 수량)
비즈니스 로직
- cancel( )
- getTotalPrice( )
•Item의 overall
- 상품ID : id
- 상품명 : name
- 상품가격 : price
- 상품수량 : stockQuantity
- 카테고리 : categories(앨범, 책, 영화)
비즈니스 로직
- addStock(수량)
- removeStock(수량)
이제 진짜 시작!!
🧙 :
주문 조회 API를 만들건데 이제 배송정보, 회원 정보에 주문상품정보
까지 들어있는..
API를 만들면서 컬렉션 때문에 발생하는 성능 문제를 해결해나갈거야
API Controller의 이름은 OrderApiController.
package jpabook.jpashop.api;
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
private final OrderQueryRepository orderQueryRepository;
}
결론부터 말하자면 version_1의 방법(엔티티를 직접 노출하는 방법)은 쓰면 안돼.
왜 안되는지는 저번 포스팅에도 있고 지금 더 자세히 알아보자!!
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString((new OrderSearch())); // 모든 주문 정보를 가져와
for (Order order : all) { //하나의 주문씩 돌면서 확인할건데
order.getMember().getName(); // 주문 - 회원 - 이름, LAZY 강제 초기화
order.getDelivery().getAddress(); // 주문 - 배달 - 배송지, LAZY 강제 초기화
List<OrderItem> orderItems = order.getOrderItems(); // 모든 주문 상품의 정보를 가져와
orderItems.stream().forEach(o -> o.getItem().getName()); // 하나씩 돌면서 상품의 이름 조회, LAZY 강제 초기화
}
return all;
}
🤠 : 간단한 코드설명
- orderRepository에서 findAllByString() 메서드를 사용하여 모든 주문정보를 List<Order>에 가져와요
- 모든 주문정보를 하나씩 돌면서(for) Member와 Delivery를 조회해요 --> Lazy 강제 초기화
- 하나의 주문에는 여러개의 주문상품이 있을 수 있으므로
- order에서 getOrderItems() 메서드를 사용하여 모든 주문상품정보를 List<OrderItem>에 가져와요
- 모든 주문상품정보를 하나씩 돌면서(stream) 주문상품의 이름을 조회해요
🧙 :
위의 코드 설명에서 주목할 것은 각각의 주문(Order)에 주문상품(OrderItem)이 여러개 있으므로 모든 주문상품을 다시 List<OrderItem>
에 담아서 stream()
으로 조회하고 있다는 거야. 즉, 두단계를 거쳐서 정보를 확인하고 있지는거지.
나중에 DTO 쓸 때 조심해야되니까 기억해둬
이렇게 OrderItem과 Item 관계를 직접 초기화하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성해. 그리고 여기서도 마찬가지로 양방향 관계는 @JsonIgnore
꼭 걸어줘야해!!
하.지.만. 이제 말하기도 지겹다. 이건 엔티티를 직접 노출하는 방법이니까 쓰지마
🧙 :
엔티티를 직접 노출하지 말고 어떻게 하라고?? DTO를 만들라고!!
@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(toList());
return result;
}
🤠 : 간단한 코드설명
- findAllByString()메서드를 이용해서 모든 주문정보를 List<Order>에 가져와요
- 받아온 주문 정보를 stream()을 이용하여 하나씩 List<OrderDto>에 가져와서 모아놔요(map, collect)
@Getter
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
// private List<OrderItem> orderItems; // OrderItem도 엔티티이기 떄문에 DTO로 바꿔야 한다.
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.getOrderItems().stream().forEach(o -> o.getItem().getName());
// orderItems = order.getOrderItems();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(toList());
}
}
🤠 : 간단한 코드설명
- 주문아이디, 주문자이름, 주문날짜, 주문상태, 배송지.. 까지는 저번에 했던 주문내역과 같아요
- 주문상품(List<OrderItem> orderItems)이 추가 됐어요
- ⭐️주문상품도 엔티티이기 때문에 DTO로 변환해줘야 해요⭐️
- 이에 맞춰 생성자를 선언해요
- orderItems은 DTO로 변환 과정이 필요해요
- 모든 주문상품(OrderItem)을 가져온뒤 각각의 주문상품을 stream을 통해
주문상품DTO(OrderItemDto)에 넣어주고 모아놔요(map, collect)
- *주석처리는 OrderItem 엔티티를 직접 노출하는 경우
@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();
}
}
🤠 : OrderItemDto. 상품명, 주문가격, 주문수량이 필요하고 생성자도 선언
🧙 : ❗️주의사항❗️
Order 엔티티를 DTO로 변환하기 위해 OrderDto라는 DTO를 만들어줬어.
근데 지금 주문내역(Order)안에서 주문상품(OrderItem) 엔티티가 또 외부로 노출되고있어.
이렇게 엔티티(Order) 안에 엔티티(OrderItem)
가 있는 경우, 안쪽의 엔티티(OrderItem)
도 DTO로 변환해줘야해!
엔티티를 외부로 노출하면 안되니까 껍데기만 한번 감싸고 끝!!이 아니라 속에 있는것 까지 다 엔티티로 변환 해줘야 한다는거지.
(단, member, address같은 value 오브젝트는 노출해도 된다)
DTO 생성할 때 많이 실수하는 부분이니까 꼭 알아두도록해!
🧙 :
엔티티를 DTO로 변환해서 엔티티 외부 노출을 막긴 했는데 이제 문제는 뭐다?? 성능이다~~
이렇게 하면 쿼리가 너어어어무 많이 나가
DB 입장에서 1대N join이 있을 때 데이터양이 N만큼으로 증가해버려
예제를 예시로 들어볼게
주문내역
을 조회하는 쿼리 1번Member, Address
: N번 (전체 주문의 수)OrderItem
: N번 (전체 주문의 수)Item
: M번 (orderItem의 수)Order
- Member, Address
& OrderItem
- Item
*참고로 지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행한다.
이게 뭔말이냐고? 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않는다!
🧐 : 그러면 fetch join을 이용해서 최적화해야 한댔어!!
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
🤠 : 간단한 코드설명
- findAllWithItem() 메서드를 이용해서 모든 주문정보를 List<Order>에 가져와요
- 받아온 주문 정보를 stream()을 이용하여 하나씩 List<OrderDto>에 가져와서 모아놔요(map, collect)
🧙 :
v2랑 바뀐게 메서드 하나 밖에 없지? findAllByString() ➡️ findAllWithItem
다른건 다 똑같고 데이터를 어떻게 끌어오냐의 차이! 어떻게? fetch join으로!
그래서 할거는 orderRepository
에 fetch join으로 테이블을 조회하는 함수인 findAllWithItem()
메서드를 만드는 것 밖에 없다!
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();
}
🤠 : 간단한 코드설명
- fetch join을 이용해서 엔티티를 조회하는 쿼리문을 작성해요
- ⭐️ `distinct`가 중요해요 ⭐️
🧙 :
여기서 주목할 것은 select절에 있는 distinct
야.
JPA의 distinct는 MySQL에서 쓰이는 distinct랑은 쫌 다른게, order의 id가 같으면 중복을 제거해줘
이게 무슨말이냐면..
만약 distinct가 없다면
하나의 주문내역에는 여러개의 주문상품이 있을 수 있지? 바로 1대N 관계인 상황인데
여기서 그냥 join을 해버리면 데이터베이스의 row가 증가해버려.
order - orderItem1/orderItem2/orderItem3
join 후 —>
order - orderItem1
order - orderItem2
order - orderItem3
여기서 order은 다 똑같은 값!
그 결과 같은 order의 엔티티 조회수도 증가해버려.
이때 distinct
를 쓰면 SQL에 distinct 옵션을 추가하고 & 같은 엔티티가 조회되면 애플리케이션에서 중복을 걸러줘!!
JPA -
distinct
1. SQL에 distinct 옵션 추가
2. 같은 엔티티가 조회된 경우애플리케이션에서 중복을 걸러줌
🧙 :
아무튼 이렇게 하면 order가 fetch join 때문에 중복 조회 되는 것을 막아준다~ 즉, SQL이 1번만 실행된다~
➡️ 전체 주문내역을 조회하는 쿼리가 1번 나갈건데 이때 Member, Address,OrderItem,Item이 싹다 fetch join으로 묶여서 같이 조회된다!
전체 주문내역 + Member, Address,OrderItem,Item
을 조회하는 쿼리 1번🧙 :
하지만 이 방법은 ❗️페이징이 불가능
❗️하다는 단점이 있어
정확히 말해서 가능은 한데 매우매우매우 위험해져
Hibernate가 경고 로그를 남기면서 모든 데이터를 DB에서 메모리로 읽어오고,
메모리에서 페이징 하는데 이때 데이터가 방대하다면?? Out Of Memory로 심각한 오류가 발생!
이렇게 되는 이유는 1:N인 컬렉션에서 페이징은 1을 기준으로 해야되는데
fetch join을 하면 N을 기준으로 row가 생성되기 때문이야
참고: 컬렉션 페치 조인을 사용하면 페이징이 불가능하다.
하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고,
메모리에서 페이징 해버린다(매우 위험하다).
자세한 내용은 자바 ORM 표준 JPA 프로그래밍의 페치 조인 부분을 참고하자.
참고: 컬렉션 페치 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상에 페치 조인을 사용하면 안된다.
데이터가 부정합하게 조회될 수 있다.
자세한 내용은 자바 ORM 표준 JPA 프로그래밍을 참고하자.
🧙 :
윗부분을 정리하자면
- 컬렉션을 fetch join하면 페이징이 불가능하다
- 컬렉션을 fetch join하면 1대N 조인이 발생하므로 데이터가 예측할 수 없이(겁나 많이) 증가한다
- 1대N 에서 1(Order)을 기준으로 페이징을 하는것이 목적이지만
- 데이터는 N(OrderItem)을 기준으로 row가 생성된다
- 이 경우 Hibernate는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도하며 Out of Memeory가 발생할 수 있다
🧐 : 그럼 페이징 + 컬렉션 엔티티를 함께 조회 못해??
🧙 : 그럴리가. 안됐으면좋겠다
/**
* V3.1 엔티티를 조회해서 DTO로 변환 페이징 고려
*-ToOne 관계만 우선 모두 페치 조인으로 최적화
* - 컬렉션 관계는 hibernate.default_batch_fetch_size, @BatchSize로 최적화
*/
@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); //xToOne 관계인 애들은 그냥 fetch join으로 가져온다
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
🤠 : 간단한 코드설명
- (int)offset, (int)limit 파라미터를 받아서
- findAllWithMemberDelivery(offset, limit) 메서드를 이용해서 모든 주문정보를 List<Order>에 가져와요
- 받아온 주문 정보를 stream()을 이용하여 하나씩 List<OrderDto>에 가져와서 모아놔요(map, collect)
🧙 :
v3때와 비교했을 때 바뀐건 메서드 findAllWithItem ➡️ findAllWithMemberDelivery(offset, limit)
밖에 없어!(인자를 받는 메서드를 오버로딩)
🧐 : findAllWithMemberDelivery()
이거 어디서 본거같은데..?
🧙 : 지연로딩 최적화 v3에서 썼어!
그럼 findAllWithMemberDelivery(offset, limit)
를 확인해봐야겠지?
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();
}
🤠 : 간단한 코드설명
- fetch join을 이용해서 엔티티를 조회하는 쿼리문을 작성해요
- 여기서는 distinct를 쓰지 않아요
- `setFirstResult(offset)` , `setMaxResults(limit)`으로 페이징 처리를 해요
- 🧐 : ordetItem은 언제 조회해?
- 🤠 : orderItem은 OrderDto - 생성자에서 조회돼요
🧐 : BatchSize
가 뭔데?
🧙 : 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회할 수 있게 해주는 친구야. (IN은 여러개를 한번에 땡겨오는 SQL명령어)
BatchSize를 안썼을 때 쿼리 호출수가 1 + N인데
(주문내역 + Member, Address
조회를 위해 1번, orderItem
조회를 위해 N번 )
BatchSize를 사용하면 in 쿼리로 한번에 가져오기 때문에 쿼리 호출수가 1 + 1로 줄어들어
(주문내역 + Member, Address
조회를 위해 1번, orderItem
조회를 위해 1번)
🧐 : ❓
그럼 이때 orderItem을 조회 할 때의 저 1은 size에 해당하는 만큼만이겠네??
데이터가 1000개인데 size가 1000이면 1번 조회하는데
데이터가 2000개인데 size가 1000이면 2번 조회해야잖아
정확히 1 + (int(N/size) + 1) 인거 아닌가?
🧙 : ... 그러게
🧐 : 어떻게 적용해?
spring:
jpa:
propeties:
hibernate:
default_batch_fetch_size: 100
🤠 : application.yml 파일에 위와 같은 코드를 추가해요
@BatchSize(size = 1000)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
🤠 : domain - Order 엔티티에서 ToMany 관계인 엔티티에게 개별적으로 @BatchSize를 달아요
🧙 : 그냥 application.yml에 한번세 설정해주는게 편하고 좋아🙂
정리
- xToOne 관계를 모두 fetch join한다. ToOne 관계는 row 수를 증가 시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
- 컬렉션은 지연 로딩으로 조회한다
- 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size를 적용한다(전역설정)
- 개별로 적용하고 싶다면 @BatchSize를 적용한다
참고: paging 가능하게 하는 함수 만들때 ToOne인 애들은 빼도 되는데 그러면 쿼리가 따로 나가서 그냥 같이 묶어주는게 좋다.
🧙 :
컬렉션에서도 최적화 of 최적화인 DTO를 직접 조회하는 방법도 알아보자!!
엔티티가 아닌 DTO를 바로 조회할 거니까 DTO와, DTO를 조회 하는 함수를 만들거야
또 Order를 조회하면 OrderItem이 엔티티로 조회되기 때문에 OrderItem에 대한 DTO도 만들어야돼!!
DTO를 find하는 함수는 리포지토리에서 만들건데 이 리포지토리는 api에 맞는(화면 출력에 맞는) 전용 리포지토리 이기 때문에 핵심 비즈니스 로직은 아니야.
따라서 패키지를 따로 빼줘서 만들어주는게 좋아! DTO도 이 패키지 안에 따로 생성해주자.
repository.order.query
OrderQueryDto
OrderItemQueryDto
OrderQueryRepository - findOrder( ), findOrderQueryDtos( )
🧐 : ❓
v2할 때 OrderApiController에서 DTO 만들었잖아?
DTO는 굳이 패키지에서 따로 안만들고 v2했던 것 처럼 controller에서 바로 만들어서 쓰면 안돼?
🧙 : 안돼❗️
리포지토리가 컨트롤러를 참조하는 경우가 발생하면 안되기 때문!!
v2에서는 DTO를 컨트롤러에서 생성해서 컨트롤러가 쓰는건데
지금은 리포지토리에서 쓸거라서 컨트롤러에서 만들면 리포지토리가 컨트롤러를 참조하는 꼴이돼.
또 어차피 저 리포지토리가 DTO알아야되니까 같은 패키지 안에 만들거야
@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;
// 생성자
// DTO가 엔티티를 파라미터로 받는게 아닌 필요한 데이터를 인자로 받아온다.
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;
}
}
🤠 : 그냥.. DTO에요
@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;
}
}
🤠 : 그냥.. DTO에요
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
public final EntityManager em;
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();
}
public List<OrderQueryDto> findOrderQueryDtos() {
List<OrderQueryDto> result = findOrders(); // order 조회하는 query 1번 --> N개의 order 조회
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId()); // orderItem 조회 : query N번
o.setOrderItems(orderItems);
});
return result;
}
}
🤠 : 코드 설명
- findOrders( ) : 주문을 조회하는 메서드
- 어떤 데이터를 가져올지는 new 키워드써서 직접 정의하고
- Member와 Address를 join해요
- findOrderItems(Long orderId) : 주문상품을 조회하는 메서드
- 어떤 데이터를 가져올지는 new 키워드써서 직접 정의하고
- item을 join해요
- 파라미터로 주문아이디를 줘서 주문아이디 별로 조회해요
- findOrderQueryDtos() : 위에서 정의한 두 함수를 사용
- findOrder( )로 전체 주문 조회 1번 ➡️ 결과 : N개의 주문 조회
- findOrderItems( )로 N개의 주문 중 각각의 주문에서 주문상품 조회 N번 ➡️ 결과 : M개의 상품 조회
🧙 :
힘들다.. 준비할게 너무 많아...
준비가 끝났으면 컨트롤러에서 v4 메서드를 작성하자!!
@GetMapping("api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueryDtos();
}
🤠 : orderQueryRepository에서 findOrderQueryDtos( )를 호출해요
🧙 :
...! DTO를 직접조회하기 위한 DTO와 리포지토리만 만들어주면 v4 메서드를 다 작성한거네
정리
- Query : fetch join 1번 , 컬렉션 N번
- @xToOne - fetch join : order - member - delivery : 조인해도 row 가 증가하지 않는다
- @xToMany - 컬렉션 : order - orderItem : 조인하면 row가 증가한다
➡️ 1+N 문제 발생
그럼이제 1+N 문제를 해결해야겠군!!
🧙 :
컬렉션 조회를 최적화하기 위한 find 함수를 작성할거야.
find함수 —> 리포지토리
화면 출력을 위한 메서드 —> OrderQueryRepository
findAllByDto_optimization( ) 코드
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<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;
}
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders(); // ToOne 한번에 조회
List<Long> orderIds = toOrderIds(result);
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(orderIds);
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
🤠 : 코드설명
- findOrder() : 주문을 조회하는 메서드 (위에서 함)
- toOrderIds(List<OrderQueryDto> result) : 주문아이디를 조회하는 메서드
- 주문DTO를 파라미터로 받아서 stream()을 이용해 하나씩 orderIds에 넣어요
- 주문DTO 필드 중 orderId를 골라서 map으로 하나씩 저장해서 collect 해요
- findOrderItemMap(List<Long> orderIds) : 주문상품을 조회하는 메서드
- 어떤 데이터를 가져올지는 new 키워드써서 직접 정의하고 item을 join해서
- 파라미터로 받은 주문아이디별로 조회해요
- ⭐️ MAP을 사용해서 N번의 쿼리를 1번의 쿼리로 끝내요 ⭐️
- findAllByDto_optimization() : 위에서 정의한 세 함수를 사용
- findOrder()로 전체 주문 조회 1번 ➡️ 결과 : N개의 주문 조회
- toOrderIds()로 주문아이디를 조회한 후
- findOrderItemMap()으로 N개의 주문에 있는 상품들을 모아서 한번에 조회
➡️ 쿼리수 1 + 1
🧙 : 쓰다보니 코드설명에서 다 써놨는데 그래도 다시 설명하자면
MAP
이 필요해!!주문아이디는 주문 DTO에서 주문아이디(orderId)만 쏙 빼서 stream
으로 하나씩 하나씩 넣어줄거야.
그럼 이제 주문상품 DTO에서 가져올 데이터를 주문아이디 별로 받아서 조회할건데 이때 MAP
을 이용하면 하나씩 하나씩 세팅해놓고 한번에 쿼리 처리하는 방식이 가능해져 ➡️ 컬렉션 쿼리 1번
🧐 :
그럼 여기서 키포인트는 stream과 MAP이네
MAP
을 이용하여 컬렉션을 한번의 쿼리로 줄일건데
MAP의 인자를 넣어주기 위해서 stream
으로 orderId를 조회한다
🧙 :
그럼 이제 컨트롤러에서 메서드 정의하고 매핑만 해주면 끝!
@GetMapping("api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findAllByDto_optimization();
}
🧐 : 아 쿼리 두번도 싫어. 한번에 못끝내?
제발 안된다고 해줘
🧙 : 가능!
컬렉션 조회를 최적화하기 위한 find 함수를 작성할거야..
근데 이제 쿼리가 한번으로 끝나는!
find 함수 정의 —> 리포지토리
화면출력을 위한 메서드 —> OrderQueryRepository
그러기 위해서는 DTO를 따로 만들어줘야돼
@Data
public class OrderFlatDto {
private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간
private Address address;
private OrderStatus orderStatus;
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;
}
}
🤠 : 필요한 필드를 선언하고 생성자까지 만들어요
🧙 : FlatDto를 만들었으니 쿼리리 한번으로 조회할 수 있는 메서드를 만들어보자
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();
}
🤠 : OrderFlatDto에서 필요한 데이터를 뽑아서(new) 조회하는 쿼리문을 만들어요
🧙 :
이렇게 모든 데이터를 한번에 뽑아왔으면 이 데이터를 화면출력 하기 위해서 가공해줘야돼
데이터를 한번에 뽑아왔다는건 ToOne과 ToMany가 한번에 조회된다는건데
지금까지 OrderQueryDto에서의 생성자는 ToOne, ToMany를 따로 조회하는 방법이었기(orderItem을 생성하기 위해 한번 더 들어감) 때문에 두단계를 거치는 것이 아닌 한번에 생성되는 생성자를 만들어줘야돼(= 그냥 파라미터에 List 넣어주는겨
@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {
// 생성자2
// 파라미터에 List<OrderItemQueryDto>가 추가
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;
}
🤠 : 파라미터에 OrderItemQueryDto를 받는 부분이 추가됐어요
🧙 : 모든 준비는 끝났다..!
이제 컨트롤러에서 메서드 정의하고 매핑만 해주면 끝!
@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());
}
🤠 : 복잡해요..
🧙 :
이렇게 하면 쿼리가 1번만에 끝난다는 엄청난 장점이 있지만
한번에 뽑아온 데이터를 원하는대로 가공해서 써야되는데 추가작업(데이터를 가공)은 애플리케이션에서 해야된다는 단점이 있고
(이렇게까지 해야돼?)
조인으로 인해서 DB에서 애플리케이션으로 전달되는 데이터에 중복 데이터가 추가되므로 상황에 따라 v5보다 느릴수도 있다는 단점이 있어.
• 엔티티 조회
- 엔티티를 조회해서 그대로 반환: V1
- 엔티티 조회 후 DTO로 변환: V2
- 페치 조인으로 쿼리 수 최적화: V3
- 컬렉션 페이징과 한계 돌파: V3.1
- 컬렉션은 페치 조인시 페이징이 불가능
- ToOne 관계는 페치 조인으로 쿼리 수 최적화
- 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size , @BatchSize 로 최적화
• DTO 직접 조회
- JPA에서 DTO를 직접 조회: V4
- 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화: V5
- 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환: V6
• 권장 순서
1. 엔티티조회방식으로우선접근
1. 페치조인으로 쿼리 수를 최적화
2. 컬렉션 최적화
1. 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화
2. 페이징 필요X 페치 조인 사용
2. 엔티티조회방식으로해결이안되면DTO조회방식사용
3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate
> 참고: 엔티티 조회 방식은 페치 조인이나, hibernate.default_batch_fetch_size ,
@BatchSize 같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다.
반면에 DTO를 직접 조회하는 방식은 성능을 최적화 하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.
> 참고: 개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야 한다.
항상 그런 것은 아니지만, 보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고간다.
> 엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다.
> 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 줄타기를 해야 한다.
• DTO 조회 방식의 선택지
DTO로 조회하는 방법도 각각 장단이 있다.
V4, V5, V6에서 단순하게 쿼리가 1번 실행된다고 V6이 항상 좋은 방법인 것은 아니다.
V4는 코드가 단순하다. 특정 주문 한건만 조회하면 이 방식을 사용해도 성능이 잘 나온다.
예를 들어서 조회한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.
V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 대신에 이것을 최적화한 V5 방식을 사용해야 한다.
예를 들어서 조회한 Order 데이터가 1000건인데, V4 방식을 그대로 사용하면, 쿼리가 총 1 + 1000번 실행된다.
여기서 1은 Order 를 조회한 쿼리고, 1000은 조회된 Order의 row 수다.
V5 방식으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다.
상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성능 차이가 날 수 있다.
V6는 완전히 다른 접근방식이다.
쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페이징이 불가능하다.
실무에서는 이정도 데이터면 수백이나, 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다.
그리고 데이터가 많으면 중복 전송이 증가해서 V5와 비교해서 성능 차이도 미비하다.