N+1

ORM을 사용하는 상황에서 무조건 만나게 되는 문제

Q. N+1 문제가 무엇인가요?

1개의 쿼리를 예상했으나, N개의 쿼리가 더 나가는 N+1번 조회 쿼리가 발생하는 현상을 말합니다. 이러한 상황은 연관관계 매핑을 하게 되는 1:N 또는 N:1 관계에서 주로 발생하게 됩니다. 로딩 시점을 즉시 로딩(EAGER)으로 설정하였을 때, 지연 로딩(LAZY)으로 설정했을 때 모두 발생할 수 있습니다.

Q. 즉시 로딩과 지연 로딩을 했을 때 각각 언제 발생하나요?

즉시 로딩(EAGER)의 경우 데이터를 가져오는 시점에 바로 N+1 문제가 발생하게 됩니다. 지연 로딩(LAZY)의 경우 데이터를 가져온 후 가져온 데이터에서 하위 엔티티를 다시 조회할 때 발생하게 됩니다.

Q. 즉시 로딩과 지연 로딩은 무엇인가요?

즉시로딩은 DB와 Mapping된 Entity를 조회할 때 연관관계에 있는 데이터까지 한 번에 조회해오는 기능을 말합니다. JPA에서는 fetch = FetchType.EAGER 옵션을 통해 설정을 할 수 있습니다.

  • 조인(join)을 이용하여 Mapping된 Entity를 함께 조회합니다.

  • 연관관계에 있는 데이터도 동시에 불러야 할 상황에 지연 로딩은 2번 SELECT 쿼리가 발생하지만, 즉시 로딩은 한 번에 가져온다.

지연로딩 은 Entity 조회 시점이 아닌 연관관계를 참조할 때 연관된 데이터를 조회하는 기능을 말합니다. JPA에서 fetch = FetchType.LAZY 옵션을 통해 설정할 수 있습니다.

  • 연관된 데이터를 프록시로 조회합니다.

  • 조회 대상이 영속성 컨텍스트에 이미 있다면 프록시 객체가 아닌 실제 객체를 사용합니다.

로딩 시점과 상관없이 N+1은 발생할 수 있는 것을 기억하자.

Q. 그렇다면 해결하기 위한 방법은?

Fetch Join

JPQL에서 성능 최적화를 위해 제공하는 기능으로, 연관된 엔티티나 컬렉션을 모두 조회하는 기능을 말합니다. INNER JOIN으로 호출이 되며 연관된 엔티티를 함께 조회(즉시 로딩)을 하게 됩니다.

@Query("select o from Owner o join fetch o.cats")
List<Owner> findAllFetchJoin();

즉시 로딩 vs Fetch Join

  • 즉시 로딩: 연관된 데이터를 즉시 로딩시키며 내부적으로 Join 또는 추가 쿼리(N + 1)을 통해 데이터를 가져오게 됩니다. 불필요한 데이터까지 함께 로딩이 될 수 있어 성능 저하가 발생할 수 있다.

  • Fetch Join: 명시적으로 즉시 로딩을 하기 위한 방법으로 join fetch 를 사용하여 연관된 엔티티를 한 번의 쿼리로 조회하게 됩니다.

권장하는 방법은 지연 로딩을 사용한 후, 필요한 경우에 Fetch Join을 사용하는 것을 권장한다.

물론 Fetch Join에도 문제점이 존재한다.

  • 지연 로딩으로 설정하더라도, 즉시 로딩을 하게 되어 FetchType 무의미해진다.

  • 또한, 하나의 쿼리문으로 가져오다보니 페이징 쿼리를 사용할 수 없다.

Entity Graph

@EntityGraphattributePaths 에 가져올 엔티티를 지정하여 해당 엔티티에 대한 정보만 즉시 로딩이 가능하다. OUTER JOIN으로 호출이 된다.

@EntityGraph(attributePaths = "cats")
@Query("select o from Owner o")
List<Owner> findAllEntityGraph();

주의할 점 ‼️

Fetch Join과 Entity Graph 방식은 조인을 통해 데이터를 가져온다. 이러한 상황에서 중복된 데이터가 발생할 수 있기 때문에 distinct 를 사용하거나, Set 컬렉션을 통해 중복 데이터를 허용하지 않도록 해야한다.

Batch Size

Hibernate가 제공하는 @BatchSize 어노테이션을 이용하여 연관된 엔티티를 조회할 때 지정한 사이즈 만큼만 가져올 수 있다. 이 때는, Query에 IN 절 을 사용해서 조회하게된다.

여기서 size는 in 절에 올 수 있는 최대 인자의 개수를 말한다.

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Owner {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;

    @BatchSize(size = 5) // <--- Batch Size !
    @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
    private Set<Cat> cats = new LinkedHashSet<>();
}

즉시 로딩인 경우

Owner를 조회하는 시점에 Cat도 같이 조회를 한다. 이 때, Cat의 수 만큼 쿼리를 날리는 것이 아닌, 조회한 Owner의 ID를 모아 IN 절을 날리게 된다.

만약, 고양이가 10마리라면 배치 사이즈가 5이기 때문에 2개의 쿼리가 발생할 것이다.

지연 로딩인 경우

지연 로딩된 엔티티 최초 사용 시점에 5건을 미리 로딩해두고, 6번 째 엔티티 사용 시점에 다음 쿼리를 추가로 실행하게 된다.

hibernate.default_batch_fetch_size 속성을 통해 기본 배치 사이즈를 조정할 수 있다.

Q. 내가 해결했던 방법

위의 방법 중 Fetch Join을 통해 해결을 했던 기억이 난다. 상황을 간단하게 되짚어 보자면,

  • 주문(Sale) 엔티티는 책(Book)회원(Member)에 대해 연관관계 매핑이 되어 있었습니다.

  • 요구사항은 특정 회원의 주문 내역을 조회하는 것이었으며, memberId를 기반으로 주문 정보를 가져오고자 했습니다.

문제점:

  • 회원의 주문 내역 조회 시 책 정보는 필요하지 않았습니다.

  • 그러나 책과 회원의 로딩 전략이 EAGER로 설정되어 있어 주문을 조회할 때, 책의 개수만큼 N+1 쿼리가 발생하는 문제가 있었습니다.

해결과정:

  1. 로딩 전략 변경: 책과 회원의 로딩 전략을 LAZY로 수정하여 불필요한 즉시 로딩을 방지했습니다.

  2. Fetch Join 도입: 회원 정보를 가져오기 위해 발생하는 추가 쿼리를 해결하기 위해 Fetch Join을 활용하여 회원 데이터를 한 번의 쿼리로 조회했습니다.

  3. 중복 제거: Fetch Join으로 인해 카타시안 곱이 발생할 가능성을 방지하기 위해 List를 Set으로 변환하여 중복을 제거했습니다.

  4. DTO Projection: 필요한 데이터만 조회하도록 DTO Projection을 사용해 영속성 컨텍스트의 관리 대상이 아닌 DTO 객체로 처리했습니다.


참고한 글

Last updated