[JPA] N+1 문제

N+1 Problem

 

 N+1 문제란, 연관관계가 설정된 엔티티를 조회할 때 연관된 엔티티의 개수(N)만큼 추가적인 쿼리가 발생하는 문제를 말한다. 

 

 Member와 Order, Delivery엔티티가 있다고 하자. Order는 주문정보를 담은 엔티티로, Member(회원)와 다대일 연관관계를 가지며, 배송정보를 담은 엔티티인 Delivery와는 일대일 연관관계를 갖는다.

 

 

@Entity
@Table(name="orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name="order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;
}

 

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name="member_id")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

 

@Entity
@Getter @Setter
public class Delivery {

    @Id @GeneratedValue
    @Column(name="delivery_id")
    private Long id;

    @OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
    private Order order;
}

 

 

@GetMapping("/**")
public List<GetOrderListResponseDto> getOrderList() {
    List<Order> orders = orderRepository.findAll(new OrderSearch());
    ...
}

 

 이러한 상황에서 Order리스트를 조회하는 쿼리를 날린다고 하자. 처음에는 select * from order와 같이 order를 위한 1개의 select문을 날린다. 그런데 지연 로딩을 설정해놓았기 때문에 Order와 연관관계를 가지는 Member와 Delivery 객체가 영속성 콘텍스트 안에 존재하지 않는다. 따라서 select문을 통해서 가져온 리스트의 N개의 원소 각각에 대해서 Member와 Delivery정보를 조회하는 쿼리가 N개씩 추가로 나가게 되는 것이다. (1+N+N)

 

 이러한 현상은 각 Order에 연관관계를 갖는 Member가 1차 캐시(영속성 콘텍스트)에 존재하지 않기 때문에 나타난다. 따라서 만약 N개의 Order들이 모두 동일한 Member로부터 생성되었다면, 첫 로딩을 제외하면 이미 영속성 콘텍스트 내에 Member객체가 존재하므로 Member에 관련해서는 1번의 쿼리만 나가게 된다(1+1+N)

 

 

 

 그렇다면 fetchType을 EAGER로 설정하면 해결되지 않을까? 라는 생각이 자연스럽게 든다.

 

하지만 EAGER로 설정해놓았다 하더라도 Order리스트를 받아오기 전에 연관관계를 가지는 엔티티 정보를 가져오는 것이 아니라, 일단 Order리스트를 받아온 후에 각각의 리스트 원소에 대해서 추가 쿼리가 나가는 방식으로 동작한다. 결국 fetchType이 EAGER와 LAZY라는 사실은 추가쿼리(N)가 나가는 시점에만 영향을 끼치고, 리스트를 받아온 이후에 추가 쿼리가 나가게 되어 N+1문제가 발생하게 된다. 

 

 

 jpql이 나가는것을 보면 Order리스트를 조회하는 시점에 delivery를 조회하는 쿼리 하나와, order, delivery, member 엔티티를 3중조인한 inner join문 하나가 날아간다. 이 쿼리 자체는 EAGER로 해놨으므로 날아가는 것으로 보인다,

 

더보기

그런데 EAGER의 경우 Order를 위한 1개의 select쿼리 뒤에 delivery를 위한 1개의 쿼리와 3중 조인문 2개의 쿼리가 나가고 있는데 (위 사진)

  1. join문이 EAGER처리를 위한 쿼리라면 왜 order 조회쿼리 뒤에 바로 나가지 않고 delivery 조회쿼리가 한번 나간 뒤에 나가는건지
  2. 왜 delivery에 대한 추가 쿼리는 나가는데 member는 조회쿼리가 나가지 않고 join문이 나가는지
  3. EAGER의 경우에도 모든 연관객체가 영속성 콘텍스트 안에 존재하지 않는다면 정확히 1+N개의 쿼리가 나가는지 (=LAZY와 똑같이 1+N개의 쿼리가 나가는지)

이 3개가 이해되지 않는다.

EAGER일때 쿼리를 이해해보려고 노력했는데 "LAZY와는 추가쿼리(N)가 나가는 시점의 차이가 있다" 정도 말고는 이해가 되지 않는다.

 

-> 이거 다른 요인때문에 이렇게 조인문 날아갔을수도 있다. (참고: https://velog.io/@jinyoungchoi95/JPA-%EB%AA%A8%EB%93%A0-N1-%EB%B0%9C%EC%83%9D-%EC%BC%80%EC%9D%B4%EC%8A%A4%EA%B3%BC-%ED%95%B4%EA%B2%B0%EC%B1%85)

 

 

 

이러한 문제들은 fetch join을 이용하여 해결할 수 있다.

 

 

 

 

Fetch Join

 

 JPA 내부적으로 jpql을 사용하여 join을 사용하게 되면 join된 모든 테이블 정보를 가져오는 것이 아니라, 오직 jpql에서 주체가 되는 엔티티(from절에 해당하는 엔티티)만 조회하여 컨텍스트에 저장한다. 

fetch join을 사용하지 않는 경우에, 위와 같은 jqpl문을 돌리면

N+1에서 '1'에 해당하는 쿼리가 위와 같이 나가는 것을 볼 수 있는데, 자세히 보면 Order의 단순 필드들에 대한 조회만 이루어져 영속화된다는 것을 알 수 있다. 즉 Order와 연관관계를 맺은 테이블의 필드는 조회되지 않으며, 따라서 그에 대한 접근을 필요로 할 경우 추가로 쿼리가 나가야 한다.

 

이번에는 fetch join을 사용해보자.

 

그냥 join을 사용했을 때와는 다르게 Order 테이블과 연관관계를 갖는 Member 테이블의 필드들까지 한번에 조회해서 영속화하는 것을 볼 수 있다. 따라서 order 리스트의 element들에 대해서 추가적인 쿼리를 날릴 필요가 없다.

 

 

 

 

그런데..

 

 그렇다면 fetch join만 사용하면 문제가 다 해결된 걸까?

위에서 나타난 사례들은 주체가 되는 엔티티인 Order를 중심으로 보았을 때 @ManyToOne 또는 @OneToOne 관계를 갖는 엔티티들에 대한 예시이다. 따라서 단순하게 fetch join해서 한꺼번에 가져오더라도 결함이 발생하지 않았다.

 

@Entity
@Table(name="orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name="order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;
    
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) //영속성 전이
    private List<OrderItem> orderItems = new ArrayList<>();
}
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="item_id")
    private Item item;

    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="order_id")
    private Order order;
}

 

이번에는 Order가 @OneToMany관계를 가지고 있는 OrderItem이라는 엔티티를 추가했다. 말 그대로 OneToMany이므로 1개의 Order에 여러개의 OrderItem이 연관관계를 가질 수 있으므로 fetchjoin을 하면 문제가 발생한다.

 

public List<Order> findAllWithItem() {
    return em.createQuery(
            "select 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
            ).getResultList();
}

 

 

위와 같이 rdbms상에서 join을 진행하면 order 하나에 여러개의 orderitems가 매핑되므로, 같은 order_id를 갖는 여러개의 행이 나오는 것을 볼 수 있다. db상에서는 컬렉션이 존재할 수 없으니까 당연한 결과이다.

이 결과를 그대로 받아서 뿌리면, 각각의 Order가 매핑된 OrderItem 개수만큼 중복해서 나타나는 현상이 발생하게 된다.

 

이 문제는 distinct 키워드를 사용하여 해결할 수 있다. 

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

db에서 distinct는 완전히 동일한 row에 대해서만 적용되지만, jpql에 distinct를 작성하면 hibernate가 루트 엔티티 id값이 같은 행들은 하나만 나오게 필터링해준다.

 

 

 

 

극한의 최적화

 

 이제 distinct 키워드를 이용한 fetch join을 이용하면 모든 문제가 해결된 것 같아 보인다. 하지만 이렇게 컬렉션을 무작정 fetch join할 경우 페이징이 불가능하다는 문제가 있다. db에서 쿼리를 실행한 이후 나온 결과값을 JPA를 사용하여 필터링을 해주다 보니, db에서는 limit, offset과 같은 페이징을 어떻게 처리해야 할지 알 수 없다. 

 

1. fetchType은 Lazy로 설정한다.
2. *ToOne 관계를 가지는 엔티티들은 기존에 했듯이 fetch join을 사용한다.
3. *ToMany관계를 가지는 엔티티가 존재한다면 hibernate.default_batch_fetch_size를 적용해준다.

 

 핵심은 hibernate.default_batch_fetch_size를 설정해주는건데, 이 옵션을 설정해주면 쿼리가 나갈때 연관관계를 맺는 엔티티들에 대해서 in 쿼리문을 사용하여 한번에 조회해오게 된다. 

 1+N 문제는 루트 엔티티가 컬렉션으로 반환될 때, 연관관계를 가지는 엔티티들에 대해서 매번 추가쿼리가 나감으로서 발생했다. 하지만 default_batch_fetch_size를 설정해주면 루트 엔티티 조회시 연관관계를 갖는 엔티티들에 대해 한번에 size만큼의 조회쿼리를 날림으로서 한번에 많은 데이터를 조회해오게 해서 N개의 추가쿼리가 나가던 현상을 N/size개의 추가쿼리가 나가도록 해준다.

 

in 쿼리를 사용하여 한꺼번에 조회해오게 한다.

이 방법은 distinct를 사용한 fetch join과 다르게 db로 날리는 쿼리를 조작하는 방식이므로 페이징 또한 가능하다

 

 

정리

 

 JPA를 사용했을 때 발생하는 성능 문제의 반 이상이 N+1문제라고 한다. fetch join을 적극적으로 사용하는 습관을 기르자. 물론 그것보다 중요한 것은 JPARepository 인터페이스를 이용하거나, Querydsl을 사용할 때 내부적으로 어떤 형태의 쿼리가 나갈지를 예측할 수 있어야겠지