이번엔 컬렉션 조회 즉, 일대다 ManyToOne의 성능 최적화에 대해서 알아보자. 일대다로 조회하면 하나의 엔티티를 가져올 때 그 안에 여러개의 리스트가 프로퍼티로 있게 된다. 이건 Java의 입장이다.
SQL의 입장으로 보면 하나의 엔티티 조회지만 여러개의 리스트를 가져오게 되는 것이므로 여러 개의 로우가 결과가 된다. 즉 한개 엔티티만 조회했는데 결과는 여러개의 로우인 것이다. 이 부분이 이전 글에서 설명했던 객체지향 언어와 쿼리언어의 패러다임 차이에 의해 발생하는 모순? 이다.
먼저 사용할 엔티티 객체를 살펴보자
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
}
먼저 주문, Order가 있고 이 주문에 어떤 상품들이 있는지에 대한 OrderItem이 있다.
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "item_id")
private Item item;
@JsonIgnore
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "order_id")
private Order order;
private int orderPrice; //주문 가격
private int count; //주문 수량
}
OrderItem은 이런식이다. 한 주문에 여러 종류의 상품이 들어가고 또 OrderItem은 이 주문 상품의 개수와 가격 그리고 어떤 상품인지에 대한 정보가 있다.
@Entity
@Getter @Setter
public class Item {
@Id
@GeneratedValue
@Column(name = "item_id")
private Long id;
private String name;
}
상품은 대략 이런식이다. 실제 강의 코드와 조금 다른데, 이 글에서 편한 설명과 빠른 이해를 위해 필요한 부분만 작성하였다.
이제 다시 Order를 가져오는 코드를 작성해보자.
List<Order> orders = orderRepository.findAll();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
이렇게 Order를 리포지토리로부터 가져오자. 그리고 API에 노출하기 위해 DTO에 매핑을 해준다.
@Data
static class OrderDto {
private Long orderId;
private String name;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(toList());
}
}
ㅇ
DTO는 대략 이런 방식이다. Order를 받아서 타입을 변환해주고, Order 내부에 있던 OrderItem도 OrderItemDto로 변환을 해준다. 이 때 OrderItemDto로의 변환을 위해 OrderItem에 접근하면 Lazy 로딩이 발생하여 쿼리가 나간다.
@Data
static class OrderItemDto {
private String itemName;//상품 명
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
}
}
OrderItemDto에서도 Item 엔티티에 접근하게 되므로 여기서도 Lazy 로딩이 나간다.
엔티티와 외부로 노출되는 API의 연관성을 끊어내기 위해 DTO로 매핑하고 매핑하는 과정을 루프에 넣고, 루프내에서 엔티티에 계속 접근하게 되면 접근할 때 마다 쿼리가 나간다. 결국
select
order0_.order_id as order_id1_6_,
order0_.delivery_id as delivery4_6_,
order0_.member_id as member_i5_6_,
from
orders order0_
select
member0_.member_id as member_i1_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id in (
?, ?
)
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_,
from
item item0_
where
item0_.item_id in (
?, ?, ?, ?
)
뭐 대략 한개의 주문 정보를 가져오는데, 총 4번의 쿼리가 나간다. 데이터베이스와 Spring Boot가 커넥션 풀을 유지해서 효율적으로 쿼리를 날리려고 하겠지만 한 커넥션 풀 내에서 한 번의 요청에 4번이나 데이터가 네트워크를 왔다갔다 하는 것은 좋지 않다.
이전 글에서도 사용했던 Fetch Join을 사용하면 쉽게 최적화가 가능하다.
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member m" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
이런식으로 Order 내의 OrderItem, 그리고 OrderItem 내의 Item까지 Fetch를 해준다. 이렇게 하면 JPA가 inner join으로 처리해서 한 번의 쿼리로 데이터를 가져올 수 있게 해준다.
distinct를 붙인 이유는 가장 처음에 말했던 SQL과 객체지향언어의 차이인데, 위 코드로 생성된 SQL을 실제로 MySQL에서 실행해보면 OrderItem의 개수만큼 Order를 만들어주기 때문이다.
JPQL에서의 distinct는 사실 컬럼의 모든 값을 비교해서 중복되는 것을 줄여주지만 JPQL에서의 distinct는 조금 다르다.
distinct 다음에 Order를 aliasing하는 Order o 구문이 있는데, JPA 답게 id가 같다면 같은 객체 레퍼런스로 인지하고 중복을 줄여준다.
이렇게 하면 모든 문제가 해결될 것 같았지만, 단점이 있다고 한다. 컬렉션 페치 조인(일대다 페치 조인)에서는 페이징이 불가능하다.
코드를 아래와 같이 수정해보자.
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)
.setFirstResult(1)
.setMaxResults(100)
.getResultList();
}
1부터 100까지면 두 번째 결과부터 100번째까지의 결과를 가져온다. 실제로 실행된 쿼리를 보면
select
distinct order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_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
inner join
order_item orderitems3_
on order0_.order_id=orderitems3_.order_id
inner join
item item4_
on orderitems3_.item_id=item4_.item_id
대강 이러하다. 살펴보면 일단 페이징을 담당하는 limit, offset 쿼리가 없다. 그리고 콘솔에
WARN 5152 --- [nio-8080-exec-1] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
이러한 워닝 로그가 뜬다. 컬렉션 페치에서는 메모리 단에서 페이징을 처리한다는 뜻이다. 페이징은 데이터 중에 몇개만 가져오는 것으로 최적화를 하는것인데 컬렉션에서 바로 페이징을 처리하면 먼저 모든 데이터를 메모리에 로드한 이후에 거기서 페이징을 처리한다는 뜻이다. 그건 개발자가 원했던 페이징 최적화가 아니다.
이유는 역시 패러다임의 문제다. 하나의 엔티티를 가져오고 싶지만 여러개의 로우를 결과로 뱉기 때문에 페이징을 어떻게 처리할지 모르기 때문이다.
따라서 컬렉션 페치 조인할 때는 기본 페이징 메소드를 사용해선 안되고, 또 여러개의 일대다 프로퍼티를 페치조인을 하면 안된다. 이 역시 패러다임을 망칠 수 있다.
가장 간단하게 해결할 수 있는 방법이 batchSize를 등록하는 것이다. 스프링의 설정파일인 application.properteis나 application.yml에 아래와 같은 옵션을 추가하자
jpa:
properties:
default_batch_fetch_size: 1000
이렇게 추가하고 쿼리 코드를 아래와 같이 수정한다.
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 자체를 하지 않는 것이다. 그러면 위에서 생긴 에러가 사라진다.
하지만 Order 안의 OrderItem들과 Item도 필요하게 되는데 그 부분은
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
위에서 사용했듯이 DTO로 매핑해서 Lazy 로딩을 해준다. 이렇게 하고 코드를 실행하면
...fetch join -> inner join을 활용한 order 쿼리...
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_.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_,
from
item item0_
where
item0_.item_id in (
?, ?, ?, ?
)
대략 아래와 같은 쿼리가 추가되는데, default_batch_fetch_size
이 설정이 order_id, item_id와 연관된 In 쿼리를 실행해준다. 설정 값만큼 In 쿼리 내에 order_item 개수를 지정할 수 있게 된다.
이렇게 하면 OrderItem에 대한 N+1 문제와 일대다 페치 조인에 의한 메모리 부하 문제를 동시에 해결할 수 있다.
일대다 페치 조인을 페이징 없이 사용한다면, 굳이 batchSize를 등록할 필요가 없고 in 쿼리가 여러번 생성되는 일이 없지만 SQL의 패러다임 때문에 OrderItem에 맞춰진 로우의 개수가 반환이 된다.
즉 중복 쿼리가 발생하게 되는 것이다. 이 두 개는 각각의 장단점이 있지만 만약 데이터의 개수가 많아진다면 일대다 페치 조인의 중복 쿼리가 늘어나게 되기 때문에 batchSize를 적절히 조절해서 정확히 필요한 쿼리만 작성하는 게 좋을 수도 있다. 또 in 쿼리를 id 즉 Pk 기준으로 필요한 데이터만 가져오기 때문에 batchSize를 조정하는게 좋을 수도 있다.
날아가는 쿼리의 개수를 줄이는 방법에는 Map을 이용한 방법도 있다.
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", OrderQueryDto.class)
.getResultList();
}
위 방법을 통해서 하나의 쿼리로 Order들을 가져온다. 이제 Order 안의 OrderItem들을 가져와야 한다.
public List<OrderQueryDto> findAllByDto() {
// OrderItem들을 제외한 Order만 가져온다. - 쿼리 1
List<OrderQueryDto> result = findOrders();
// 가져온 Order들로부터 Id만 뽑아낸다.
List<Long> orderIds = result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
// 뽑아낸 Id를 in절을 사용하여 OrderItem들을 가져온다. - 쿼리2
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();
// groupingBy를 이용하여 <Id, List<OrderItemDto>>의 형태로 변환한다.
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
// 최초에 OrderItem 없이 가져온 Order들안에 OrderItem들을 넣어준다.
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
위 방식을 사용하면 총 2개의 쿼리를 날리고 OrderItem들을 가져온 다음에는 메모리에서 Map을 통해 Order 내에 OrderItem들을 세팅해줄 수 있다.