[Spring] 지연 로딩과 JPA 성능 최적화

bluewhale·2021년 5월 27일
0

Spring

목록 보기
7/9

이 글은 김영한 님의 실전! 스프링 부트와 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 문제가 발생함
    }
}

 

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 문제에 대한 좋은 해결방안이 될 수 있다.

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

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();
    }

fetch join 을 활용한 쿼리

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

References

JPA N+1 문제 및 해결방안

profile
안녕하세요

0개의 댓글