N+1 문제란?
N+1 문제는 하나의 엔티티를 조회한 후, 연관된 N개의 엔티티를 각각 별도의 쿼리로 조회할 때 발생하는 성능 문제입니다. 예를 들어, 한 번의 쿼리로 부모 엔티티를 조회한 다음, 각 부모 엔티티에 대해 자식 엔티티를 개별 쿼리로 조회하는 경우를 생각해 보세요.
예시:
예를 들어, Parent와 Child라는 두 개의 엔티티가 있고, Parent 엔티티는 여러 Child 엔티티를 가질 수 있다고 가정합니다.
@Entity
public class Parent {
@Id
private Long id;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private List<Child> children;
}
@Entity
public class Child {
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
}
다음과 같은 코드로 모든 부모와 그들의 자식을 로드하려고 한다고 가정해봅시다:
List<Parent> parents = entityManager.createQuery("SELECT p FROM Parent p", Parent.class).getResultList();
for (Parent parent : parents) {
List<Child> children = parent.getChildren(); // 이 시점에서 Lazy Loading에 의해 데이터베이스 쿼리가 실행됨
}
문제 발생:
1. 첫 번째 쿼리: 모든 Parent 엔티티를 로드하기 위해 실행됩니다.
SELECT * FROM Parent;
2. N개의 추가 쿼리: 각 Parent 엔티티의 children 필드를 접근할 때마다 실행됩니다.
SELECT * FROM Child WHERE parent_id = ?;
결과적으로, 부모 엔티티가 10개 있다면, 총 11개의 쿼리(1개의 부모 쿼리 + 10개의 자식 쿼리)가 실행됩니다. 이처럼 불필요하게 많은 쿼리가 실행되면서 성능 문제가 발생합니다. 이 현상이 바로 N+1 문제입니다.
N+1 문제 해결 방법
N+1 문제를 해결하기 위한 몇 가지 주요 방법을 소개하겠습니다.
1. Fetch Join 사용
JPQL에서 FETCH JOIN을 사용하여 연관된 엔티티를 한 번의 쿼리로 함께 로드할 수 있습니다.
List<Parent> parents = entityManager.createQuery(
"SELECT p FROM Parent p JOIN FETCH p.children", Parent.class).getResultList();
이 쿼리는 부모와 자식을 한 번의 쿼리로 로드합니다.
SELECT p.*, c.* FROM Parent p JOIN Child c ON p.id = c.parent_id;
2. Entity Graph 사용
JPA 2.1 이상에서는 엔티티 그래프(Entity Graph)를 사용하여 특정 엔티티를 로드할 때 연관된 엔티티를 명시적으로 지정할 수 있습니다.
EntityGraph<Parent> graph = entityManager.createEntityGraph(Parent.class);
graph.addAttributeNodes("children");
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.fetchgraph", graph);
List<Parent> parents = entityManager.createQuery("SELECT p FROM Parent p", Parent.class)
.setHint("javax.persistence.fetchgraph", graph)
.getResultList();
3. Batch Fetching 사용
Hibernate에서는 Batch Fetching을 사용하여 한 번에 여러 연관된 엔티티를 로드할 수 있습니다. 이는 한 번의 쿼리로 여러 부모의 자식들을 로드하여 쿼리 횟수를 줄입니다.
@Entity
public class Parent {
@Id
private Long id;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
@BatchSize(size = 10)
private List<Child> children;
}
Hibernate는 부모 엔티티를 조회한 후 배치 사이즈만큼 자식 엔티티를 한 번에 조회합니다.
4. Hibernate의 @Fetch Annotation 사용
Hibernate의 @Fetch(FetchMode.SUBSELECT) 어노테이션을 사용하여 서브셀렉트를 통한 배치 로드를 지원합니다.
@Entity
public class Parent {
@Id
private Long id;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private List<Child> children;
}
Hibernate는 첫 번째 쿼리에서 부모 엔티티를 로드하고, 두 번째 쿼리에서 서브셀렉트를 사용하여 연관된 자식 엔티티를 한 번에 로드합니다.
SELECT * FROM Parent;
SELECT * FROM Child WHERE parent_id IN (SELECT id FROM Parent);
결론
N+1 문제는 JPA를 사용할 때 발생할 수 있는 성능 문제 중 하나입니다. 이를 해결하기 위해 Fetch Join, Entity Graph, Batch Fetching, Hibernate의 @Fetch 어노테이션 등 다양한 방법을 사용할 수 있습니다. 각 방법의 장단점을 이해하고, 상황에 맞게 적절한 방법을 선택하는 것이 중요합니다.