[JPA] Spring Boot JPA 조회 성능 최적화 (1)

호성·2022년 1월 13일
1
post-thumbnail

이번 글에서는 JPA 조회 성능 최적화를 다뤄본다.

대부분의 성능 저하 문제는 연관관계에 있는 객체를 조회할 때 생기는데, 그 중에서도 지연로딩, XtoOne 관계에서의 조회를 개선해본다.

Entity 상황

  • 주문
@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<>();

//... 기타 생략
}

  • 참고:
  1. @OneToMany관계는 Default값이 Lazy Loading이다.
  2. @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문제라고 하며, 조회 성능에 매우 치명적인 요소이다.

  • 참고:
  1. 같은 Member가 두 개의 주문을 전부 했다고 가정하면, Member 조회 쿼리는 한 번만 발생할 것이다. 영속성 컨텍스트에 캐싱되기 때문이다. 그럼 이건 1+1문제?

LAZY를 안 쓰면 되지 않을까?

모든 관계 설정에 있어서 LAZY를 사용하지 않고 EAGER 즉시 로딩 방식으로 사용해서는 안 되는 이유가 있다.

1. 실무에서 같은 Entity를 사용하더라도 API 규격은 다양할 수 있다. (상황에 따라 여러 개의 DTO가 존재한다.)

  • 상황에 따라 사용하는 Entity의 필드가 다양한데, 항상 사용하는 것이 아닌 연관관계 필드를 매번 join하여 조회할 순 없다.

2. 쿼리를 예측하기 어렵다.

  • 관계가 양방향이냐, 단방향이냐에 따라 조회 쿼리가 달라질 수 있다.

즉, LAZY 방식을 EAGER 방식으로 바꾸는 것은 해결책이 아님을 알 수 있다.

시도 1: Fetch Join

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: DTO로 조회

시도 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. API 규격이 Repository 계층까지 침범해버렸다.

  • Layered Architecture의 Repository 계층이 Controller에서 수행되어야 할 작업에 대해 아는 것이 올바르지는 않다.

2. 변경에 유연하지 않다.

  • API가 변경되어 새로운 반환 필드가 추가될 때, Repository의 쿼리를 수정해야 한다. 객체 지향 설계를 침범한다.

또한, 시도 1에서 시도 2를 수행했을 때의 성능 개선의 정도는 그렇게 눈에 띌 정도는 아니다.

정말 필드 쿼리 개수가 20~30개 이상 차이가 나고 시도 2 까지의 개선이 크게 유의미할 때만 사용하도록 하자.

profile
스프링 깎는 노인

0개의 댓글