JPA(Java Persistence API)에서 N+1 문제는 데이터베이스 쿼리를 실행할 때 발생하는 성능 문제 중 하나입니다.
N+1 문제란?
연관관계에서 발생하는 이슈로 연관관계가 설정된 부모 엔티티를 조회할 경우 조회된 자식 엔티티들만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 됩니다. 이를 N+1 문제라고 합니다.
예를 들어 N+1 문제는 다음과 같은 상황에서 발생합니다.
- 1개의 쿼리로 특정 객체를 로딩합니다. (예: 부모 객체)
- 이후 해당 객체와 관련된 N개의 객체를 가져와야 합니다. (예: 자식 객체들)
- N개의 객체를 가져오기 위한 쿼리가 N번 만큼 추가로 실행됩니다.
설명을 위한 엔티티 예제
단순하게 부모 객체와 자식 객체 관계로 표현해보겠습니다.
- 부모는 여러 자식을 키우고 있다.
- 자식들은 한 명의 부모에 종속되어 있다.
- 부모와 자식 간 양방향 연관관계를 가진다.
@Entity
@Getter @Setter
@NoArgsConstructor
public class Parent {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
// 연관관계의 주인은 Child 클래스 필드의 parent이며, 즉시 로딩으로 설정한다.
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<Child> childList = new ArrayList<>();
...
}
@Entity
@Getter @Setter
@NoArgsConstructor
public class Child {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
@ManyToOne
@JoinColumn(name = "parent_id") // 연관관계의 주인으로 설정한다.
private Parent parent;
public Child(String name) {
this.name = name;
}
// 양방향 연관관계 매핑 메서드
public void changeParent(Parent parent) {
parent.getChildList().add(this);
this.setParent(parent);
}
}
테스트 케이스를 만들어 Mock 데이터를 넣은 후, 엔티티를 조회합니다.
- 부모를 2명 생성한다.
- 자식을 10명 생성한다.
- 각각의 부모는 자식을 10명씩 키우고 있다.
@Test
void test() {
Parent parent1 = new Parent("parent1"); // 부모1 엔티티 생성
Parent parent2 = new Parent("parent2"); // 부모2 엔티티 생성
for (int i = 0; i < 10; i++) {
Child child = new Child("child" + i); // 자식 엔티티 생성
child.changeParent(parent1); // 양방향 연관관계 매핑
}
for (int i = 0; i < 10; i++) {
Child child = new Child("child" + i); // 자식 엔티티 생성
child.changeParent(parent2); // 양방향 연관관계 매핑
}
parentRepository.save(parent1); // 영속성 전이로 부모 엔티티만 영속화
parentRepository.save(parent2); // 영속성 전이로 부모 엔티티만 영속화
entityManager.flush(); // 영속성 컨텍스트에 보관된 엔티티를 데이터베이스에 반영한다.
entityManager.clear(); // 영속 컨텍스트를 비운다. (초기화)
System.out.println("---------------------------------------");
List<Parent> findParents = parentRepository.findAll();
}
🤔 결과는 어떻게 되었을까?
Hibernate SQL log를 활성화하여 실제 호출된 쿼리를 확인해 본 결과 다음과 같습니다.
- 부모를 모두 조회하는 쿼리를 호출한다. -> findAll()
- 자식을 조회하는 쿼리가 부모를 조회한 row 만큼 쿼리가 추가로 발생된 것을 확인할 수 있다.
🤔 즉시 로딩(Eager)이기 때문에 발생하는 것일까?
결론부터 말하면 아닙니다. 연관된 엔티티를 즉시 로딩으로 한 번에 조회했기 때문에 발생하는 문제라고 생각할 수도 있습니다. 그러나 이는 잘못된 생각이며 직접 지연 로딩(Lazy)으로 변경하여 확인해 봅시다.
public class Parent {
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private List<Child> childList = new ArrayList<>();
...
}
fetch = FetchType.LAZY로 바꿔 위 테스트 코드를 그대로 실행합니다.
확인 결과 조회 쿼리가 하나(?) 밖에 발생되지 않았습니다.
🤔 그렇다면 해결된 것일까?
언뜻 해결된 것처럼 보이나, 그렇지 않습니다. 지연 로딩은 연관관계 데이터를 프록시 객체로 바인딩하기 때문에 현시점에서는 조회 쿼리가 발생되지 않습니다. 그러나 자식 엔티티를 사용하게 될 경우 사용되는 자식의 수만큼 조회 쿼리가 발생하게 됩니다.
부모 객체가 데리고 있는 자식 객체의 이름을 사용하여 호출해 보면 SQL 로그는 다음과 같습니다.
List<Parent> findParents = parentRepository.findAll();
List<String> childNames = findParents.stream()
.flatMap(parent -> parent.getChildList().stream()
.map(Child::getName)).toList();
로그를 확인해 보면 결국 N+1 문제는 동일하게 발생되는 것을 확인할 수 있습니다. 지연 로딩은 단지 N+1 문제가 발생하는 시점을 뒤로 미룰 뿐 문제가 해결되지 않습니다.
N+1 문제 해결방안
🤔 N+1 문제는 왜 발생하는 것일까?
JpaRepository에 정의된 인터페이스 메서드를 실행해 보면 JPA는 메서드를 분석하여 JPQL을 생성하여 실행하게 됩니다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어로 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 생성합니다. 이러한 이유로 JPQL은 findAll() 메서드를 실행할 때 해당 엔티티를 조회하는 select * from Parent 쿼리만 실행하게 됩니다. JPQL 입장에서 연관관계를 무시하고 해당 엔티티를 기준으로 쿼리를 조회하기 때문입니다.
이러한 이유로 연관된 엔티티 데이터가 필요한 경우, FetchType으로 설정한 기준으로 즉시 로딩을 할지, 지연 로딩을 할지 결정하여 호출하게 됩니다.
지금부터 N+1 해결방안이 무엇이 있는지 알아봅시다.
Fetch join
페치 조인(fetch join)은 JPQL에서 성능 최적화를 위해 제공되는 기능으로, 일반적인 SQL 조인과 다릅니다. 즉, 연관된 엔티티나 컬렉션을 한 번의 쿼리로 조회할 수있도록 도와줍니다.
JPQL에서 페치 조인은 다음과 같이 사용할 수 있습니다.
// 직접 EntityManager를 통해 JPQL을 실행하는 방식
List<Parent> findParents = em.createQuery(
"select p from Parent p join fetch p.child c", Parent.class)
.getResultList();
List<String> childNames = findParents.stream()
.flatMap(parent -> parent.getChildList().stream()
.map(Child::getName)).toList();
// JpaRepository를 활용하여 JPQL을 실행하는 방식 (실제로 이 방법을 사용한다.)
@Query("select p from Parent p join fetch p.childList")
List<Parent> findAllFetchJoin();
SQL 로그는 다음과 같이 한번의 쿼리만 발생합니다.
select
p1_0.parent_id,
cl1_0.parent_id,
cl1_0.child_id,
cl1_0.name,
p1_0.name
from
parent p1_0
join
child cl1_0
on p1_0.parent_id=cl1_0.parent_id
그렇다면 일반 조인과 다를 게 없어 보인다는 의문이 들기도 합니다. 그러나 방식에 차이가 있습니다. 일반 조인과 차이점은 지연 로딩인 경우 일반 조인은 실행 시 연관된 엔티티를 조회하지 않으나, 페치 조인은 연관된 엔티티를 가져오기 때문에 FetchType의 설정이 무의미하다는 단점을 가집니다.
🤔 페치 조인(fetch join)의 특징과 한계?
페치 조인은 애플리케이션 전체에 영향을 미치므로 글로벌 로딩 전략이라고 불립니다. 앞서 설명했듯이 FetchType 설정이 무의미하며, 즉시 로딩되어 조회됩니다. 이러한 이유로 실무에서는 기본적으로 지연 로딩으로 최적화한 이후에 필요에 따라 페치 조인을 적용하는 것이 효율적입니다.
페치 조인 대상에는 별칭을 줄 수 없기 때문에 select, where, 서브 쿼리쿼리에 페치 조인 대상을 적용할 수 없습니다. 이러한 제약을 걸어둔 이유는 잘못된 별칭 사용으로 무결성이 깨질 수 있기 때문입니다.
페치 조인은 둘 이상의 컬렉션 페치를 하면 데이터가 증폭되는 문제를 가지고 있어 사용하면 안 됩니다. 또한 페이징을 사용할 수 없습니다. 하나의 쿼리문으로 가져오다 보니 페이징 단위로 데이터를 가져오는 것이 불가능합니다.
💡 컬렉션 페치 조인
Hibernate 6부터 중복 제거 명령어인 distinct를 사용하지 않아도 엔티티의 중복을 제거하도록 변경되었습니다. 이는 데이터베이스가 아닌 애플리케이션 상에서 강제로 중복을 제거해 줍니다.
EntityGraph
@EntityGraph의 attributePaths에 쿼리 수행 시 바로 가져올 필드명을 지정하면 지연 로딩(Lazy)이 아닌 즉시 로딩(Eager)으로 조회하여 가져오게 됩니다. 페치 조인(fetch join)과 동일하게 JPQL을 사용하여 쿼리문을 생성하고, 필요한 연관관계를 EntutyGraph에 설정하면 됩니다.
EntityGraph는 JpaRepository를 통해 활용할 수 있습니다.
@EntityGraph(attributePaths = "childList")
@Query("select p from Parent p")
List<Parent> findAllEntityGraph();
System.out.println("---------------------------------------");
List<Parent> findParents = parentRepository.findAllEntityGraph();
List<String> childNames = findParents.stream()
.flatMap(parent -> parent.getChildList().stream()
.map(Child::getName)).toList();
해당 메서드를 다음과 같이 실행하면 다음과 같이 실행됩니다.
select
p1_0.parent_id,
cl1_0.parent_id,
cl1_0.child_id,
cl1_0.name,
p1_0.name
from
parent p1_0
left join
child cl1_0
on p1_0.parent_id=cl1_0.parent_id
FetchMode.SUBSELECT
해당 방식은 한 번의 쿼리가 아닌 두 번의 쿼리로 해결하는 방법입니다. 해당 엔티티를 조회하는 쿼리는 그대로 발생되며, 연관관계 데이터를 조회할 때 서브 쿼리로 함께 조회하는 방식입니다.
@Entity
@Getter @Setter
@NoArgsConstructor
public class Parent {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "parent_id")
private Long id;
private String name;
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "parent", fetch = FetchType.EAGER, cascade = CascadeType.ALL)
private List<Child> childList = new ArrayList<>();
public Parent(String name) {
this.name = name;
}
}
부모 엔티티를 findAll() 메서드를 통해 조회하면 다음과 같습니다.
select
p1_0.parent_id,
p1_0.name
from
parent p1_0
select
cl1_0.parent_id,
cl1_0.child_id,
cl1_0.name
from
child cl1_0
where
cl1_0.parent_id=?
즉시 로딩으로 설정하면 조회 시점에 2번의 쿼리가 발생되며, 지연 로딩으로 설정하면 사용하는 시점에 2번째 쿼리가 실행되게 됩니다.
BatchSize
Hibernate가 제공하는 "org.hibernate.annotations.BatchSize" 애노테이션을 이용하면 연관된 엔티티를 조회할 때 지정된 size 만큼 SQL의 IN절을 사용하여 조회합니다.
@Entity
@Getter @Setter
@NoArgsConstructor
public class Parent {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "parent_id")
private Long id;
private String name;
@BatchSize(size = 5) // BatchSize 5번
@OneToMany(mappedBy = "parent", fetch = FetchType.EAGER, cascade = CascadeType.ALL)
private List<Child> childList = new ArrayList<>();
public Parent(String name) {
this.name = name;
}
}
즉시 로딩 설정하였기 때문에 부모 객체를 조회하는 시점에 자식 객체들도 함께 조회하게 됩니다. @BatchSize를 통해 자식의 row 객수만큼 추가 SQL를 날리지 않고, 조회한 부모의 id들을 모아 SQL IN 절을 날립니다.
select
p1_0.parent_id,
p1_0.name
from
parent p1_0
select
cl1_0.parent_id,
cl1_0.child_id,
cl1_0.name
from
child cl1_0
where
cl1_0.parent_id in (?, ?, ?, ?, ?)
size는 IN절에 최대 인자 개수를 의미합니다.
QueryBuilder
Query를 실행하도록 지원해 주는 다양한 플러그인이 있습니다. 대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있으며, 이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있습니다. 일반적으로 JPA는 QueryDSL와 조합하여 사용합니다.
결론
N+1 문제는 JPA를 사용하면서 자주 부딪히는 문제 중 하나입니다. Fetch Join이나 EntityGraph를 사용하면 Join문을 통해 하나의 쿼리로 해결할 수 있지만, 데이터 중복 관리가 필요하고 FetchType에 따라 동작이 달라질 수 있습니다.
SUBSELECT는 두 번의 쿼리로 실행되지만 FetchType을 EAGER로 설정해야 한다는 단점이 있습니다. 또한 BatchSize는 연관관계의 데이터 크기를 정확히 알아야 최적화할 수 있는 size를 설정할 수 있지만, 실제로는 연관 관계 데이터 크기를 파악하기 어렵습니다.
JPA만으로는 모든 비즈니스 로직을 구현하기 부족할 수 있습니다. JPA는 만능이 아니기 때문에 간단한 구현은 퍼포먼스를 향상할 수 있지만, 복잡한 비즈니스 로직은 복잡한 쿼리를 통해 구현할 때 다양한 난관에 부딪힐 수 있습니다. 그리고 항상 불필요한 쿼리를 조심해야 합니다. 이러한 이유로 QueryBuilder를 함께 사용하는 것이 권장됩니다. 그렇게 함으로써 다양한 이슈를 해결할 수 있습니다.
GitHub Repositoty & 참조
https://github.com/seonghun08/jpa-nplus1-problem
- https://jojoldu.tistory.com/165
- https://tech.wheejuni.com/2018/06/16/jpa-cartesian/
- https://joont92.github.io/jpa/JPA-성능-최적화/
- https://www.icatpark.com/entry/N-1-문제-원인
'Spring > JPA' 카테고리의 다른 글
QueryDSL 문법 정리 (0) | 2024.07.13 |
---|---|
엔티티(Entity) 설계시 주의점 (1) | 2024.05.23 |
영속성 전이(CASCADE)와 고아 객체(ORPHAN) (0) | 2024.04.28 |
즉시 로딩(Eager)과 지연 로딩(Lazy) (0) | 2024.04.28 |
프록시(Proxy) (0) | 2024.04.28 |