이번 글에서는 JPA 조회 성능 최적화
를 다뤄본다.
대부분의 성능 저하 문제는 연관관계에 있는 객체를 조회할 때 생기는데, 그 중에서도 지연로딩, XtoOne 관계에서의 조회를 개선해본다.
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
//... 기타 생략
}
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
//... 기타 생략
}
@OneToMany
관계는 Default값이 Lazy Loading
이다.@JsonIgnore
를 사용하면 해당 객체를 JSON으로 변환할 때 무시된다. @GetMapping("/api/v1/simple-orders")
public List<OrderSimpleQueryDto> ordersV2() {
return orderRepository.findAll().stream()
.map(OrderSimpleQueryDto::new)
.collect(Collectors.toList());
}
api 요청이 들어오면, 데이터베이스로부터 단순히 모든 주문 테이블 필드를 조회하고 DTO로 변환하여 반환하는 코드이다.
현재 상황은 유저 2명이 각각 1개의 주문을 한 상태이다. 즉, 결과가 주문 2건이 조회 되어야 한다.
그렇다면 뭐가 문제일까? 다음은 위 방법을 사용했을 때 발생한 쿼리이다.
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 ?
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=?
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=?
Order
조회 쿼리 1번 외에 Member
조회 쿼리가 2번 더 발생했다.
Order
객체에서 Member
와의 관계를 LAZY로 설정했기 때문에 List<Order>
를 List<OrderSimpleQueryDto>
로 변환하는 과정에서 실질적으로 Member
객체가 사용될 때 쿼리가 발생하는 것이다. 이와 같은 문제를 N+1문제라고 하며, 조회 성능에 매우 치명적인 요소이다.
Member
가 두 개의 주문을 전부 했다고 가정하면, Member
조회 쿼리는 한 번만 발생할 것이다. 영속성 컨텍스트에 캐싱되기 때문이다. 모든 관계 설정에 있어서 LAZY를 사용하지 않고 EAGER 즉시 로딩 방식으로 사용해서는 안 되는 이유가 있다.
즉, LAZY 방식을 EAGER 방식으로 바꾸는 것은 해결책이 아님을 알 수 있다.
JPQL을 이용한 Fetch Join 방식으로 위 문제를 해결할 수 있다.
기존의 findAll()
메소드를 다음과 같이 수정하자.
public List<Order> findAll() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m", Order.class)
.getResultList();
}
쿼리 결과는 다음과 같다.
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
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_,
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
JPQL을 사용하면 LAZY, EAGER를 무시하게 되고 연관 관계 객체들이 join되면서 단 한번의 쿼리 만으로 원하는 결과를 조회할 수 있게 된다.
대부분의 성능 문제는 시도 1만 거쳐도 해결이 되지만, 그래도 문제는 존재한다.
fetch join을 통해 발생한 쿼리는 해당 객체의 모든 필드를 조회하게 된다.
시도 2에서 이 문제를 해결해보자.
시도 2는 바로 Domain에서 DTO로 변환하는 과정이 아닌, Direct하게 DTO로 조회하는 JPQL을 작성하는 것이다.
public List<OrderSimpleQueryDto> findAll() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
쿼리 결과는 다음과 같다.
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_,
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
실제 사용하는 필드만을 골라서 쿼리문이 발생한 것을 확인할 수 있다.
다만, 시도 2에는 문제점이 존재한다.
또한, 시도 1에서 시도 2를 수행했을 때의 성능 개선의 정도는 그렇게 눈에 띌 정도는 아니다.
정말 필드 쿼리 개수가 20~30개 이상 차이가 나고 시도 2 까지의 개선이 크게 유의미할 때만 사용하도록 하자.