[JPA] N+1 문제

Bam·2025년 5월 30일
0

Spring

목록 보기
68/73
post-thumbnail

N+1 문제

JPA를 사용할 때 가장 주의해야하는 성능적 문제는 N+1 문제입니다.

N+1 문제는 하나의 쿼리로 부모 엔티티를 조회할 때 각 부모 엔티티의 연관된 자식 엔티티까지 N번의 쿼리로 조회하여 총 N+1번의 쿼리가 실행되는 성능 저하 문제입니다.

즉시 로딩 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
(...)

심지어 지금과 같은 컬렉션을 즉시 로딩하는 방법은 권장되지 않습니다. 컬렉션을 즉시 로딩하는 경우 예외가 발생할 확률이 매우 높습니다.

지연 로딩 N+1

이번엔 지연 로딩으로 설정해보도록 하겠습니다.

@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

N+1 문제를 해결하는 가장 대표적인 방식이 Fetch Join (페치 조인)입니다.

페치 조인JOIN을 사용해서 연관된 엔티티를 한 번에 조회하는 방식입니다.

em.createQuery("SELECT DISTINCT m FROM Member m JOIN FETCH m.orders", Member.class)
	.getResult();

일대다 관계를 조인하는 경우 중복 데이터 조회가 발생하게 되므로 반드시 DISTINCT 키워드를 사용해서 중복 데이터를 제거하는 것이 좋습니다.

페이징과 하이버네이트의 @BatchSize

페치 조인은 가장 간단하게 N+1 문제를 해결할 수 있는 방법이지만 만약 페이징을 사용하고 있다면 사용에 주의해야합니다.

다음 두 가지 문제로 인해 페치 조인을 페이징에 사용하는 것은 권장되지 않습니다.

  • 일대다 관계에서 중복 데이터가 발생
  • 중복 제거 후 결과를 메모리에서 페이징하여 DB에서 LIMIT/OFFSET 적용이 되지 않아 데이터 다량 조회 시 성능 저하

그래서 지연 로딩이나 페이징 상황에서는 하이버네이트에서 제공하는 @BatchSize 기능을 사용하여 N+1 문제를 해결하거나 페이징을 시도합니다.

@BatchSize는 하이버네이트 구현체에서만 동작하는 기능입니다.

@BatchSize(size = 10)
@OneToMany(fetch = FetchType.Lazy)
private List<Order> orders = new ArrayList<>();

@BatchSizeIN 쿼리를 사용해서 하나의 쿼리로 조회를 수행하게 만들어줍니다.

SELECT * FROM orders_table WHERE member_id IN (?, ?, ?, ..., ?)

위와같은 식으로 하나의 SELECT 쿼리를 이용해서 최대 size 개의 데이터를 조회하게 됩니다.

예시는 일대다 관계지만 일대일, 다대일에서도 사용할 수 있습니다.

만약 하이버네이트 구현체를 사용하지 않는다면 DTO를 만들어서 쿼리 결과를 DTO로 받아내는 방식을 사용합니다.
이 방법은 JPQL의 NEW를 참조해주세요.

@EntityGraph

@EntityGraphSpring Data JPA가 제공하는 N+1 문제 해결 방법입니다.

다음과 같이 Repository 인터페이스의 쿼리 메소드에 @EntityGraph를 붙여서 사용합니다.

@EntityGraph(attributePaths = "orders") //attributePaths에는 엔티티 클래스 필드명
List<Member> findAll();

하이버네이트의 @Fetch

만약 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 사용, 일반적인 일대일/다대일 연관관계
@EntityGraphSpring Data JPA 사용
하이버네이트 구현체: @EntityGraph, @BatchSize
그 외: DTO 조회
다대일 페이징

최종 정리하면 가능한 지연 로딩 방식을 이용하고 페치 조인 or @EntityGraph를 이용해서 N+1 문제를 해결하여 최적화를 하는 것이 좋습니다.

Spring Data JPA라면 @EntityGraph를 우선 사용하고 복잡한 조회 쿼리가 있는 부분에선 JPQL에 페치 조인을 사용하는 것이 좋습니다.

0개의 댓글