
N+1 문제는 한 번의 쿼리로 부모 엔티티 N개를 조회한 후, 각 부모 엔티티마다 연관된 자식 엔티티들을 조회하기 위해 추가로 N개의 쿼리가 실행되는 상황을 말함. 즉, 전체적으로 (1 + N)개의 쿼리가 실행된다.
예를 들어, `Parent`와 `Child` 엔티티가 `OneToMany` 관계에 있으며, `Parent` 엔티티가 `FetchType.LAZY`로 설정되어 있다고 가정
@Entity
public class Parent {
@OneToMany(fetch = FetchType.LAZY, mappedBy = "parent")
private List<Child> children;
}
이제 `Parent` 엔티티 목록을 조회하면, 부모 엔티티를 가져오는 쿼리가 먼저 실행
List<Parent> parents = entityManager.createQuery("SELECT p FROM Parent p", Parent.class).getResultList();
여기까지는 문제가 없음. 하지만, 이후 각 부모 엔티티에 대해 자식 엔티티를 접근하면, 각 접근마다 쿼리가 실행
for (Parent parent : parents) {
List<Child> children = parent.getChildren(); // 각 parent에 대해 추가 쿼리 실행
}
이러한 접근 방식 때문에 다음과 같은 쿼리가 실행
1. 부모 엔티티 목록을 가져오는 1개의 쿼리.
2. 각 부모 엔티티에 대해 자식 엔티티를 가져오는 N개의 쿼리.
즉, 부모 엔티티가 N개라면 전체적으로 1 + N개의 쿼리가 실행되며, 이것이 바로 N+1 문제이다.
`FetchType.LAZY` 뿐만 아니라 `FetchType.EAGER`를 사용해도 N+1 문제가 발생할 수 있다. 이는 로딩 시점의 문제일 뿐, 근본적으로는 연관된 엔티티를 가져오는 방식에서 기인하는 문제다.
즉시 로딩 (FetchType.EAGER)에서의 N+1 문제
`FetchType.EAGER`를 사용하면 연관된 엔티티를 즉시 로드하게 된다. 이는 부모 엔티티를 조회할 때마다 해당 부모 엔티티와 연관된 자식 엔티티도 한 번에 가져온다는 의미다.
예시
예를 들어, `Parent`와 `Child` 엔티티가 `OneToMany` 관계에 있으며, `Parent` 엔티티가 `FetchType.EAGER`로 설정되어 있다고 가정하자.
@Entity
public class Parent {
@OneToMany(fetch = FetchType.EAGER, mappedBy = "parent")
private List<Child> children;
}
이제 `Parent` 엔티티 목록을 조회하면, 부모 엔티티를 가져오는 쿼리와 함께 각 부모 엔티티에 대해 자식 엔티티를 가져오는 쿼리가 실행된다.
List<Parent> parents = entityManager.createQuery("SELECT p FROM Parent p", Parent.class).getResultList();
여기서 발생하는 N+1 문제를 자세히 살펴보면 다음과 같다:
1. 부모 엔티티 목록을 가져오는 1개의 쿼리.
2. 부모 엔티티에 대해 자식 엔티티를 가져오는 N개의 쿼리.
즉, 부모 엔티티가 N개라면 전체적으로 1 + N개의 쿼리가 실행된다.
`FetchType.LAZY`와 `FetchType.EAGER` 모두 N+1 문제가 발생할 수 있지만, 문제의 발생 시점이 다르다:
- 지연 로딩 (FetchType.LAZY): 부모 엔티티를 처음 조회할 때는 자식 엔티티를 로드하지 않다가, 자식 엔티티에 접근할 때마다 쿼리가 실행된다.
- 즉시 로딩 (FetchType.EAGER): 부모 엔티티를 조회할 때 연관된 모든 자식 엔티티를 한 번에 로드하려고 시도하므로, 처음 조회 시점에 N+1 문제가 발생한다.
이 문제를 해결하기 위해 JPA에서는 `fetch join`을 사용할 수 있다. `fetch join`을 사용하면 연관된 엔티티를 한 번의 쿼리로 함께 가져올 수 있다. 예를 들어, 다음과 같은 JPQL을 사용하면 `Parent` 엔티티와 연관된 `Child` 엔티티를 한 번의 쿼리로 조회할 수 있다.
SELECT p FROM Parent p JOIN FETCH p.children
위 쿼리를 실행하면 `Parent` 엔티티와 연관된 `Child` 엔티티를 함께 조회하여 N+1 문제를 해결할 수 있다.
하지만, `fetch join`을 사용할 때도 주의해야 할 점이 있다.
만약 조회해야 할 데이터가 너무 많아지면 한 번에 가져오는 데이터 양이 커져 메모리 문제가 발생할 수 있다.
이를 해결하기 위해 두 가지 방법을 사용할 수 있다.
1. Batch Size 조정
JPA에서는 `@BatchSize` 어노테이션을 사용하여 한 번에 로딩하는 엔티티 수를 조정할 수 있습니다. 예를 들어, 다음과 같이 설정할 수 있다.
@BatchSize(size = 10)
@OneToMany(mappedBy = "parent")
private List<Child> children;
이 설정을 통해 한 번에 로딩하는 `Child` 엔티티의 수를 10개로 제한하여 메모리 사용량을 조절할 수 있다.
2. QueryDSL 사용
`fetch join`을 사용할 때 데이터 양이 많아 메모리 문제가 발생하는 경우, QueryDSL을 사용하여 페이징 쿼리를 작성할 수 있다. QueryDSL은 동적 쿼리를 타입 세이프하게 작성할 수 있도록 도와주는 라이브러리다.
List<Parent> parents = queryFactory.selectFrom(parent)
.leftJoin(parent.children, child).fetchJoin()
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
이 방법을 사용하면 데이터베이스에서 필요한 만큼만 데이터를 가져와 메모리 사용량을 줄일 수 있다.
| JPA 캐시/ 1차 캐시, 2차 캐시 (1) | 2024.06.08 |
|---|---|
| FetchType.LAZY/FetchType.EAGER 에서의 N+1문제? (0) | 2024.06.01 |
| 연관 관계(OneToMany, ManyToOne) (0) | 2024.06.01 |
| JPA에서 Cascade Type을 지정하지 않으면? (0) | 2024.06.01 |
| [JPA/Hibernate] Flush란? (0) | 2024.05.30 |