JPA에서 발생하는 N+1 문제는, ORM (Object-Relational Mapping) 기술을 사용할 때 불필요한 추가 쿼리가 호출되는 문제이다.
특정 객체를 대상으로 쿼리를 수행했을 때, 해당 객체가 가지고 있는 연관관계 또한 조회하게 되면서 N번의 추가적인 쿼리가 발생하는 문제이다.
아래의 예시를 살펴보자.
@Entity
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author")
private List<Book> books = new ArrayList<>();
}
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
}
위의 예시에서 Author와 Book이라는 두 개의 엔티티가 있고, 각 저자(Author)는 여러 책(Book)을 가지고 있는 상황을 가정하였다.
N+1 문제 발생 코드
public List<Author> findAllAuthors() {
return entityManager.createQuery("SELECT a FROM Author a", Author.class)
.getResultList();
}
위의 메소드 findAllAuthors()
를 호출할 경우 아래의 쿼리가 실행된다.
SELECT * FROM Author;
이 쿼리는 저자 수 만큼의 레코드를 반환한다. 예를 들어, 10명의 저자가 있다고 가정하면 10개의 레코드를 반환한다. 문제는, 이후 각 저자에 대한 책을 가져오기 위해 추가 쿼리가 실행된다는 것이다.
SELECT * FROM Book WHERE author_id = 1;
SELECT * FROM Book WHERE author_id = 2;
...
SELECT * FROM Book WHERE author_id = 10;
따라서 총 1 + N(여기서는 10) = 11개의 쿼리가 실행된다. 저자 수가 많을수록 추가 쿼리 수가 늘어나게 된다.
그렇다면 위와 같은 문제가 발생하는 이유는 무엇인가? 근본적인 원인은 객체지향 언어와 관계형 데이터베이스 간 패러다임 차이에서 비롯된다.
객체지향 프로그래밍에서는 객체가 다른 객체와의 연관 관계를 필드로 가지고 있다. 이를 통해 메모리 내에서 연관된 객체에 쉽게 접근할 수 있다.
관계형 데이터베이스에서는 이러한 연관 관계를 표현하기 위해 외래 키를 사용하고, 연관된 데이터를 조회하기 위해서는 명시적인 SQL 쿼리를 작성해야 한다.
이러한 패러다임 차이로 인해, 연관된 데이터를 조회할 때 추가적인 쿼리가 발생하게 된다. findAllAuthors()
메소드가 호출될 때, JPQL은 객체지향 쿼리로 엔티티의 객체와 필드 이름을 사용하여 쿼리를 작성한다. 그 결과는 SELECT * FROM Author;
로, 해당 쿼리는 연관된 엔티티를 자동으로 조회하지 않기 때문에, 추가적인 쿼리가 발생하게 되는 것이다.
JPA N+1 문제의 해결에 앞서, Loading 전략 변경을 통해 N+1 문제를 해결할 수 없다는 경고가 흔히 동반된다. 그 이유를 즉시 로딩과 지연 로딩 각각의 경우에 N+1 문제가 발생하는 과정을 통해 알아보겠다.
즉시 로딩(Eager Loading): 엔티티를 조회할 때 연관된 엔티티를 즉시 함께 로드하는 방식이다.
지연 로딩(Lazy Loading): 엔티티를 조회할 때 연관된 엔티티를 로드하지 않고, 실제로 접근할 때 로드하는 방식이다.
JPQL에서 만든 SQL을 통해 데이터(1)를 조회
이후, EAGER 전략으로 해당 데이터의 연관 관계인 하위 엔티티들(N)을 추가 조회
2번 과정으로 N + 1 문제 발생
JPQL에서 만든 SQL을 통해 데이터를 조회
JPA에서 Fetch 전략을 가지지만, 지연 로딩이기 때문에 추가 조회는 하지 않음
하지만, 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생하기 때문에 결국 N + 1 문제 발생
결론적으로, EAGER 로딩과 LAZY 로딩 모두 각기 다른 시점에 하위 엔티티들을 조회하지만, N + 1 쿼리 문제가 발생할 수 있다. EAGER 로딩은 즉시 하위 엔티티를 조회하면서 문제를 발생시키고, LAZY 로딩은 나중에 하위 엔티티를 접근할 때 문제를 발생시킨다.
Fetch Join은 JPQL을 사용하여 연관된 엔티티를 한 번에 가져오는 방법이다. N+1 문제는 기본적으로 한 번의 조회로 하나의 테이블만 조회하고, 연결된 테이블은 따로 조회하기에 발생하는 문제이다.
Join과 Fetch Join의 차이?
일반 Join은 JPQL에서 주체가 되는 Entity만을 SELECT 하여 영속화하며, 연관 Entity는 조건으로만 사용되고 영속성 컨텍스트에 저장되지 않는다. Fetch Join은 주체가 되는 Entity와 연관 Entity 모두를 SELECT 하여 영속화한다.SELECT o FROM Order o JOIN o.customer c WHERE c.name = :customerName
일반 Join의 경우, Customer는 Order에 대한 조건으로 사용되며, 영속성 컨텍스트에는 Order만 저장된다.
SELECT o FROM Order o JOIN FETCH o.customer c WHERE c.name = :customerName
Fetch Join 경우 Order와 Customer 모두 영속성 컨텍스트에 포함된다.
위 예시의 경우, Customer와의 Join을 통해 Order를 필터링한 후, 결과적으로 Order의 정보만 필요하다면 Join을 사용하면 되고, Customer 정보까지 필요한 경우 Fetch Join을 사용하면 된다.
SELECT p FROM Parent p JOIN FETCH p.childList
위와 같이 JPQL을 작성할 경우, 미리 두 테이블을 JOIN 하여 한 번에 모든 데이터를 가져온다. 예시에 Fetch Join을 적용하면 아래와 같다.
public List<Author> findAllAuthorsWithBooks() {
return entityManager.createQuery("SELECT a FROM Author a JOIN FETCH a.books", Author.class)
.getResultList();
}
Fetch Join를 사용할 경우, 아래와 같은 단점에 주의해야 한다.
Paging 불가
Fetch Join을 사용하면 한 번의 쿼리로 모든 데이터를 가져오기 때문에, JPA가 제공하는 Paging API(Pageable)를 사용할 수 없다. Paging은 데이터를 페이지 단위로 나누어 필요한 데이터만 부분적으로 가져오는 기능이다.Collection Fetch Join은 하나까지만 가능
둘 이상의 컬렉션을 fetch join 하면, 결과적으로 Cartesian Product가 발생한다. 예를 들어, 각 컬렉션이 각각 N개의 엔티티를 가지고 있다면, N * N 개의 조합이 만들어진다. 이러한 문제를 예방하기 위해 JPA 표준 자체에서 하나 이상의 컬렉션을 fetch join하는 것을 금지하고 있다.별칭(alias) 부여 불가
Fetch join에서 별칭 부여가 안되는 이유는 데이터의 일관성이 깨질 수 있기 때문이다. Fetch join을 하면 엔티티 측에서는 연관된 데이터가 전부 포함되어 있다고 예상하지만, 별칭을 통해 필터링(WHERE 등)을 진행하면 엔티티와 연관된 데이터 간 일관성이 깨지게 된다. (일관성과 무관한 경우, Hibernate는 별칭을 허용한다.)
EntityGraph는 JPA에서 제공하는 기능으로, attributePaths 부분에 같이 조회할 연관 엔티티명을 적는 방식으로 연관된 엔티티를 함께 페치(Fetch)하는 방법이다.
@EntityGraph(attributePaths = {"books"})
attributePaths
속성은 함께 로딩할 연관된 엔티티의 경로를 지정한다. 여기서는 books를 지정하여 Author 엔티티를 조회할 때 Book 엔티티도 함께 로드한다.
Fetch Join과 EntityGraph의 차이점?
위의 예시를 보면 EntityGraph가 단순히 fetch join을 쿼리 없이 수행하는 방법이라고 생각할 수 있다. 하지만 Fetch Join과 EntityGraph의 주요 차이점은 아래와 같다.
- Fetch join의 경우 inner join, EntityGraph는 outer join을 기본으로 한다.
- EntityGraph의 경우 runtime에 fetchType.Lazy를 fetchType.Eager로 변경하여 outer join을 수행한다.
- 이러한 특징 때문에, EntityGraph의 경우 여러 개의 컬렉션을 효율적으로 로드할 수 있다. (여러 연관된 엔티티를 OUTER JOIN을 사용하여 한 번의 쿼리로 로드할 수 있기 때문이다.)
@BatchSize
어노테이션을 사용하면 엔티티나 컬렉션 필드에 대해 배치 사이즈를 설정할 수 있다. 이를 통해 JPA는 연관 엔티티를 가져올 때 한 번에 지정된 크기만큼의 엔티티를 가져온다. 아래의 예시를 보자.
@Entity
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@BatchSize(size = 10)
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books = new ArrayList<>();
}
여기서 @BatchSize(size = 10)
은 JPA가 Author 엔티티의 books 컬렉션을 로드할 때 한 번에 최대 10개의 Book 엔티티를 가져오도록 설정한 것이다.
spring.jpa.properties.hibernate.default_batch_fetch_size=10
Hibernate를 사용하는 경우, 위와 같이 hibernate.default_batch_fetch_size
속성을 설정 파일에 추가하여 전체 애플리케이션에 대한 배치 사이즈를 설정할 수도 있다.
SELECT * FROM Author;
위의 쿼리가 실행되어 Author 엔티티의 books 컬렉션에 접근할 때, JPA는 배치 사이즈에 따라 여러 Book 엔티티를 한 번에 로딩한다. 예를 들면, 아래와 같은 쿼리가 실행된다고 볼 수 있다.
SELECT * FROM Book WHERE author_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Batch Size는 여러 컬렉션을 각각 별도의 배치 쿼리로 로드하기 때문에 fetch join과 달리 여러 개의 컬렉션을 로드할 수 있다. 하지만, Batch Size는 연관된 엔티티를 여러 번의 배치 쿼리로 로드하기에 쿼리 수가 fetch join보다 많다. (Batch Size가 10으로 설정된 경우, 첫 번째 배치로 10개의 엔티티를 로드하고, 다음 배치로 또 10개의 엔티티를 로드하는 방식)