이전 JPA 포스트에서는 N:1
최적화하는 방법을 알아봤습니다. 결국 fetch join으로 쿼리의 조회 횟수를 줄이고 원하는 컬럼을 출력하는 것인데요. 1:N
의 방식은 같은 부모의 다양한 자식 컬럼이 조인되는 특성상 fetch join과 Lazy loding 조건을 모두 이용하는 방식을 이용할 겁니다.
@GetMapping("api/v1/orders")
public List<Order> 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 all;
}
별 특별한 내용은 없습니다만, 글을 처음 시작하는 부분이니 다시 말씀드리면, findAllByString
을 통해 전체 조회를 시작합니다.
//findAllByString
String jpql = "select o from Order o join o.member m";
그리고 Entity에 필요한 객체는 Lazy Loding으로 선언 되어있으니 한번씩 코드에 선언을 하여 강제적으로 초기화 진행하는 겁니다.
@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue
@Column (name = "order_id")
private Long id;
//요런거 강제호출하여 영속화해야겠죠?
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status;
//이하 생략...
}
단, 이 경우 중요한건 불러드리는 Entity
에서 해당 Order
Entity를 참조하는 양방향 관계인지 확인해야합니다.
OrderItem
과 Delivery
Entity에는 다음과 같이 Order
를 바라보는 변수가 존재합니다. 해당값들은 출력을 막아놔야(@JsonIgnore
) 무한 루프를 돌지 않고 정상적으로 출력하게 합니다.
public class Delivery {
@Id
@GeneratedValue
@Column(name = "delivery_id")
private Long id;
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status;
}
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
@JsonIgnore// Order가 OneToMany로 바라보는 해당 객체는 자료를 호출시 무한 로딩에 대상이되므로 JsonIgnore를 붙힌다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="order_id")
private Order order;
private int orderPrice;
private int count;
//이하 생략...
결과: 결과가 길어서 스샷으로 대체합니다.
잘나오는것 같습니다. orders
가 null
이네요.. 초기화를 진행하지 않은 부분은 null
로 나옵니다. 이 방법은 직접 초기화해야하고, Entity를 직접 노출하므로 안 좋은것 같습니다.
이 역시, 저의 JPA 포스트에서 많이 언급한 이야기, 다시 말씀드리면 위에처럼 Entity를 직접 노출하여 API 스팩으로 사용하는것은 유지보수하기 힘듭니다.
또한 전체코드가 다 출력이 되므로 필요한 내용만 담을 수 있게 DTO를 따로 만드시는 것을 진행해보겠습니다.
@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;
}
@Getter // No serializer found error 뜨면 Getter가 없어서 그런것.
static class OrderDto{
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
//private List<OrderItem> orderItems; // DTO 수준에서 api를 설계하는 것이므로 Entity가 아닌 DTO로 따로 빼야한다.
private List<OrderItemDto> orderItems;
public OrderDto(Order o){
orderId = o.getId();
name = o.getMember().getName();
orderDate = o.getOrderDate();
orderStatus = o.getStatus();
address = o.getDelivery().getAddress();
//orderItems = o.getOrderItems();
//orderItems.stream().forEach(oi -> oi.getItem().getName());
orderItems = o.getOrderItems().stream()
.map(oi -> new OrderItemDto(oi))
.collect(Collectors.toList());
}
}
@Getter
static class OrderItemDto{
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem oi) {
itemName = oi.getItem().getName();
orderPrice = oi.getOrderPrice();
count = oi.getCount();
}
}
결과: 원하는것만 잘 담겨진것을 확인 가능합니다.
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2023-08-07T10:03:54.400372",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "11111"
},
"orderItems": [
{
"itemName": "JPA BOOK1",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA BOOK2",
"orderPrice": 10000,
"count": 2
}
]
},
//이하생략
위에 코드를 실행하면 SQL이 대량으로 호출되는것을 확인 가능합니다. 당연한 소리입니다. Lazy Loding이니깐요. 그럼 이전 포스트에서 해결한 방식처럼 fetch join으로 N+1
의 문제를 해결해봅시다.
@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();
}
자 여기서 신기한 점은 distinct
입니다. DB에서 사용하는 중복을 걸려주는 distinct
랑은 약간 다른데요.
sql을 join하는 시점에서 Order(2개)기준으로 하위Entity(2개)를 join한다하면 다음처럼 조회가 됩니다.
하지만 이를 Entity로 옮기게 되면 결국 Order가 같은게 2개씩 온다는 것을 알 수 있습니다. (PK : 4 두개, 11 두개)
전체로 조회했을때는 컬럼이 달라보이지만, Order기준(Entity)으로 봤을때는 하나의 하위 Entity(물건, 주소)를 바라보니 결과는 동일하게 두번, 두번 나오게 됩니다.
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2023-08-07T10:31:25.194908",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "11111"
},
"orderItems": [
{
"itemName": "JPA BOOK1",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA BOOK2",
"orderPrice": 10000,
"count": 2
}
]
},
{
"orderId": 4,
"name": "userA",
"orderDate": "2023-08-07T10:31:25.194908",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "11111"
},
"orderItems": [
{
"itemName": "JPA BOOK1",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA BOOK2",
"orderPrice": 10000,
"count": 2
}
]
},
//11도 동일하게 두번 나옵니다.
]
그래서 distinct
를 하여 결과가 동일하면 제거해주는 작업을 JPA내부에서 처리해주는 겁니다. (결과는 따로 출력 안하겠습니다.)
만약 자료가 많을시를 대비해서 paging처리를 해야합니다. 하지만 위에 distinct
의 경우에는 paging 처리가 다소 힘듭니다. 보통 SQL에서 paging이라고 하면 limit
, offset
이나 rownum
을 사용하게 되는데, 위에 결과처럼 4개가 나와버리면 처리가 곤란하기 때문입니다.
물론 결과가 나오기는 합니다. hibernate
가 자체적으로 paging처리를 위해 memory에 올려 걸러줍니다만, 데이터가 많아지면 서버를 터뜨릴 수 있는 원흉이 됩니다.
결론은 N:1 fetch join, Lazy Loding을 이용하는 것입니다.
@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)) // DTO를 생성하면서 lazy loding 초기화를 한다.
.collect(Collectors.toList());
return collect;
}
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();
}
Member
와 Delivery
는 Order
기준 하나의 Entity를 가지고 있습니다. 이렇게 N:1 맵핑이 되는 Entity는 fetch join을하면 SQL에 자료 중복문제를 해결되고, 이 데이터를 가지고 paging을 진행하는 겁니다. 리스트 값으로 불러와야 되는부분(OrderItems
)은 lazy loding을 진행하면 깔끔하게 데이터 처리가 됩니다.
결과 : json값은 동일하니 빼겠습니다.
다음과 같이 총 7번의 쿼리가 날라가게 됩니다.
앞서 총 7번의 쿼리가 요청된것을 확인했습니다. 하지만 보통 첫번째 쿼리가 전송되었을때 서브 쿼리에 돌려야할 PK값을 다 알고 있는 상황입니다. 굳이 여러번 호출할 필요가 있을까요? WHERE IN 절을 사용하면 해결될거라는 주니어 개발자라도 많이들 알고 계실겁니다. 이때 사용하는 것이 @BatchSize
입니다.
application.yml(.properties)
을 통해서 global 설정이 가능합니다.
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
#show_sql: true
format_sql: true
default_batch_fetch_size: 100
당연히 Spring 어노테이션으로 활용가능합니다.
@BatchSize(size = 100) // application.yml global value 말고 따로 설정하고 싶다면 여기다가. (ToMany일때만,)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
값의 기준은 where 절에 몇개를 붙이겠냐인데, 원하시는 값을 입력하시면 됩니다. 보통 500~1000주시면 될거같습니다. 초과하는 호출은 다시한번 호출합니다. 멈추지는 않습니다.
결과 : 쿼리 실행을 3번으로 끝냅니다.
말씀드렸다시피 Entity에 직접 조회하는건 안좋은 방법입니다. 따로 DTO로 빼서 사용하는 방법을 아래예제에서 작성하겠습니다. (설명은 결국 위에서 해온던것이므로 제외)
// DTO로 변환하는 3가지방법을 제시했습니다.
@GetMapping("api/v4/orders")
public List<OrderQueryDto> ordersV4(){
return orderQueryRepository.findOrderQueryDtos();
}
@GetMapping("api/v5/orders")
public List<OrderQueryDto> ordersV5(){
return orderQueryRepository.findAllByDtoOptimizing();
}
@GetMapping("api/v6/orders")
public List<OrderQueryDto> ordersV6(){
List<OrderFlatDto> flats = orderQueryRepository.findAllByDtoFlat();
return flats.stream() //객체를 groupingby를 하므로 DTO에 EqualsAndHashCode를 선언해준다
.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());
}
Repository입니다.
/**
* findOrderQueryDtos : 핵심은 NToOne을 먼저 조회해서 컬럼의 조회를 최소화시키고 NToMany를 나중에 join하는 방식이다.
* findAllByDtoOptimizing : where in 절을 이용해서 한번에 조회하는 방법을 이용한다.
* findAllByDtoFlat : 한번에 조회는 가능하다. join 해서 가져오며, 다수에 맞춰서 작동하므로 orderRepository의 findAllWithItem와 같은 이유로 페이징 안됨.
**/
public 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();
}
public List<OrderQueryDto> findAllByDtoOptimizing() {
List<OrderQueryDto> result = findOrders();
List<Long> orderIds = result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
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>> orderItemsMap = orderItems.stream()
.collect(Collectors.groupingBy(dto -> dto.getOrderId()));
result.forEach(o-> o.setOrderItems(orderItemsMap.get(o.getOrderId())));
return result;
}
public List<OrderFlatDto> findAllByDtoFlat() {
return em.createQuery(
"select distinct 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();
}
따로 사용할 DTO class를 작성합니다.
OrderQueryDto
@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;
}
}
OrderItemQueryDto.class
@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;
}
}
OrderFlatDto.class
@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;
}
}
여기까지 일대다 JPA를 최적화 하는 방법을 알아봤습니다. 아직 제대로된 프로젝트를 만들어본적이 없어서 복잡하게 느껴지기는 하나 그럼에도 원활하게 사용한다면, sql문을 짜는 것보다 엄청나게 수고를 덜을 수 있을거란 생각이 많이 들게합니다. JPA마스터가 멀지 않았네요. 이후 강의는 Spring JPA, Queryedsl을 알아보도록 하겠습니다.