숑숑이의 개발일기

N+1 문제란?

연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(N) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 된다. 즉, N+1문제는 1번의 쿼리를 날렸을 때 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 것을 말한다.

 

When 언제 발생하는가?

  • JPA Repository를 활용해 인터페이스 메소드를 호출할 때(Read 시)

Who 누가 발생시키는가?

  • 1:N 또는 N:1 관계를 가진 엔티티를 조회할 때 발생

How 어떤 상황에 발생되는가?

  • JPA Fetch 전략이 EAGER 전략으로 데이터를 조회하는 경우
  • JPA Fetch 전략이 LAZY 전략으로 데이터를 가져온 이후 연관 관계인 하위 엔티티를 다시 조회하는 경우

Why 왜 발생하는가?

  • JPA Repository로 find 시 실행하는 첫 쿼리에서 하위 엔티티까지 한 번에 가져오지 않고, 하위 엔티티를 사용할 때 추가로 조회하기 때문에
  • JPQL은 기본적으로 글로벌 Fetch 전략을 무시하고 JPQL만 가지고 SQL을 생성하기 때문에

 

EAGER(즉시 로딩)인 경우

  1. JPQL에서 만든 SQL을 통해 데이터를 조회
  2. JPA에서 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회
  3. 2번의 과정으로 N+1문제 발생

 

LAZY(지연 로딩)인 경우

  1. JPQL에서 만든 SQL을 통해 데이터를 조회
  2. 지연 로딩이므로 추가 조회는 하지않으나 하위 엔티티를 가지고 작업시 조회가 발생하므로 N+1문제 발생

 

해결방안

Fetch join

Fetch join은 JpaRepository에서 제공해주는 것은 아니다. 최적화된 쿼리를 JPQL로 작성해 사용할 수 있다. join문이 inner join으로 실행된다.

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

 

단점

  • 연관관계를 설정해놓은 FetchType을 사용할 수 없다.
  • 하나의 쿼리문으로 가져오므로, ㅋPaging API를 사용할 수 없다.
  • 패치 조인 대상에게 별칭(as) 부여가 불가능 하다.
  • 번거롭게 쿼리문을 작성해야 한다.
  • 1:N 관계 컬렉션이 두 개 이상인 경우 사용 불가

 

EntityGraph

@EntityGraph의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 된다. JPQL을 사용하여 작성 후 필요한 연관관계를 EntityGraph에 설정하면 된다. join문이 outer join으로 실행된다.

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

 

 

Fetch Join과 EntityGraph 사용시 주의할 점

Fetch Join과 EntityGraph는 JPQL을 사용하여 JOIN문을 호출한다는 공통점이 있다. 또, 둘은 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 중복 데이터가 발생할 수 있으므로 중복된 데이터가 존재하지 않도록 주의해야 한다.

 

카테시안 곱(Cartesian Product)란?
두 테이블 사이에 유효 join 조건을 적지 않았을 때 해당 테이블에 대한 모든 데이터를 전부 결합하여 테이블에 존재하는 행 갯수를 곱한만큼의 결과 값이 반환되는 것.

 

이러한 문제를 해결하기 위한 방법은 아래와 같다.

 

DISTINCT를 추가하여 중복 제거

@Query("select DISTINCT o from Owner o join fetch o.pets")
List<Owner> findAllJoinFetch();

 

OneToMany 필드 타입을 Set으로 선언하여 중복 제거

@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
private Set<Pet> pets = new LinkedHashSet<>();

Set은 중복을 허용하지 않는 자료구조이기 때문에 중복등록이 되지 않는다. 만일, 순서가 필요하다면 LinkedHashSet을 사용하면 된다.

 

카테시안 곱 피하기

카테시안 곱이 일어나는 Cross Join은 쿼리의 표현에서 발생하는 문제로, JPA 프레임워크로 부터 특정한 방향(흔히 inner join)으로 최적화된 쿼리를 유도하려면, 프레임워크가 이해할 수 있는 연관관계와 상황을 코드로 적절히 전달하면 된다.

 

이 때 최적화된 쿼리를 유도하는데 도움을 주는 것에는 Fetch Join, FetchType.EAGER, @EntitiyGraph, Querydsl 등이 있다.

 

FetchMode.SUBSELECT

아래의 해결 방법은 한 번의 쿼리가 아닌 두번의 쿼리로 해결하는 방법이다. 해당 엔티티를 조회하는 쿼리는 그대로 발생하고 연관관계의 데이터 조회시 서브쿼리로 함께 조회하는 방법이다. FetchType을 EAGER로 설정해 두어야 한다는 단점이 있다.

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Owner {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;
 
    @Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
    private Set<Cat> cats = new LinkedHashSet<>();
 
}

 

BatchSize

하이버네이트가 제공하는 @BatchSize 어노테이션을 이용하면 연관된 엔티티 조회 시 지정된 size 만큼 JPQL의 IN절을 사용하여 조회한다. 그러나 연관관계 데이터의 최적화 데이터 사이즈를 정확히 알기 쉽지 않다.

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Owner {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;
 
    @BatchSize(size=5)
    @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
    private Set<Cat> cats = new LinkedHashSet<>();
}

 

QueryBuilder 사용

JPA만으로 실제 비즈니스 로직을 구현하기 부족할 수 있다. 이때는 QueryBuilder를 사용하여 최적화된 쿼리를 구현할 수 있다. 대표적으로 Mybatis, QueryDSL, JDBC Template등이 있다.

// QueryDSL로 구현한 예제
return from(owner).leftJoin(owner.cats, cat)
                   .fetchJoin()

 

https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1
https://jojoldu.tistory.com/165
profile

숑숑이의 개발일기

@숑숑-

풀스택 개발자 준비중입니다