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(즉시 로딩)인 경우
- JPQL에서 만든 SQL을 통해 데이터를 조회
- JPA에서 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회
- 2번의 과정으로 N+1문제 발생
LAZY(지연 로딩)인 경우
- JPQL에서 만든 SQL을 통해 데이터를 조회
- 지연 로딩이므로 추가 조회는 하지않으나 하위 엔티티를 가지고 작업시 조회가 발생하므로 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
'Backend > JPA' 카테고리의 다른 글
[JPA] Batch Insert란? Hibernate에서 Batch Insert를 사용하는 이유는? (1) | 2024.01.15 |
---|---|
[JPA] EntityManagerFactory VS EntityManager (1) | 2024.01.12 |
[JPA] OneToMany, ManyToOne에서 어떤 Fetch Type을 사용해야 할까? (1) | 2024.01.08 |