본문 바로가기

Spring 정리/Spring JPA

N + 1 문제

반응형

개요


N + 1이란, JPA를 사용하면서, 연관관계에 있는 엔티티들을 조회할 때, 조인과 관련하여 쿼리 실행 횟수가 불필요하게 늘어나는 경우를 말합니다. N + 1 문제를 위해서, 엔티티의 연관관계 설정에 따라서 발생하는 결과를 알아봅니다.

 

 

연관관계 조회 타입


엔티티 연관관계에서 기본 설정 값은 아래와 같습니다.

 

@OneToOne, @ManyToOne : EAGER

(연관 엔티티가 1개만 있기 때문에 하나정도는 같이 조회하는 의미로 EAGER로 만들지 않았나 생각합니다.)

 

@ManyToMany, @OneToMany : LAZY

(연관 엔티티가 여러개인 리스트라 쿼리 양이 많으므로, 최대한 해당 엔티티 리스트를 사용할 때 쿼리를 날리라는 의미에서 LAZY라고 생각합니다. )

 

아래 Review 엔티티가  USER,  BOOK을 @ManyToOne로 가지고 있으므로 defualt는 EAGER입니다. 조회하는 시점에서만 데이터를 가져오기위해서 @ManyToOne(fetch = FetchType.LAZY)로 지연로딩전략을 사용합니다.

 

public class Review {
...
@ManyToMany(fetch = FetchType.LAZY)
@ToString.Exclude
private User user;

@ManyToOne(fetch = FetchType.LAZY)
@ToString.Exclude
private Book book;

@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name="review_id")
private List<Comment> comments;
...
}

 

 

특히 @ToString.Exclude를 사용해야 하는데 이유는 다음과 같이 2가지입니다.

 

1. @ToString.Exclude를 해야 LAZY 지연로딩의 효과를 볼 수 있습니다. ToString으로 getter가 호출되면 지연로딩으로 늦춘 쿼리조회가 나가기 때문입니다. 따라서 의도치 않게 ToString으로 쿼리가 실행될 수 있으므로 추가합니다.

 

2. @ToString.Exclude를 해야 순환참조를 예방합니다.(StackOverFlow) 연관관계가 있는 각 엔티티는 서로 칼럼을 가지고 있기 때문에, 의도치 않게 다른 엔티티를 조회하며 서로 조회하면 순환참조가 발생할 수도 있습니다.

 

EAGER와 LAZY는 쿼리를 호출하는 시점의 차이이지 특별하게 N + 1 의 쿼리 횟수에 영향을 끼치지 않습니다. 

 

 

* @ToString.Exclude가 없는 경우

 

Review : Comment 가 1: N의 연관관계를 가지고 있는 경우를 확인합니다.

 

//@OneToMany(fetch = FetchType.LAZY)
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name="review_id")
private List<Comment> comments = new ArrayList<>();

 

    @Test
    @Transactional
    void reviewTest() {
        List<Review> reviewList = reviewRepository.findAll();

        System.out.println(reviewList);
    }

 

 

Comment 테이블에는 2개의 row가 저장되어 있습니다.

 

 

(comments에 @ToString.Exclude가 없음에 유의합니다)

 

결론적으로는 LAZY와 EAGER 모두 같은 결과를 보여줍니다.

 

Hibernate: 
    select
        review0_.id as id1_8_,
        review0_.created_at as created_2_8_,
        review0_.updated_at as updated_3_8_,
        review0_.book_id as book_id7_8_,
        review0_.content as content4_8_,
        review0_.score as score5_8_,
        review0_.title as title6_8_,
        review0_.user_id as user_id8_8_ 
    from
        review review0_
Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?
Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?

 

EAGER, LAZY 모두 comments가 2개가 조회됩니다. Review조회 쿼리를 하나 날렸는데 Comment 테이블의 데이터 갯수만큼 추가쿼리가 발생합니다. 이는 Review를 조회하면서 ToString으로 모든 칼럼들을 조회하기 때문입니다. 총 N + 1로 조회됩니다.

 

 

* @ToString.Exclude가 있는 경우

 

여기서 EAGERLAZY는 차이가 있습니다.

 

EAGER는 전체를 조회하는 시점에서 이미 쿼리를 모두 가져와서 영속성 컨텍스트에 넣습니다.

 

LAZY의 경우 getComment()로 getter를 조회할 때 비로소 DB에 쿼리를 날려 영속성 컨텍스트에 가져옵니다

 

    @Test
    @Transactional
    void reviewTest() {
        List<Review> reviewList = reviewRepository.findAll();

        //System.out.println(reviewList); => 이번 비교는 Review 리스트 조회 제거!
        System.out.println("전체를 가져왔습니다.");

        System.out.println(reviewList.get(0).getComments());
        System.out.println("첫번째 리뷰의 코멘트를 가져왔습니다");

        System.out.println(reviewList.get(1).getComments());
        System.out.println("두번째 리뷰의 코멘트를 가져왔습니다");
    }

 

Review의 모든 엔티티들이 영속성 컨텍스트에 있습니다. LAZY라면, 연관관계에 있는 엔티티는 프록시에 있을 것이고, EAGER라면, Review 뿐만 아니라, 연관관계에 있는 엔티티들도 영속성 컨텍스트에 있을 것입니다.

 

 

* LAZY

 

@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name="review_id")
@ToString.Exclude
private List<Comment> comments = new ArrayList<>();

 

Hibernate: 
    select
        review0_.id as id1_8_,
        review0_.created_at as created_2_8_,
        review0_.updated_at as updated_3_8_,
        review0_.book_id as book_id7_8_,
        review0_.content as content4_8_,
        review0_.score as score5_8_,
        review0_.title as title6_8_,
        review0_.user_id as user_id8_8_ 
    from
        review review0_
전체를 가져왔습니다.
Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?
[Comment(super=BaseEntity(createdAt=null, updatedAt=null), id=1, comment=null)]
첫번째 리뷰의 코멘트를 가져왔습니다
Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?
[Comment(super=BaseEntity(createdAt=null, updatedAt=null), id=2, comment=null)]
두번째 리뷰의 코멘트를 가져왔습니다

 

LAZY이므로 comment 객체는 영속성 컨텍스트에 없습니다. 따라서, getComment()인 getter로 comment를 조회할 때 비로소 영속성 컨텍스트에 comment를 가져오므로 쿼리가 동작합니다.

 

 

* EAGER

 

@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name="review_id")
@ToString.Exclude
private List<Comment> comments = new ArrayList<>();

 

Hibernate: 
    select
        review0_.id as id1_8_,
        review0_.created_at as created_2_8_,
        review0_.updated_at as updated_3_8_,
        review0_.book_id as book_id7_8_,
        review0_.content as content4_8_,
        review0_.score as score5_8_,
        review0_.title as title6_8_,
        review0_.user_id as user_id8_8_ 
    from
        review review0_
Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?
Hibernate: 
    select
        comments0_.review_id as review_i5_4_0_,
        comments0_.id as id1_4_0_,
        comments0_.id as id1_4_1_,
        comments0_.created_at as created_2_4_1_,
        comments0_.updated_at as updated_3_4_1_,
        comments0_.comment as comment4_4_1_,
        comments0_.review_id as review_i5_4_1_ 
    from
        comment comments0_ 
    where
        comments0_.review_id=?
전체를 가져왔습니다.
[Comment(super=BaseEntity(createdAt=null, updatedAt=null), id=1, comment=null)]
첫번째 리뷰의 코멘트를 가져왔습니다
[Comment(super=BaseEntity(createdAt=null, updatedAt=null), id=2, comment=null)]
두번째 리뷰의 코멘트를 가져왔습니다

 

이미 영속성 컨텍스트에 모든 comment 쿼리가 있기 때문에, getComment()에서 별도의 쿼리가 발생하지 않습니다. 이미 처음에 모든 Review를 조회할 때, 연관되어 있는 Comment 정보들에 쿼리를 날립니다.

 

즉, N + 1 문제점이란, N개의 전체 조회(1) + N개가 각각 연관관계 조회(N)의 횟수만큼 쿼리를 날리는 문제입니다.

 

 

해결책


N + 1과 관련한 문제를 예방하기 위해서 조회하는 방법은 3가지가 있습니다. 크게 join fetch와 엔티티 그래프탐색이고, 그래프 탐색은 2가지로 가능합니다.

 

    @Test
    @Transactional
    void reviewTest() {
        List<Review> reviewList = reviewRepository.findAllByFetchJoin();

        reviewList.forEach(System.out::println);
    }

 

테스트는 다음처럼 상황에 맞는 메서드를 실행하여 리스트로 리턴받고 각각 출력합니다.

 

 

1. join fetch


해결책을 확실하게 확인하기 위해 comment 3번째를 추가합니다.

 

 

 

comment 테이블에서 REVIEW_ID 가 1인 경우가 2가지라는 것에 주의합니다.

 

public interface BookRepository extends JpaRepository<Book, Long> {    
    @Query("select r FROM Review r join fetch r.comments")
    List<Review> findAllBYFetchJoin();
}

 

Hibernate: 
    select
        review0_.id as id1_8_0_,
        comments1_.id as id1_4_1_,
        review0_.created_at as created_2_8_0_,
        review0_.updated_at as updated_3_8_0_,
        review0_.book_id as book_id7_8_0_,
        review0_.content as content4_8_0_,
        review0_.score as score5_8_0_,
        review0_.title as title6_8_0_,
        review0_.user_id as user_id8_8_0_,
        comments1_.created_at as created_2_4_1_,
        comments1_.updated_at as updated_3_4_1_,
        comments1_.comment as comment4_4_1_,
        comments1_.review_id as review_i5_4_1_,
        comments1_.review_id as review_i5_4_0__,
        comments1_.id as id1_4_0__ 
    from
        review review0_ 
    inner join
        comment comments1_ 
            on review0_.id=comments1_.review_id
            
Review(super=BaseEntity(createdAt=null, updatedAt=null), id=1, title=null, content=null, score=5.0, comments=[Comment(super=BaseEntity(createdAt=null, updatedAt=null), id=1, comment=null), Comment(super=BaseEntity(createdAt=null, updatedAt=null), id=3, comment=null)])
Review(super=BaseEntity(createdAt=null, updatedAt=null), id=2, title=null, content=null, score=5.0, comments=[Comment(super=BaseEntity(createdAt=null, updatedAt=null), id=2, comment=null)])
Review(super=BaseEntity(createdAt=null, updatedAt=null), id=1, title=null, content=null, score=5.0, comments=[Comment(super=BaseEntity(createdAt=null, updatedAt=null), id=1, comment=null), Comment(super=BaseEntity(createdAt=null, updatedAt=null), id=3, comment=null)])

 

 join fetch 조회 시, Reivew 로우 갯수는 DB에서 2개밖에 없지만,  콘솔 에서는 결과가 3개로 나옵니다. review와 comment가 조인하면서 뻥튀기가 됐기 때문입니다. 이는 Review : Comment가 1 : N 관계에 있는 경우, From 절에 Review 기준으로 조회할 때 발생합니다. Comment와 inner join을 하는 과정에서 중복되는 데이터가 모두 조회됐습니다.

 

(반대로, Comment를 기준으로 조회하면, 연관관계가 1인 Reivew는 어차피 1개밖에 없기 때문에 뻥튀기의 문제가 없습니다.)

 

 

뻥튀기의 이유는 join fetch하는 과정에서, Comment의 id가 1,3인 행이 Review_id를 1로 가지고 있는데 중복 조회되기 때문입니다. 따라서 Review 테이블에 ID가 1인 행이 2번 중복해서 결과를 리턴합니다.

 

이 때 distinct를 사용하면 중복된 것을 걸러 줄 수 있습니다. 일반 SQL에서 distinct는 모든 column 값이 같아야만 제외되는 것과는 달리, JPQL에서 distinct는 중복되는 엔티티를 제거해 주기 때문입니다.

 

public interface BookRepository extends JpaRepository<Book, Long> { 
	@Query("select distinct r from Review r join fetch r.comments")
	List<Review> findAllByYFetchJoin();
}

 

 

2. EntityGraph


@EntityGraph(attributePaths = "comments")
@Query("select r FROM Review r")
List<Review> findAllByEntityGraph();

 

Hibernate: 
    select
        review0_.id as id1_8_0_,
        comments1_.id as id1_4_1_,
        review0_.created_at as created_2_8_0_,
        review0_.updated_at as updated_3_8_0_,
        review0_.book_id as book_id7_8_0_,
        review0_.content as content4_8_0_,
        review0_.score as score5_8_0_,
        review0_.title as title6_8_0_,
        review0_.user_id as user_id8_8_0_,
        comments1_.created_at as created_2_4_1_,
        comments1_.updated_at as updated_3_4_1_,
        comments1_.comment as comment4_4_1_,
        comments1_.review_id as review_i5_4_1_,
        comments1_.review_id as review_i5_4_0__,
        comments1_.id as id1_4_0__ 
    from
        review review0_ 
    left outer join
        comment comments1_ 
            on review0_.id=comments1_.review_id

 

comments의 갯수만큼 쿼리를 날리지 않고 처음 한번에 모든 내용을 조회합니다. left out join을 사용합니다.

 

 

 

public interface BookRepository extends JpaRepository<Book, Long> { 
	@EntityGraph(attributePaths = "comments")
	List<Review> findAll();
}

 

처음 EntityGraph 사용과 결과가 같습니다

 

 

오류 해결


could not initialize proxy - no Session

 

User 1 : N UserHistory

 

 세션이 없어서, 프록시를 초기화하지 못한다고 합니다. LAZY는 세션이 열려있을 때 적용이 가능합니다. 세션이 열려있다는 것은 영속성 컨텍스트가 엔티티를 관리하고 있다는 의미입니다.  따라서 no Session 오류가 난다면 EAGER 타입으로 바꾸거나, @Transactional로 트랜잭션을 열어주어야 한다. 

 

 

 

결론


 

* LAZY, EAGER 상관 없이 연관관계 엔티티를 getter로 조회하는 순간 N + 1 문제가 발생한다.

* N + 1 문제란, 처음 날린 1개 쿼리와, 쿼리를 날려 조회된 결과 N에 대해 N개를 추가로 조회해서 붙여진 이름이다.

* join fetch(inner join)과 entityGraph(left outer join)을 사용하면 처음 한번에 모든 쿼리를 조회하여 해결할 수 있다.

 

 

 

* 참고

한 번에 끝내는 Java/Spring 웹 개발 마스터 초격차 패키지

자바 ORM 표준 JPA 프로그래밍 - 기본편

반응형