본문 바로가기

문제 해결, 기술 비교/개인프로젝트(북클럽)

페이징에서 JPA N +1 문제 해결하기

728x90
반응형

개요


 JPA는 간편하게 쿼리문을 사용할 수 있는 장점이 있지만 일대다 관계 혹은 EAGER, LAZY 조회 전략에 따라서 N + 1 문제가 발생합니다. 도메인을 그냥 조회하는 법, DTO로 변환해서 조회하는 법 등 다양한 방법들이 있는데, 이번에는 QueryDsl에서 페이징을 할 때 N + 1 문제를 방지하면서 쿼리문을 최소한으로 유지하기 위한 전략을 알아보겠습니다.

 

 

문제 상황


페이징을 이용해서 스터디를 100개씩 조회하도록 합니다.

 

 기존에는 Study M : N Account 관계를 가지고 있으므로, 중간 테이블로 StudyLikes 엔티티를  가지며 Study 1 : N StudyLikes의 관계를 가집니다. 스터디를 조회하고 나면, 각 스터디의 스터디 좋아요(StudyLikes)를 순회하며, 내가 좋아요를 했는지의 여부에 따라서 하트가 칠해집니다. 따라서, 지연로딩으로 컬렉션을 조회하는데, 모든 스터디 좋아요(StudyLikes)를 조회하므로 N + 1 문제가 발생합니다.

 

 

 

기존의 쿼리문


스터디 리스트 조회 시, Study 도메인에 SutdyLikes 도메인을 left join 하여 조회했습니다. 그런데 문제는 Study와 StudyLikes는 1: N 관계를 가지고 있으므로 from절에서 study를 기준으로 조회하면 @OneToMany 형태로 조회하게 됩니다. 

 

기존의 쿼리문

 

 

 즉, LAZY 설정과는 관련없이 자연스럽게 100개의 스터디의 연관관계에 있는 스터디 좋아요(StudyLikes) 엔티티를 모두 조회하게 되며, 쿼리문이 N + 1 로 100개가 추가됩니다.

 

(만약에 StudyLikes에서 또다른 @OneToMany 연관관계를 가지는 칼럼이 조회된다면, N * M 개의 DB 쿼리문이 나갈 수 있는 가능성이 있습니다.)

 

 

아래는 실제 쿼리문의 결과입니다

 

2022-09-28T00:51:46,588 INFO  [http-nio-9059-exec-1] c.e.b.c.AspectLog: com.example.bookclub.controller.StudyController.studyOpenList
Hibernate: 
    select
        study0_.study_id as study_id1_14_,
        .... 
    from
        study study0_ 
    where
        study0_.study_state=? 
    order by
        study0_.study_id desc limit ? offset ?
Hibernate: 
    select
        count(study0_.study_id) as col_0_0_ 
    from
        study study0_ 
    where
        study0_.study_state=?
Hibernate: 
    select
        studylikes0_.study_id as study_id5_17_0_,
        studylikes0_.studylike_id as studylik1_17_0_,
        studylikes0_.studylike_id as studylik1_17_1_,
        studylikes0_.created_date as created_2_17_1_,
        studylikes0_.updated_date as updated_3_17_1_,
        studylikes0_.account_id as account_4_17_1_,
        studylikes0_.study_id as study_id5_17_1_ 
    from
        study_like studylikes0_ 
    where
        studylikes0_.study_id=?
Hibernate: 
    select
        studylikes0_.study_id as study_id5_17_0_,
        studylikes0_.studylike_id as studylik1_17_0_,
        studylikes0_.studylike_id as studylik1_17_1_,
        studylikes0_.created_date as created_2_17_1_,
        studylikes0_.updated_date as updated_3_17_1_,
        studylikes0_.account_id as account_4_17_1_,
        studylikes0_.study_id as study_id5_17_1_ 
    from
        study_like studylikes0_ 
    where
        studylikes0_.study_id=?
        
        
        .....
        
    
    (study_like 조회 100 번)

 

먼저 Study 리스트를 조회하므로, 100개의 스터디를 조회하기 위한 쿼리문이 나갑니다.

또한, 페이징을 계산하는 관계로 count 쿼리를 하나 날렸습니다. 

 

그리고 그 아래에는 스터디 좋아요(StudyLikes)가 100번 조회됩니다. N + 1 문제가 발생한 것입니다.

 

이를 해결할 수 있는 방법은 무엇일까요?

 

 

개선된 쿼리문(default_batch_fetch_size)


먼저 쿼리문에서 조인을 할 때는 2가지 규칙이 있습니다.

  • @ManyToOne은 join fetch 을 한다 ( 1개밖에 연관관계 로우가 없으므로 영향이 없다.)
  • @OneToMany의 컬렉션 조회는 지연로딩한다 (default_batch_fetch_size로 in 조회를 한다.)

 

@ManyToOne은 연관관계가 1개이므로 상관 없습니다. @OneToMany의 경우에는 default_batch_fetch_size 로 정한 수만큼 한번에 데이터를 끌어옵니다. 기본적으로 100개씩 조회를 한다고 하여, 100을 설정했습니다. 이제 페이징으로 특정 페이지를 이동하면, 100개를 한번에 가져옵니다. 

 

 

한번에 100개씩 조회

 

 설정파일에서 전역으로 100개를 설정했지만, @BatchSize를 사용해서 독립적으로 사이즈를 정할 수도 있습니다.

각 상황에 따라서 얼마나 조회하는지, 갑자기 너무 많은 DB를 조회하게 되어 성능상 이슈는 없는지 확인을 하고 적절하게 사이즈를 선택해야 합니다.

 

 

@OneToMany는 지연로딩 하면 되기 때문에 별도로 join을 할 필요가 없습니다.

 

개선된 쿼리문

 

조회 결과는 아래와 같습니다.

 

Hibernate: 
    select
        study0_.study_id as study_id1_14_,
        ...
    from
        study study0_ 
    where
        study0_.study_state=? 
    order by
        study0_.study_id desc limit ? offset ?
Hibernate: 
    select
        count(study0_.study_id) as col_0_0_ 
    from
        study study0_ 
    where
        study0_.study_state=?
Hibernate: 
    select
        studylikes0_.study_id as study_id5_17_1_,
        studylikes0_.studylike_id as studylik1_17_1_,
        studylikes0_.studylike_id as studylik1_17_0_,
        studylikes0_.created_date as created_2_17_0_,
        studylikes0_.updated_date as updated_3_17_0_,
        studylikes0_.account_id as account_4_17_0_,
        studylikes0_.study_id as study_id5_17_0_ 
    from
        study_like studylikes0_ 
    where
        studylikes0_.study_id in (
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
        )
Hibernate: 
    select
        count(study0_.study_id) as col_0_0_ 
    from
        study study0_ 
    where
        study0_.study_state=?

 

100개의 스터디를 조회할 때, 스터디 좋아요(StudyLikes)는 in절에서 한번에 조회합니다. 기존에는 1 + N으로 나가던 쿼리가, 이제 1 + 1으로 2번만에 조회가 되도록 개선되었습니다.

 

 

결론


페이징 조회 시, default_batch_fetch_size을 활용하면 in절을 통해 한번에 데이터를 조회할 수 있다

하지만, 성능상 이슈가 발생하지 않는 선에서 적정 제한을 두고 활용한다. 

 

참고


실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

728x90
반응형