목표
주문내역에서 추가로 주문한 상품 정보를 추가로 조회하자.
Order 기준으로 컬렉션인 OrderItem 와 Item 이 필요하다.
주문 조회 V1 : 엔티티 직접 노출
- 결론 : 엔티티를 직접 노출했기 때문에 사용X
📌 OrderApiController
package bookbook.shopshop.api;
import bookbook.shopshop.domain.Order;
import bookbook.shopshop.domain.OrderItem;
import bookbook.shopshop.repository.OrderSearch;
import bookbook.shopshop.repository.order.OrderRepository;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
/**
* V1. 엔티티 직접 노출
* - 엔티티가 변하면 API 스펙도 변한다.
* - 트랜잭션 안에서 지연 로딩 필요
* - 양방향 연관관계 문제
*/
@GetMapping("/api/v1/orders")
public Result 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 new Result<>(all);
}
@Data
@AllArgsConstructor
static class Result<T> {
T data;
}
}
- orderItem, item 관계를 직접 초기화하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성한다.
- 양방향 연관관계는 무한 루프가 걸리지 않게 @JsonIgnore를 추가해야한다.
- 하지만 엔티티를 반환하지 않을 경우에는 @JsonIgnore를 안붙여도 된다.
- 엔티티를 직접 노출했기 때문에 V1은 사용하면 안된다.
주문 조회 V2 : 엔티티를 DTO로 변환
📌 OrderApiController
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
/**
* V2. 엔티티를 조회해서 DTO로 변환 (fetch join 사용x)
* - 단점 : 지연로딩으로 쿼리 N번 호출
*/
@GetMapping("/api/v2/orders")
public Result ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return new Result<>(result);
}
@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(OrderItemDto::new)
.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();
}
}
@Data
@AllArgsConstructor
static class Result<T> {
T data;
}
}
- Order를 DTO로 변환하여 반환하였다.
- Order안에 있는 OrderItem도 DTO로 변환하여 반환해야하기 때문에 OrderDTO에 OrderItemDTO를 넣었다.
▼
- 총 2개의 주문(Order)가 있다.
- 하나의 주문(Order)당 하나의 회원(Member)이 존재한다.
- 하나의 회원(Member)당 2개의 주문 상품(OrderItem)이 존재한다.
select
order0_.order_id as order_id1_6_,
order0_.delivery_id as delivery4_6_,
order0_.member_id as member_i5_6_,
order0_.order_date as order_da2_6_,
order0_.status as status3_6_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id limit ?
- 먼저 Order 엔티티를 조회한다.
- 조회 결과는 2개의 주문이 나온다.
select
member0_.member_id as member_i1_4_0_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id=?
- 주문1에 대한 Member 엔티티인 회원1을 조회한다.
select
delivery0_.delivery_id as delivery1_2_0_,
delivery0_.city as city2_2_0_,
delivery0_.street as street3_2_0_,
delivery0_.zipcode as zipcode4_2_0_,
delivery0_.status as status5_2_0_
from
delivery delivery0_
where
delivery0_.delivery_id=?
- 주문1에 대한 Delivery 엔티티를 조회한다.
select
orderitems0_.order_id as order_id5_5_0_,
orderitems0_.order_item_id as order_it1_5_0_,
orderitems0_.order_item_id as order_it1_5_1_,
orderitems0_.count as count2_5_1_,
orderitems0_.item_id as item_id4_5_1_,
orderitems0_.order_id as order_id5_5_1_,
orderitems0_.order_price as order_pr3_5_1_
from
order_item orderitems0_
where
orderitems0_.order_id=?
- 하나의 회원 당 2개의 주문 상품을 가지고 있다.
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_.ect as ect7_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=?
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_.ect as ect7_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=?
- 주문 상품이 2개이므로 2개 조회하는 select절이 나간다.
select
member0_.member_id as member_i1_4_0_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id=?
- 주문2에 대한 Member 엔티티인 회원2을 조회한다.
select
delivery0_.delivery_id as delivery1_2_0_,
delivery0_.city as city2_2_0_,
delivery0_.street as street3_2_0_,
delivery0_.zipcode as zipcode4_2_0_,
delivery0_.status as status5_2_0_
from
delivery delivery0_
where
delivery0_.delivery_id=?
select
orderitems0_.order_id as order_id5_5_0_,
orderitems0_.order_item_id as order_it1_5_0_,
orderitems0_.order_item_id as order_it1_5_1_,
orderitems0_.count as count2_5_1_,
orderitems0_.item_id as item_id4_5_1_,
orderitems0_.order_id as order_id5_5_1_,
orderitems0_.order_price as order_pr3_5_1_
from
order_item orderitems0_
where
orderitems0_.order_id=?
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_.ect as ect7_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=?
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_.ect as ect7_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=?
결국 엄청나게 쿼리문이 나가게 된다.
쿼리가 총 1+N+N번이 실행된다.
- order 조회 조회 1번 (order 조회 결과 수가 N이 된다.)
- order → member 지연 로딩 조회 N번
- order → delivery 지연 로딩 조회 N번
주문 조회 V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화
1. distinct 사용 전
📌 OrderRepository (코드 추가)
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();
}
📌 OrderApiController
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용)
* - 페이징시에는 N(컬렉션) 조회를 포기해야한다.
*/
@GetMapping("/api/v3/orders")
public Result ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return new Result<>(result);
}
@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(OrderItemDto::new)
.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();
}
}
@Data
@AllArgsConstructor
static class Result<T> {
T data;
}
}
{
"data": [
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-12T21:40:58.601643",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-12T21:40:58.744643",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
}
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
orderitems3_.order_item_id as order_it1_5_3_,
item4_.item_id as item_id2_3_4_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_,
orderitems3_.count as count2_5_3_,
orderitems3_.item_id as item_id4_5_3_,
orderitems3_.order_id as order_id5_5_3_,
orderitems3_.order_price as order_pr3_5_3_,
orderitems3_.order_id as order_id5_5_0__,
orderitems3_.order_item_id as order_it1_5_0__,
item4_.name as name3_3_4_,
item4_.price as price4_3_4_,
item4_.stock_quantity as stock_qu5_3_4_,
item4_.artist as artist6_3_4_,
item4_.ect as ect7_3_4_,
item4_.author as author8_3_4_,
item4_.isbn as isbn9_3_4_,
item4_.actor as actor10_3_4_,
item4_.director as directo11_3_4_,
item4_.dtype as dtype1_3_4_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
inner join
order_item orderitems3_
on order0_.order_id=orderitems3_.order_id
inner join
item item4_
on orderitems3_.item_id=item4_.item_id
2022-04-12 23:27:07.562 INFO 13340 --- [nio-8080-exec-8] p6spy : #1649773627562 | took 24ms | statement | connection 8| url jdbc:h2:tcp://localhost/~/haha
select order0_.order_id as order_id1_6_0_, member1_.member_id as member_i1_4_1_, delivery2_.delivery_id as delivery1_2_2_, orderitems3_.order_item_id as order_it1_5_3_, item4_.item_id as item_id2_3_4_, order0_.delivery_id as delivery4_6_0_, order0_.member_id as member_i5_6_0_, order0_.order_date as order_da2_6_0_, order0_.status as status3_6_0_, member1_.city as city2_4_1_, member1_.street as street3_4_1_, member1_.zipcode as zipcode4_4_1_, member1_.name as name5_4_1_, delivery2_.city as city2_2_2_, delivery2_.street as street3_2_2_, delivery2_.zipcode as zipcode4_2_2_, delivery2_.status as status5_2_2_, orderitems3_.count as count2_5_3_, orderitems3_.item_id as item_id4_5_3_, orderitems3_.order_id as order_id5_5_3_, orderitems3_.order_price as order_pr3_5_3_, orderitems3_.order_id as order_id5_5_0__, orderitems3_.order_item_id as order_it1_5_0__, item4_.name as name3_3_4_, item4_.price as price4_3_4_, item4_.stock_quantity as stock_qu5_3_4_, item4_.artist as artist6_3_4_, item4_.ect as ect7_3_4_, item4_.author as author8_3_4_, item4_.isbn as isbn9_3_4_, item4_.actor as actor10_3_4_, item4_.director as directo11_3_4_, item4_.dtype as dtype1_3_4_ from orders order0_ inner join member member1_ on order0_.member_id=member1_.member_id inner join delivery delivery2_ on order0_.delivery_id=delivery2_.delivery_id inner join order_item orderitems3_ on order0_.order_id=orderitems3_.order_id inner join item item4_ on orderitems3_.item_id=item4_.item_id
select order0_.order_id as order_id1_6_0_, member1_.member_id as member_i1_4_1_, delivery2_.delivery_id as delivery1_2_2_, orderitems3_.order_item_id as order_it1_5_3_, item4_.item_id as item_id2_3_4_, order0_.delivery_id as delivery4_6_0_, order0_.member_id as member_i5_6_0_, order0_.order_date as order_da2_6_0_, order0_.status as status3_6_0_, member1_.city as city2_4_1_, member1_.street as street3_4_1_, member1_.zipcode as zipcode4_4_1_, member1_.name as name5_4_1_, delivery2_.city as city2_2_2_, delivery2_.street as street3_2_2_, delivery2_.zipcode as zipcode4_2_2_, delivery2_.status as status5_2_2_, orderitems3_.count as count2_5_3_, orderitems3_.item_id as item_id4_5_3_, orderitems3_.order_id as order_id5_5_3_, orderitems3_.order_price as order_pr3_5_3_, orderitems3_.order_id as order_id5_5_0__, orderitems3_.order_item_id as order_it1_5_0__, item4_.name as name3_3_4_, item4_.price as price4_3_4_, item4_.stock_quantity as stock_qu5_3_4_, item4_.artist as artist6_3_4_, item4_.ect as ect7_3_4_, item4_.author as author8_3_4_, item4_.isbn as isbn9_3_4_, item4_.actor as actor10_3_4_, item4_.director as directo11_3_4_, item4_.dtype as dtype1_3_4_ from orders order0_ inner join member member1_ on order0_.member_id=member1_.member_id inner join delivery delivery2_ on order0_.delivery_id=delivery2_.delivery_id inner join order_item orderitems3_ on order0_.order_id=orderitems3_.order_id inner join item item4_ on orderitems3_.item_id=item4_.item_id;
order ref = bookbook.shopshop.domain.Order@2c1e8364 id = 4
order ref = bookbook.shopshop.domain.Order@2c1e8364 id = 4
order ref = bookbook.shopshop.domain.Order@7279bc7e id = 11
order ref = bookbook.shopshop.domain.Order@7279bc7e id = 11
- 쿼리 한 방으로 다 해결할 수 있으나 페이징처리가 안되는게 가장 큰 단점이다.
postman 조회 결과
{
"data": [
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-12T21:41:58.684213",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-12T21:41:58.684213",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-12T21:41:58.774214",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-12T21:41:58.774214",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
}
각각의 Order 안에 Order_Item이 2개이므로 총 Order_Item 개수 만큼 나왔다.
하나의 주문(Order) 당 주문상품(Order_Item)이 2개가 존재한다.
하나의 주문(Order)에서 주문 상품(Order_Item)을 조회하면 2개의 주문 상품이 나온다. 각각의 주문 상품의 주문을 조회하면 총
일대다 조회일 때 '다'의 row 개수 만큼 뻥튀기가 된다.
2. distinct 사용 후
📌 OrderRepository (코드 추가)
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" + // distinct 추가
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class
).getResultList();
}
{
"data": [
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-12T21:40:58.601643",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-12T21:40:58.744643",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
}
select절에 distinct를 사용하니깐 중복이 제거되어 orderId가 각각 1개씩 나온 것을 알 수 있다.
🔍 컬렉션 페치 조인 단점
컬렉션 페치 조인을 사용하면 페이징이 불가능하다.
컬렉션 페치 조인은 1개만 사용할 수 있다. 둘 이상의 컬렉션을 페치 조인할 경우 데이터가 부정합하게 조회될 수 있다.
주문 조회 V3.1 : 엔티티를 DTO로 변환 - 페이징과 한계 돌파
결론 : 매우 중요하고 컬렉션을 페이징할 때 자주 쓰인다.
1. 페이징과 한계 돌파
- 컬렉션을 페치 조인하면 페이징이 불가능하다.
- 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
- 일대다에서 일(1)을 기준으로 페이징하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.
- Order를 기준으로 페이징하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.
- 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB데이터를 읽어서 메모리에서 페이징을 시도한다. 이럴 때 최악의 경우 장애로 이어질 수 있다.
🔍 페이징 + 컬렉션 엔티티를 함께 조회하는 방법
- 먼저 ToOne(OneToOne, ManyToOne)관계를 모두 페치조인한다. ToOne관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
- 컬렉션(일대다 관계)은 지연 로딩으로 조회한다.
- 지연 로딩 성능을 최적화하기 위해 hibernate.default_batch_fetch_size 또는 @Batchsize를 적용한다.
- hibernate.default_batch_fetch_size : 글로벌 설정
- 지연 로딩으로 발생하는 쿼리를 IN절로 한번에 모아보내는 기능이다.
- 이 기능을 더 권장한다.
- @BatchSize : 개별 최적화
- 이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.
- hibernate.default_batch_fetch_size : 글로벌 설정
🔍 batchSize 적용하기
기본적으로 hibernate.default_batch_fetch_size 로 글로벌하게 설정하는 것을 권장한다.
💡 hibernate.default_batch_fetch_size
📌 application.yml
jpa:
hibernate:
properties:
hibernate:
default_batch_fetch_size: 100
- 이 때 default_batch_fetch_size의 크기는 100 ~ 1000으로 정하면 된다.
- 이 전략은 SQL IN절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다.
이 때 default_batch_fetch_size의 크기는 100~1000으로 정하면 된다. 이 전략은 SQL IN절을 사용하는데, 데이터베이스에 따라 IN절 파라미터를 1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.
💡 @BatchSize
✔ 컬렉션은 컬렉션 필드에 적용한다.
package bookbook.shopshop.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.BatchSize;
import javax.persistence.*;
import javax.validation.constraints.NotEmpty;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter @Setter
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
@NotEmpty
private String name;
@Embedded
private Address address;
@BatchSize(size = 100) // 컬렉션에 @BatchSize 적용
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
✔ 엔티티는 엔티티 클래스에 적용한다.
package bookbook.shopshop.domain;
@BatchSize(size = 100) // 엔티티에 @BatchSize 적용
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = PROTECTED)
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = LAZY, cascade = ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate; // 주문 시간
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태 [ORDER / CANCEL]
}
🔍 V3.1 적용 코드
📌 OrderRepository (코드 추가)
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();
}
📌 OrderApiController
package bookbook.shopshop.api;
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
/**
* V3.1 엔티티를 조회해서 DTO로 변환 페이징 고려
* - ToOne 관계만 우선 모두 페치 조인으로 최적화한다.
* - 컬렉션 관계는 hibernate.default_batch_fetch_size 또는 @BatchSize로 최적화한다.
*/
@GetMapping("/api/v3.1/orders")
public Result ordersV3_page(
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return new Result<>(result);
}
@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(OrderItemDto::new)
.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();
}
}
@Data
@AllArgsConstructor
static class Result<T> {
T data;
}
}
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id limit ?
----------------------------------------------------------------------
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_.ect as ect7_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 (
?, ?, ?, ?
)
- V3 버전에 비해서 쿼리가 여러번 나가긴 했으나 최적화가 되어있고 컬렉션을 페이징 할 수 있다.
- 지연로딩으로 1+N 쿼리가 생기는 문제를 IN절을 통해 한꺼번에 모아서 보냄으로써 1+1로 최적화 했다.
2. V3.1 장점
- 장점
- 쿼리 호출수가 1+N → 1+1 로 최적화 된다.
- 조인보다 DB 데이터 전송량이 최적화된다. (Order와 OrderItem을 조인하면 Order가 OrderItem만큼 중복해서 조회된다. 그러나 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)
- 페치조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
- 결론
- ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다.
- 따라서 ToOne 관계는 페치 조인으로 쿼리 수를 줄이고 해결하고 나머지는 hibernate.default_batch_fetch_size로 최적화 하자
주문 조회 V4 : JPA에서 DTO 직접 조회
- 결론 : 문제가 너무 많다.
📌 OrderQueryDto
package bookbook.shopshop.repository.order.query;
@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;
}
}
📌 OrderItemQueryDto
package bookbook.shopshop.repository.order.query;
@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;
}
}
📌 OrderQueryRepository
package bookbook.shopshop.repository.order.query;
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
/**
* 컬렉션은 별도로 조회
* Query : 루트 1번, 컬렉션 N번
* 단건 조회에서 많이 사용하는 방식
*/
public List<OrderQueryDto> findOrderQueryDtos() {
// 루트 조회 (ToOne 코드를 모두 한번에 조회)
// 첫 번째로 나가는 쿼리
List<OrderQueryDto> result = findOrders(); // query 1번에 Order가 N개의 결과가 나옴
// 루프를 돌면서 컬렉션 추가 (추가 쿼리 실행)
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId()); // Query N번 실행
o.setOrderItems(orderItems);
});
return result;
}
/**
* 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
*/
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new bookbook.shopshop.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:N 관계인 orderItems 조회
*/
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new bookbook.shopshop.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();
}
}
- findOrderQueryDtos() 메서드에서 N+1 문제가 발생한다.
📌 OrderApiController
package bookbook.shopshop.api;
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
/**
* V4
* - N + 1 문제 발생
*/
@GetMapping("/api/v4/orders")
public Result ordersV4() {
List<OrderQueryDto> result = orderQueryRepository.findOrderQueryDtos();
return new Result<>(result);
}
@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(OrderItemDto::new)
.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();
}
}
@Data
@AllArgsConstructor
static class Result<T> {
T data;
}
}
select
order0_.order_id as col_0_0_,
member1_.name as col_1_0_,
order0_.order_date as col_2_0_,
order0_.status as col_3_0_,
delivery2_.city as col_4_0_,
delivery2_.street as col_4_1_,
delivery2_.zipcode as col_4_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
----------------------------------------------------------------------
select
orderitem0_.order_id as col_0_0_,
item1_.name as col_1_0_,
orderitem0_.order_price as col_2_0_,
orderitem0_.count as col_3_0_
from
order_item orderitem0_
inner join
item item1_
on orderitem0_.item_id=item1_.item_id
where
orderitem0_.order_id=?
----------------------------------------------------------------------
select
orderitem0_.order_id as col_0_0_,
item1_.name as col_1_0_,
orderitem0_.order_price as col_2_0_,
orderitem0_.count as col_3_0_
from
order_item orderitem0_
inner join
item item1_
on orderitem0_.item_id=item1_.item_id
where
orderitem0_.order_id=?
- Query가 루트 1번, 컬렉션 N번 실행된다.
- 루트는 첫 번째 나가는 쿼리를 의미한다.
- ToOne(N:1 / 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리했다.
- 이렇게 한 이유는 ToOne 관계는 조인해도 데이터 row수가 증가하지 않는다.
- ToMany관계는 조인하면 row수가 증가하기 때문이다.
- row수가 증가하기 않는 ToOne 관계는 조인으로 최적화하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화 하기 어렵기 때문에 findOrderItems() 같은 별도의 메서드로 조회한다.
결론 : V4는 N+1 문제를 발생한다. 이것을 V5에서 해결하도록 하겠다.
주문 조회 V5 : JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
📌 OrderQueryRepository
package bookbook.shopshop.repository.order.query;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
/**
* 최적화
* Query : 루트 1번, 컬렉션 1번 실행
* 데이터를 한꺼번에 처리할 때 많이 사용하는 방식이다.
*/
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders(); // 루트 쿼리. 주문 2개가 조회된다.
// orderId가 담긴 리스트로 변환
List<Long> orderIds = toOrderIds(result);
// 키값은 OrderItemQueryDto::getOrderId 다.
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(orderIds);
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
// 두번째 쿼리 발생 지점
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new bookbook.shopshop.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) // IN절로 최적화
.setParameter("orderIds", orderIds)
.getResultList();
return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}
private List<Long> toOrderIds(List<OrderQueryDto> result) {
List<Long> orderIds = result.stream()
.map(OrderQueryDto::getOrderId)
.collect(Collectors.toList());
return orderIds;
}
}
- ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem을 한꺼번에 조회한다.
- Map을 사용해서 매칭 성능을 향상 시켰다.
- V4는 for문을 돌릴 떄 마다 쿼리를 날렸지만 V5는 쿼리는 한 번 날리고 메모리에서 Map을 가져온다. 그리고 Map에 있는 값을 매치해서 세팅을 한다.
📌 OrderApiController
package bookbook.shopshop.api;
import bookbook.shopshop.domain.Address;
import bookbook.shopshop.domain.Order;
import bookbook.shopshop.domain.OrderItem;
import bookbook.shopshop.domain.OrderStatus;
import bookbook.shopshop.repository.OrderSearch;
import bookbook.shopshop.repository.order.OrderRepository;
import bookbook.shopshop.repository.order.query.OrderQueryDto;
import bookbook.shopshop.repository.order.query.OrderQueryRepository;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.sql.ResultSet;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
private final OrderQueryRepository orderQueryRepository;
/**
* V5
*/
@GetMapping("/api/v5/orders")
public Result orderV5() {
List<OrderQueryDto> result = orderQueryRepository.findAllByDto_optimization();
return new Result<>(result);
}
@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(OrderItemDto::new)
.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();
}
}
@Data
@AllArgsConstructor
static class Result<T> {
T data;
}
}
select
order0_.order_id as col_0_0_,
member1_.name as col_1_0_,
order0_.order_date as col_2_0_,
order0_.status as col_3_0_,
delivery2_.city as col_4_0_,
delivery2_.street as col_4_1_,
delivery2_.zipcode as col_4_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
---------------------------------------------------------------------
select
orderitem0_.order_id as col_0_0_,
item1_.name as col_1_0_,
orderitem0_.order_price as col_2_0_,
orderitem0_.count as col_3_0_
from
order_item orderitem0_
inner join
item item1_
on orderitem0_.item_id=item1_.item_id
where
orderitem0_.order_id in (
? , ?
)
- OrderItem을 가져올 떄 IN 절로 한꺼번에 가져왔다.
- 엔티티 같은 경우는 batchsize로 한꺼번에 가져올 수 있지만 DTO에서는 batchsize를 쓸 수 없다.
주문 조회 V6 : JPA에서 DTO로 직접 조회, 플렛 데이터 최적화
- DB에서 한번에 다 가져오는 방식이다.
📌 OrderFlatDto
package bookbook.shopshop.repository.order.query;
@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;
}
}
📌 OrderQueryRepository
package bookbook.shopshop.repository.order.query;
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
public List<OrderFlatDto> findAllByDto_flat() {
return em.createQuery(
"select new bookbook.shopshop.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();
}
}
📌 OrderApiController
package bookbook.shopshop.api;
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
private final OrderQueryRepository orderQueryRepository;
/**
* V6
*/
@GetMapping("/api/v6/orders")
public Result orderV6() {
List<OrderFlatDto> result = orderQueryRepository.findAllByDto_flat();
return new Result<>(result);
}
@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(OrderItemDto::new)
.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();
}
}
@Data
@AllArgsConstructor
static class Result<T> {
T data;
}
}
select
order0_.order_id as col_0_0_,
member1_.name as col_1_0_,
order0_.order_date as col_2_0_,
order0_.status as col_3_0_,
delivery2_.city as col_4_0_,
delivery2_.street as col_4_1_,
delivery2_.zipcode as col_4_2_,
item4_.name as col_5_0_,
orderitems3_.order_price as col_6_0_,
orderitems3_.count as col_7_0_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
inner join
order_item orderitems3_
on order0_.order_id=orderitems3_.order_id
inner join
item item4_
on orderitems3_.item_id=item4_.item_id
- order와 orderItem을 조인, orderItem과 Item 조인해서 연관관계를 모두 조인해서 db를 한 줄로 만든 후에 한꺼번에 가져온다.
- 다 한꺼번에 가져오는 방식이라 OrderItem 컬렉션을 조회하면서 데이터가 뻥튀기 됐다.
결론
- V5보다 더 최적화된 버전이다.
- 장점
- 쿼리가 1번만 실행된다.
- 단점
- 페이징을 못한다. (Order 기준으로 페이징을 못한다. OrderItem 기준은 가능)
- 애플리케이션에서 추가 작업이 크다.
- 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라서 V5보다 더 느릴 수 있다.
- V5가 느린 경우는 데이터양이 엄청 많은 경우다.
API 개발 고급 정리
1. 엔티티 조회
- 엔티티를 조회해서 그대로 반환 : V1
- 엔티티 조회 후 DTO로 변환 : V2
- 페치 조인으로 쿼리 수 최적화 : V3
- 컬렉션 페이징과 한계 돌파 : V3.1
- 컬렉션은 페치 조인시 페이징이 불가능하다.
- ToOne관계는 페치 조인으로 쿼리 수를 최적화시키고 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고 hibernate.default_batch_fetch_size 또는 @BatchSize 로 최적화를 시킨다.
2. DTO로 직접 조회
- JPA에서 DTO를 직접 조회 : V4
- 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화 : V5
- 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환 : V6
🔍 DTO 조회 방식의 선택지
DTO로 조회하는 방법도 각각 장단이 있다. V4, V5, V6 에서 단순히 쿼리가 1번 실행된다고 V6가 항상 좋은 방법은 아니다.
V4는 코드가 단순하다. 특정 주문 한건만 조회하면 이 방식을 사용해도 성능이 잘 나온다. 예를 들어서 조회한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다. 즉, 단건 조회는 V4가 괜찮다.
V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 보다 최적화된 V5 방식을 사용해야 한다. 예를 들어서 조회한 Order 데이터가 1000건인데, V4를 방식을 사용한다면 쿼리가 총 1 + 1000번 실행된다. 여기서 1은 Order를 조회한 쿼리고, 1000은 조회된 Order의 row수다. V5 방식으로 최적화하면 쿼리가 총 1 + 1 번만 실행된다.
V6는 완전히 다른 접근 방식이다. 쿼리 한 번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페이징이 불가능하다. 실무에서는 이정도 데이터면 수백이나 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다. 그리고 데이터가 많으면 중복 전송이 증가해서 V5와 비교해도 성능 차이도 미비하다.
3. 권장순서
1. 엔티티 조회 방식으로 우선 접근
① 페치 조인으로 쿼리 수 최적화
② 컬렉션이 있을 경우 컬렉션을 최적화 시킨다.
- 페이징이 필요할 때 : hibernate.default_batch_fetch_size 또는 @BatchSize로 최적화
- 페이징이 필요 없을 때 : 페치 조인 사용
2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식을 사용한다.
3. DTO 조회 방식으로 해결이 안되면 NativeSQL 또는 스프링 JdbcTemplate로 해결한다.
🧷 참고1
엔티티 조회 방식은 페치 조인이나 hibernate.default_batch_fetch_szie 또는 @BatchSIze 같이 코드를 거의 수정하지 않고, 옵션만 약가 변경해서 다양한 성능 최적화를 시도할 수 있다. 반면에 DTO를 직접 조회하는 방식은 성능을 최적화 하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.
🧷 참고2
개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기 해야한다. 항상 그런 것은 아니지만, 보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고간다.
엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에 단순한 코드를 유지하면서 성능을 최적화 할 수 있다.
반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에 둘 사이에 줄타기를 해야한다.
🧷 참고3
엔티티는 직접 캐싱을 하면 안된다. 엔티티는 영속성 컨텍스트에 관리되기 때문에 꼬일 수 있다. 캐싱을 하려면 엔티티를 DTO로 변환해서 DTO로 캐싱해야한다.
👀 참고 자료
'[JPA] > 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화' 카테고리의 다른 글
[JPA] API 개발 고급 - 실무 필수 최적화 (0) | 2022.04.13 |
---|---|
[JPA] API 개발 고급 - 지연 로딩과 조회 성능 최적화 (x대일 관계) (0) | 2022.04.11 |
[JPA] API 개발 기본 (0) | 2022.04.10 |