이 글은 김영한 님의 실전! 스프링 부트와 JPA 활용-1 강의를 참고하여 작성한 글입니다.
이번 글에서는 @ManyToOne
, @OneToOne
형태의 연관관계에서 쿼리를 최적화하는 방법에 대해서 다루었습니다.
JPA에서는 연관된 엔티티를 조회할 때, 2가지 옵션(Eager
, Lazy
)을 선택할 수 있다. Eager
옵션은 해당 엔티티를 조회할 때, 연관된 모든 엔티티를 함께 조회한다. 반면, Lazy
옵션은 해당 엔티티에서 실제로 연관된 객체를 호출하기 전까지 쿼리를 실행하지 않는다. 대부분의 로딩 방식에 따른 성능 이슈는 대부분 Eager
옵션을 사용하여 발생하게 된다.
아래는 주문정보를 담고 있는 Order
엔티티에 대한 예시 코드이다.
@Entity
@Getter
public class Order {
@Id
@GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "member_id")
private Member member; // JoinColumn 방식의 일대다 양방향 매칭
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
}
Order
엔티티는 고객정보를 담고 있는 Member
엔티티와 배송 정보를 담고 있는 Delivery
엔티티를 갖고 있다. Member
엔티티는 패치 옵션이 EAGER
이기 때문에Order
엔티티를 조회할 때, 항상 Member
에 대한 추가적인 쿼리가 발생한다. 반면, Delivery
는 실제로 해당 엔티티에 접근하기 전까지(ex, getDelivery()
) 실제 쿼리는 발생하지 않는다.
컨트롤러 혹은 서비스에서 하나의 엔티티와 연관된 모든 엔티티를 동시에 사용하는 경우는 드물다. 그러므로, Lazy
옵션을 사용하는 것이 대부분의 경우 ORM 성능 개선에 도움이 된다.
Lazy
옵션은 ORM 성능 개선에 도움을 주지만, 실제 사용하는 과정에서 여러 문제가 발생할 수 있다. 대표적으로 엔티티 객체를 Json으로 변환하는 과정에서 발생하는 양방향 참조로 인한 무한 루프
와 jackson 프록시 객체 인식 오류
등이 있다.
위 문제는 컨트롤러 단에서 엔티티 객체를 json 포맷으로 직렬화하는 과정에서 발생한다. 따라서, 엔티티 객체를 DTO
로 변환하여 반환하면 이러한 문제를 우아하게 해결할 수 있다.
@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
static class OrderQueryDto {
private Long orderId;
private String memberName;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
static public OrderQueryDto fromOrder(Order order) {
OrderQueryDto orderQueryDto = new OrderQueryDto();
orderQueryDto.setOrderId(order.getId()); // LAZY 초기
orderQueryDto.setMemberName(order.getMember().getName());
orderQueryDto.setOrderDate(order.getOrderDate());
orderQueryDto.setOrderStatus(order.getStatus());
orderQueryDto.setAddress(order.getDelivery().getAddress()); // LAZY 초기화
return orderQueryDto; // N + 1 문제가 발생함
}
}
앞서 지연로딩과 DTO
객체를 사용하여, JPA 쿼리 성능을 개선할 수 있었다. 그러나, 위 예제에서는 로딩 방식과 관계없이 N+1 문제
가 발생한다. N+1 문제
는 1
개의 엔티티를 조회하는 과정에서 연관된 N
개의 엔티티를 함께 조회하여 총 N+1
번의 쿼리가 발생하는 것을 의미한다. 실제로는 영속성 컨텍스트의 First Cache
에 저장된 객체를 사용하여 쿼리 횟수가 줄어들 수는 있지만, 대부분의 경우 N + 1
문제는 쿼리 성능을 악화시키는 주범이 된다.
이러한 경우, Fetch Join
을 활용하여 N+1 문제
를 해결하고 쿼리 성능을 개선할 수 있다. 이 밖에도 EntityGraph
를 활용한 outer join도 N+1 문제
에 대한 좋은 해결방안이 될 수 있다.
# Order 엔티티 쿼리
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 ?
# Member 엔티티 쿼리
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=?
# Delivery 엔티티 쿼리
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=?
fetch join
은 JPA에서 연관된 엔티티를 조회할 때, 서로 다른 테이블에 대한 쿼리를 테이블 join
을 통해 하나의 쿼리로 만들어주는 기능이다. 위 예시에선 OrderDto
객체를 생성하기 위해 3번의 쿼리가 발생하였는데, fetch join
을 사용하면 한번으로 쿼리 수를 줄일 수 있다.
// OrderRepository.java
...
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d",
Order.class
).getResultList();
}
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