N+1 문제 해결 방법

2025. 2. 27. 14:43·Framework/Spring

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. 총정리


 

 

 

728x90
저작자표시 비영리 변경금지 (새창열림)
'Framework/Spring' 카테고리의 다른 글
  • JPA를 사용한 조회 기능 구현 - 날씨와, 일정 날짜 필터링
  • 컨트롤러에서 Page 객체를 응답할 때, 발생하는 경고 해결하기
  • AOP를 적용하여 요청정보 로깅하기
  • 모든 HTTP 요청 헤더 정보 출력하기
leonie.
leonie.
  • leonie.
    leveloper
    leonie.
  • 글쓰기 관리
    • 분류 전체보기 N
      • Language
        • Java
      • Git
      • CS
      • CodingTest
        • [프로그래머스] 자바
      • Framework
        • Spring
      • Information
      • DBMS
        • Redis
        • SQL
      • AWS
      • OS
        • Mac
      • 자격증 N
        • 정보처리기사 N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Java
    스프링
    springboot
    알고리즘
    정보처리기사
    자바
    정처기
    정처기필기
    프로그래머스
    코딩테스트
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.3
leonie.
N+1 문제 해결 방법
상단으로

티스토리툴바