
JPA를 사용할 때 가장 주의해야하는 성능적 문제는 N+1 문제입니다.
N+1 문제는 하나의 쿼리로 부모 엔티티를 조회할 때 각 부모 엔티티의 연관된 자식 엔티티까지 N번의 쿼리로 조회하여 총 N+1번의 쿼리가 실행되는 성능 저하 문제입니다.
다음과 같이 일대다 관계의 회원-주문 엔티티가 있으며 페치 전략을 즉시 로딩으로 설정했습니다.
@Entity
public class Member {
...
@OneToMany(fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<>();
}
@Entity
public class Order {
...
@ManyToOne
private Member member;
}
위 경우 회원 엔티티를 하나 조회하면 주문들도 즉시 로딩으로 함께 조회됩니다.
그러나 여러 혹은 모든 회원을 조회하면 다음과 같이 각 회원에 대해 Order를 조회하는 쿼리가 발생하게 됩니다.
em.createQuery("SELECT m FROM Member m", Member.class).getResult();
SELECT * FROM Members_table //부모 엔티티를 전체 조회하면
//각 회원과 연관된 주문 수 만큼 Order 조회 쿼리가 N번 발생
SELECT * FROM orders_table WHERE member_id = 1
SELECT * FROM orders_table WHERE member_id = 2
(...)
심지어 지금과 같은 컬렉션을 즉시 로딩하는 방법은 권장되지 않습니다. 컬렉션을 즉시 로딩하는 경우 예외가 발생할 확률이 매우 높습니다.
이번엔 지연 로딩으로 설정해보도록 하겠습니다.
@Entity
public class Member {
...
@OneToMany(fetch = FetchType.Lazy)
private List<Order> orders = new ArrayList<>();
}
@Entity
public class Order {
...
@ManyToOne
private Member member;
}
지연 로딩은 필요한 시점에 로딩하기 때문에 위의 즉시 로딩 예제처럼 회원을 조회하는 경우에는 N+1이 발생하지 않습니다.
그러나 주문을 실제로 사용하는 시점에 와서 조회 쿼리가 N번 발생하게 됩니다.
//회원과 연관된 주문 컬렉션을 사용할 때 Order 조회 쿼리가 N번 발생
SELECT * FROM orders_table WHERE member_id = 1
SELECT * FROM orders_table WHERE member_id = 2
(...)
결국에 어떤 방식을 쓰던간에 N+1 문제가 발생하게 됩니다. 그래서 JPA에서는 N+1 문제를 해결하기 위해 Fetch Join, @EntityGraph 방식을 제공하고 있습니다.
그렇다고 모든 연관관계에서 N+1 문제가 발생하는 것은 아닙니다. N+1 문제가 발생하는 대표적인 상황들을 정리하면 다음과 같습니다.
지연 로딩된 컬렉션 또는 엔티티 접근즉시 로딩 시 JOIN이 아닌 SELECT로 접근하게 되는 경우N+1 문제를 해결하는 가장 대표적인 방식이 Fetch Join (페치 조인)입니다.
페치 조인은 JOIN을 사용해서 연관된 엔티티를 한 번에 조회하는 방식입니다.
em.createQuery("SELECT DISTINCT m FROM Member m JOIN FETCH m.orders", Member.class)
.getResult();
일대다 관계를 조인하는 경우 중복 데이터 조회가 발생하게 되므로 반드시
DISTINCT키워드를 사용해서 중복 데이터를 제거하는 것이 좋습니다.
페치 조인은 가장 간단하게 N+1 문제를 해결할 수 있는 방법이지만 만약 페이징을 사용하고 있다면 사용에 주의해야합니다.
다음 두 가지 문제로 인해 페치 조인을 페이징에 사용하는 것은 권장되지 않습니다.
그래서 지연 로딩이나 페이징 상황에서는 하이버네이트에서 제공하는 @BatchSize 기능을 사용하여 N+1 문제를 해결하거나 페이징을 시도합니다.
@BatchSize는 하이버네이트 구현체에서만 동작하는 기능입니다.
@BatchSize(size = 10)
@OneToMany(fetch = FetchType.Lazy)
private List<Order> orders = new ArrayList<>();
@BatchSize는 IN 쿼리를 사용해서 하나의 쿼리로 조회를 수행하게 만들어줍니다.
SELECT * FROM orders_table WHERE member_id IN (?, ?, ?, ..., ?)
위와같은 식으로 하나의 SELECT 쿼리를 이용해서 최대 size 개의 데이터를 조회하게 됩니다.
예시는
일대다관계지만일대일, 다대일에서도 사용할 수 있습니다.
만약 하이버네이트 구현체를 사용하지 않는다면
DTO를 만들어서 쿼리 결과를 DTO로 받아내는 방식을 사용합니다.
이 방법은 JPQL의 NEW를 참조해주세요.
@EntityGraph는 Spring Data JPA가 제공하는 N+1 문제 해결 방법입니다.
다음과 같이 Repository 인터페이스의 쿼리 메소드에 @EntityGraph를 붙여서 사용합니다.
@EntityGraph(attributePaths = "orders") //attributePaths에는 엔티티 클래스 필드명
List<Member> findAll();
만약 JPA 구현체로 하이버네이트를 사용한다면 @Fetch를 사용하는 것도 하나의 방법입니다. @Fetch는 데이터 조회 시 부속 질의(서브 쿼리)를 사용하여 N+1 문제를 해결합니다.
@Fetch의 사용법은 다음과 같습니다.
import org.hibernate.annotations.Fetch
@Fetch(FetchMode.SUBSELECT)
@OneToMany(fetch = FetchType.Lazy)
private List<Order> orders = new ArrayList<>();
@Fetch는 즉시 로딩에서는 조회 시점, 지연 로딩에서는 연관된 엔티티(지연된 엔티티)를 사용하는 시점에 부속 질의가 수행되게 됩니다.
N+1 문제를 해결하는 방법을 정리하면 다음과 같습니다.
| 방법 | 상황 |
|---|---|
| Fetch Join | 순수 JPA 사용, 일반적인 일대일/다대일 연관관계 |
| @EntityGraph | Spring Data JPA 사용 |
| 하이버네이트 구현체: @EntityGraph, @BatchSize 그 외: DTO 조회 | 다대일 페이징 |
최종 정리하면 가능한 지연 로딩 방식을 이용하고 페치 조인 or @EntityGraph를 이용해서 N+1 문제를 해결하여 최적화를 하는 것이 좋습니다.
Spring Data JPA라면@EntityGraph를 우선 사용하고 복잡한 조회 쿼리가 있는 부분에선 JPQL에페치 조인을 사용하는 것이 좋습니다.