1. N+1 문제
(1) N+1 문제 정의
N+1문제는 ORM을 사용할 때 발생하는 대표적인 성능저하 문제 입니다.
하나의 쿼리로 N개의 엔티티를 먼저 로딩한 후,
각 엔티티와 연관된 데이터를 조회하기 위해 추가 개별 쿼리가 N번 실행되어,
결국 N+1번의 쿼리가 발생하는 현상입니다.
[N+1 예시]
10개의 게시글이 있고, 각 게시글에 달린 댓글을 조회한다고 가정하면,
초기 게시글 조회 쿼리 1개와 각 게시글에 대한 댓글 조회 쿼리 10개
총 11개의 쿼리가 실행됩니다.
(2) N+1 문제 원인
N+1 문제의 주요 원인은 ORM의 Lazy Loading 방식과 연관관계 매핑에서 발생하는 쿼리 실행 방식에 있습니다.
🤔 Lazy Loading 이란?
연관된 데이터를 지연로딩 방식으로 설정하면,
초기에는 부모 엔티티만 로딩되고, 실제로 연관 데이터를 사용할 때 마다 별도의 쿼리가 실행됩니다.
부모 엔티티를 조회한 후,
각 엔티티의 연관 데이터를 조회할 때마다 별도의 개별 쿼리를 실행하기 때문에,
N개의 엔티티에대해 N번의 추가 쿼리가 발생합니다.
(3) N+1 문제 예시
온라인 서점의 책과, 각 책에 대한 리뷰를 표시
[책 클래스]
import javax.persistence.*;
import java.util.List;
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
// 연관된 리뷰들을 OneToMany 관계로 매핑
@OneToMany(mappedBy = "book", fetch = FetchType.LAZY)
private List<Review> reviews;
// 생성자, 게터
}
}
[리뷰 클래스]
import javax.persistence.*;
@Entity
@Table(name = "reviews")
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String content;
// Book 엔티티와 ManyToOne 관계 설정
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", nullable = false)
private Book book;
// 생성자, 게터
}
[조회 메소드] - 잘못된 방식
public void printBooksWithReviews() {
// 모든 책을 조회하는 쿼리 1회 실행
List<Book> books = bookRepository.findAll();
// 각 책에 대한 리뷰를 조회하는 N번의 추가 쿼리 실행
books.forEach(book -> {
// 해당 책의 ID를 사용하여 리뷰를 조회
List<Review> reviews = reviewRepository.findByBookId(book.getId());
});
}
책 목록을 조회하는 단 하나의 쿼리 실행 후, 각 책에 대한 리뷰를 조회하기 위해 추가적인 쿼리를 실행한다.
책의 수만큼 리뷰 조회 쿼리가 추가적으로 발생하여, 책이 많아질 수록 데이터베이스 부담이 증가하여 성능이 저하된다.
2. N+1 문제 해결 방법
(1) JOIN FETCH
관계가 있는 엔티티를 한 번의 쿼리로 함께 로드해야할 때 사용합니다.
연관된 엔티티를 즉시로딩(Eager Loading)하는 효과를 가지며,
초기 쿼리 실행 시 모든 필요한 데이터를 미리 가져옵니다.
@Query("SELECT b FROM Book b JOIN FETCH b.reviews WHERE b.id = :id")
Book findBookWithReviewsById(Long id);
엔티티 조회를 위한 추가 쿼리가 실행 되지 않아, DB의 쿼리 수가 줄어듭니다.
[주의 사항]
@Query("SELECT DISTINCT b FROM Book b JOIN FETCH b.reviews WHERE b.id = :id")
Book findBookWithReviewsById(Long id);
Book 엔티티와 Review 엔티티가 일대다 관계이므로, 하나의 Book에 여러 Review가 존재할 수 있습니다.
이 경우 JOIN FETCH로 조회하면 동일한 Book 엔티티가 여러 번 중복되어 반환될 수 있으므로,
DISTINCT를 사용하여 중복을 제거하는 것이 좋습니다.
(2) Entity Graphs
📌 참고 : JPA 2.1부터 치원하는 기능입니다.
특정 쿼리에서 엔티티의 로딩 전략을 세밀하게 제어할 수 있게 해줍니다.
이를 통해 필요한 연관 엔티티를 미리 지정해 한 번의 쿼리로 모두 로딩할 수 있으므로,
추가적인 지연 로딩(Lazy Loading) 쿼리를 줄여 N+1문제를 예방할 수 있습니다.
public interface BookRepository extends JpaRepository<Book, Long> {
@EntityGraph(attributePaths = {"reviews"})
List<Book> findAll();
}
findAll 메소드를 호출할 때 Book의 reviews를 즉시 로드합니다.
`attributePaths` 속성에 reviews를 지정하여,
Book 엔티티를 조회할 때 연관된 Review 엔티티들을 즉시 로드합니다.
각 Book 엔티티에 대해 별도로 Review를 로드하는 쿼리를 실행하지 않아도,
한 번의 쿼리로 필요한 모든 데이터를 가져옵니다.
(3) FetchType.EAGER
연관된 엔티티가 항상 필요한 경우, 미리 로드하여 지연이 발생하지 않도록 합니다.
일반적으로 권장되지 않는 방식입니다.
EAGER 로딩은 너무 많은 데이터를 불필요하게 로드할 수 있으며,
특히 많은 연관 관계가 있는 경우 성능이 저하됩니다.
(4) 배치 사이즈 설정
대량의 연관 데이터를 로드할 때, N+1 쿼리 수를 줄이기 위해 사용됩니다.
완전한 해결책은 아니며, 다른 데이터 로딩 전략을 고려해야 합니다.
`@BatchSize`어노테이션을 사용하여 한 번에 로드할 연관 엔티티의 수를 조정할 수 있습니다.
데이터 양과 성능 요구 사항에 따라 적절한 배치 크기 설정이 필요합니다.
@Entity
@Table(name = "books")
public class Book {
@OneToMany(mappedBy = "book", fetch = FetchType.LAZY)
@BatchSize(size = 10)
private List<Review> reviews;
}
(5) DTO 사용
뷰나 API 응답으로 필요한 데이터만 선택적으로 로드하기 위해 DTO를 사용합니다.
불필요한 데이터를 로드하지 않아 성능을 향상합니다.
public class BookDetailDto {
private String title;
private String author;
private List<String> reviewContents;
public BookDetailDto(String title, String author, List<String> reviewContents) {
this.title = title;
this.author = author;
this.reviewContents = reviewContents;
}
}
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT new com.example.dto.BookDetailDto(b.title, b.author, r.content) FROM Book b JOIN b.reviews r")
List<BookDetailDto> findAllBookDetails();
}
3. 총정리