JPA N+1 문제 해결하기 (fetch join, entityGraph, batch size)
서론
사이드 프로젝트를 같이 하는 동료와 이것저것 얘기하다가 N+1 문제에 대해서 이야기가 나왔다. 굉장히 단순하고 간단하게 알고있던 개념이었는데 계속 얘기하다보니 조금 헷갈리고 기존에 잘못 이해하고 있던 개념들도 있다는걸 알게되어서 이번 기회에 N+1 문제에 대해서 정확한 해결 방법을 정리해보려고 했다.
이후 나올 예시 코드를 테스트할 수 있는 컨트롤러, 서비스, 레파지토리는 아래 링크 레파지토리 안에 [nplusone] 패키지에서 확인할 수 있다.
예시 상황
N+1 문제는 데이터베이스와 ORM 환경에서 자주 발생한다. 이 문제는 엔티티와 연관된 다수의 엔티티들을 조회할 때 각 연관 엔티티에 대해 추가적인 데이터베이스 쿼리가 발생하면서 성능 저하를 일으키는 현상이다. Spring Boot와 JPA(Hibernate) 환경에서 이 문제를 쉽게 관찰할 수 있다.
예를 들어, "도서"와 "저자"가 1:N 관계를 가지는 도메인 모델을 가정한다. 여기서 하나의 저자가 여러 권의 도서를 집필했다고 하자.
Author 엔티티: 저자 정보를 저장한다.
Book 엔티티: 각 도서 정보를 저장하며, Author 엔티티와 연관 관계를 설정한다.
// Author 엔티티
@Entity
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books; // 저자가 작성한 책 목록
// ... 기타 필드와 메소드
}
// Book 엔티티
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
// ... 기타 필드와 메소드
}
시나리오 1: 저자 전체 목록만 조회하는 경우
Author 엔티티를 조회할 때 Book 엔티티는 LAZY 로딩으로 설정되어 있다면, 단순히 Author 정보만 조회하는 경우에는 Book에 대한 추가적인 쿼리가 발생하지 않는다. 이 경우에는 N+1 문제가 발생하지 않는다.
예: SELECT * FROM Author 쿼리로 모든 저자를 조회한다.
실제로 실행하면 아래처럼 한 번의 쿼리만 실행되는 모습을 확인할 수 있다.
시나리오 2: 저자와 각 저자가 작성한 책까지 조회하는 경우
Author 엔티티를 조회하고, 이후 각 Author가 작성한 Book 목록을 사용하려 할 때 N+1 문제가 발생한다.
첫 번째 쿼리로 모든 Author 정보를 조회한다. (한 번 쿼리 실행)
이후, 각 Author에 대해 그들이 작성한 Book 목록을 로드하기 위해 추가적인 쿼리가 실행된다. 이때, Author 수만큼 추가적인 쿼리가 발생한다 (N번쿼리).
예: 첫 번째 쿼리 SELECT * FROM Author, 이후 각 저자에 대해 SELECT * FROM Book WHERE author_id = ?
실제로 코드를 실행해 보면 빨간색 박스로 저자를 찾아오는 쿼리가 한 번 실행되고, 분홍색 박스로 표시된 곳에서 조금 생략됐지만 저자의 수만큼 N번 발생하는 모습을 확인할 수 있다.
N+1 문제는 주로 두 번째 시나리오처럼 연관된 데이터를 실제로 사용하려고 할 때 발생한다. 이는 LAZY 로딩 전략이 적용된 상태에서 각 개별 엔티티의 연관 데이터를 접근할 때 매번 별도의 쿼리를 실행하기 때문이다.
시나리오 2에서 N+1 문제 제대로 이해하기
🧐
사실 N+1 문제에 대해서 잘 이해가 안 됐다. 10명의 저자가 있을 때 그 저자가 작성한 책을 가져오기 위해서 10번의 쿼리가 도는 게 왜 문제인가,,, 이게 정상 작동 한 거 아닌가? 당연히 각 저자에 해당하는 책을 가져오려면 저자 수만큼 쿼리가 실행되어야 하는 거 아닌가?라는 생각에 계속 빠져있어서 N+1의 문제점을 파악하지 못하고 계속 삽질을 했었다,,, 혹시 나처럼 N+1 문제가 잘 이해되지 않는 사람을 위해서 조금 더 자세히 살펴보도록 하자,,
데이터베이스에서 모든 저자(Author)를 조회하는 쿼리를 실행한다.
SELECT * FROM Author. 이것이 "1"에 해당한다.
각 저자(Author)가 쓴 책(Book)을 조회하기 위해 추가적인 쿼리를 실행한다. 이 경우, 저자 수만큼 쿼리가 실행된다.
예를 들어, 10명의 저자가 있다면, 각 저자에 대해 책 목록을 가져오기 위해 10개의 추가 쿼리 SELECT * FROM Book WHERE author_id = ?)가 실행된다. 이것이 "N"에 해당한다.
각 저자에 대해 별도의 쿼리를 실행하면, 데이터베이스에 많은 부담이 가해지고 성능이 저하될 수 있다. 그리고 많은 수의 쿼리는 네트워크 지연을 초래할 수 있으며, 이는 애플리케이션의 전반적인 반응 시간에 영향을 줄 수 있다.
- 저자 10명 조회:
SELECT * FROM Author
(1개의 쿼리) - 각 저자의 책 조회: 각 저자에 대해
SELECT * FROM Book WHERE author_id = ?
(10개의 추가 쿼리) - 총 쿼리 수: 1 (초기 쿼리) + 10 (각 저자의 책 조회) = 11개의 쿼리
이 경우, 최적화되지 않은 상태에서는 11개의 쿼리가 실행된다. 이것이 바로 N+1 문제다.
N+1 문제를 해결하기 위한 주요 방법으로 JOIN FETCH, EntityGraph, 그리고 Batch Size 설정을 사용하는 방법이 있다. 이러한 접근법들을 각각 상세히 설명해 보겠다.
해결 방법 1 - JOIN FETCH
JOIN FETCH는 JPA 쿼리에서 사용되며, 연관된 엔티티를 한 번의 쿼리로 함께 로드하는 방법이다. 이는 N+1 문제를 효과적으로 해결할 수 있다.
// Repository에서 JOIN FETCH 사용
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT distinct a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
}
실제로 실행해 보면 아래처럼 한 번에 쿼리가 실행되는 모습을 확인할 수 있다.
이 쿼리는 모든 Author와 그들의 Book 목록을 한 번의 쿼리로 가져온다. 이렇게 하면 각 Author에 대해 별도의 쿼리를 실행할 필요가 없어져 성능이 향상된다.
⚠️ 주의 사항
- FETCH JOIN은 쿼리에서 카테시안 곱을 발생시킬 수 있다. 카테시안 곱이란, 두 테이블을 JOIN할 때 발생하는 현상으로, 한 테이블의 모든 행이 다른 테이블의 모든 행과 결합되어 많은 수의 결과 행을 생성하는 것을 의미한다.
- 예를 들어, Author A가 3권의 책을 가지고 있고, Author B가 2권의 책을 가지고 있다면, 단순한 JOIN은 A의 책 3권과 B의 책 2권을 모두 결합하여 총 5개의 행을 반환한다. 그러나 FETCH JOIN을 사용하면, Author와 Book이 결합되어 카테시안 곱이 발생할 수 있으며, 이로 인해 동일한 Author 데이터가 여러 번 중복될 수 있다.
- 이를 방지하기 위해 DISTINCT를 사용하는 것이 좋다. DISTINCT는 중복된 결과를 제거하며, 카테시안 곱으로 인해 동일한 결과가 여러 번 나타나는 것을 줄일 수 있다.
해결 방법 2 - EntityGraph
EntityGraph는 JPA 2.1부터 도입된 기능으로, 엔티티의 특정 속성에 대한 로딩 전략을 정의할 수 있다. EntityGraph를 사용하면 JOIN FETCH와 유사한 방식으로 연관된 엔티티를 함께 로드할 수 있다.
// Author 엔티티에 EntityGraph 정의
@Entity
@NamedEntityGraph(name = "Author.books", attributeNodes = @NamedAttributeNode("books"))
public class Author {
// ... 필드와 메소드
}
// Repository에서 EntityGraph 사용
public interface AuthorRepository extends JpaRepository<Author, Long> {
@EntityGraph(value = "Author.books", type = EntityGraph.EntityGraphType.LOAD)
List<Author> findAll();
}
이렇게 하면 findAll 메서드를 호출할 때 Author와 연관된 Book 목록이 함께 로드된다.
⚠️ 주의 사항
- EntityGraph의 주된 단점은 복잡한 쿼리에서 카테시안 곱(Cartesian Product)을 유발할 수 있다는 것이다. 특히, 여러 컬렉션을 함께 로드할 때 이 문제가 발생할 수 있다.
- EntityGraph를 사용할 때는 연관된 데이터의 양과 복잡도를 고려해야 한다. 필요하지 않은 데이터까지 로드하여 성능 저하를 유발하지 않도록 주의해야 하기 때문에 DISTINCT 사용을 권장하는데, FETCH JOIN 사용 시 DISTINCT를 사용하면 같은 엔티티가 여러 번 로드되는 것을 방지할 수 있다. 이는 특히 카테시안 곱 때문에 동일한 결과가 여러 번 나타나는 것을 줄이는 데 도움이 된다.
Join Fetch와 EntityGraph 차이점
해결 방법 3 - Batch Size 설정
Batch Size 설정은 Hibernate의 기능으로, 한 번의 쿼리로 여러 연관 엔티티를 배치로 로드할 수 있게 한다. 이는 쿼리의 수를 줄이는 데 도움이 된다.
application.yml에 아래와 같은 옵션을 추가해 준다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 10
이 설정은 Hibernate에서 제공하는 배치 크기 설정을 의미한다. 이 설정은 Hibernate가 연관된 엔티티(예: 컬렉션 또는 연관된 객체들)를 로드할 때 한 번에 로드할 수 있는 최대 수를 지정한다.
default_batch_fetch_size에 설정된 값은 Hibernate가 생성하는 SQL 쿼리에서 IN 절을 사용할 때 최대로 포함할 수 있는 엔티티의 수를 결정한다.
예를 들어, default_batch_fetch_size를 10으로 설정하면, Hibernate는 연관된 엔티티를 로드할 때 최대 10개의 엔티티 ID를 IN 절에 포함시키는 쿼리를 생성한다.
이 방식은 네트워크 트래픽과 데이터베이스의 부하를 줄이는 데 도움이 되며, 애플리케이션의 전반적인 성능을 향상시킬 수 있다.
아래는 실제로 실행했을 때 발생한 로그다. 이 로그를 보면 Hibernate의 배치 처리 기능(default_batch_fetch_size)이 예상대로 작동하고 있다. 두 번째 쿼리에서 IN 절을 사용하여 한 번에 여러 저자의 Book 엔티티를 로드하고 있는 것을 볼 수 있다.
🧐
- N+1 문제 발생 시: 각 Author 엔티티에 대해 별도의 SELECT * FROM Book WHERE author_id = ? 쿼리가 실행된다. 예를 들어, 10명의 저자가 있다면, 10개의 별도 쿼리가 실행된다.
- 현재 상태: 하나의 Author 쿼리와 하나의 Book 쿼리가 실행된다. Book 쿼리는 IN 절을 사용하여 한 번에 여러 저자의 책을 로드한다. 이는 효율적인 데이터 로딩 방법이며, N+1 문제를 피하는 방법이다.
default_batch_fetch_size를 사용하는 방법은 N+1 문제를 완전히 제거하는 것이 아니라, 쿼리의 수를 크게 줄여 성능을 개선하는 접근 방법이다. 이 설정은 연관된 엔티티들을 더 적은 수의 쿼리로 묶어서 로드하는 방식으로 작동한다.
- N+1 문제: 각 연관 엔티티에 대해 별도의 쿼리가 실행된다. 예를 들어, 300명의 저자가 각각의 책 목록을 가지고 있다면, 300개의 추가 쿼리가 실행된다.
- 배치 처리 사용 시: default_batch_fetch_size를 10으로 설정했다면, Hibernate는 한 번에 최대 10명의 저자에 대한 책 목록을 가져오는 쿼리를 실행한다. 따라서 300명의 저자에 대해서는 총 30번의 쿼리가 실행된다. 이는 N+1 문제를 완전히 해결하는 것은 아니지만, 쿼리의 수를 대폭 줄이는 효과가 있다.
default_batch_fetch_size 설정은 N+1 문제를 완화하여 성능을 개선한다. 완전한 해결은 아니지만, 실질적인 성능 향상을 가져올 수 있다. 그러나 배치 크기와 관련된 설정은 애플리케이션의 특정 요구 사항에 따라 조정해야 한다. 너무 큰 배치 크기는 메모리 사용량을 증가시킬 수 있고, 너무 작은 배치 크기는 여전히 많은 수의 쿼리를 발생시킬 수 있다.
어떤 상황에서 어떤 해결책을 선택해야 할까?
- 적합한 상황: 데이터베이스에서 연관된 엔티티의 데이터를 한 번의 쿼리로 가져와야 할 때. 특히, 연관된 데이터의 양이 많지 않고, 결과 집합이 크게 증가하지 않을 때 유용하다.
- 주의할 점: JOIN FETCH를 사용하면 카테시안 곱이 발생할 수 있으므로, 여러 JOIN을 중첩해야 하는 복잡한 쿼리에는 적합하지 않다.
- 적합한 상황: 객체 모델의 로딩 전략을 유연하게 관리하고 싶을 때. EntityGraph는 어노테이션을 통해 로딩 전략을 명시적으로 정의할 수 있어, 다양한 쿼리에 적용할 수 있다.
- 주의할 점: JOIN FETCH와 마찬가지로, 카테시안 곱의 위험이 있으므로, 결과 집합의 크기가 크게 증가하지 않는 경우에 적합하다.
- 적합한 상황: 연관된 데이터의 양이 많고, 각 연관 엔티티에 대한 추가 쿼리 수를 줄이고자 할 때. 특히 대량의 데이터를 처리해야 하는 경우에 적합하다.
- 주의할 점: 배치 크기가 너무 크면 메모리 사용량이 증가할 수 있고, 너무 작으면 여전히 많은 수의 쿼리가 발생할 수 있다. 따라서 애플리케이션의 특정 요구 사항에 맞게 배치 크기를 조절해야 한다.
🔥 결론
사실 N+1 문제를 해결하기 위해선 프로젝트의 상황과 요구 사항 등등 여러 가지를 생각하며 최적의 해결책을 선택해야 한다. 결국, 각 해결 방법의 장단점을 이해하고, 이를 효과적으로 조합하여 사용하는 것이 중요한것같다.
🔻 테스트 코드의 가독성을 향상시키는 방법이 궁금하다면? 🔻
테스트 코드의 가독성 향상 - BDD 방법론과 @DisplayName 어노테이션 활용
'Spring > Spring Data JPA' 카테고리의 다른 글
QueryDSL 사용 시 자동 업데이트 되지 않는 JPA Auditing 수정 날짜 문제 해결 방법 (1) | 2024.02.01 |
---|---|
JPA와 Spring Data JPA의 차이 (1) | 2024.01.02 |
[Spring Data JPA] ResponseDTO에 기본 생성자 있어야하는 이유 (1) | 2023.11.07 |
native query에서 @rank := 0, @rownum 처럼 사용자 정의 변수 사용할때 에러 발생 (1) | 2023.10.26 |
JPA(native query)에서 MariaDB WITH RECURSIVE 작동 안되는 오류 (1) | 2023.10.26 |