[Spring + QueryDsl] DB 쿼리 시 발생하는 N+1 문제 해결 및 성능 개선

가오리·2024년 3월 20일
0

BackEnd

목록 보기
7/13

SurveyDocument

survey document ERD

  • 현재 모든 엔티티 연관 관계 매핑에서 fetch = FetchType.LAZY 로 설정해 놓았다.

현재 연관관계

survey_document

  • (OneToOne) Design
  • (OneToOne) DateManagement
  • (OneToMany) question_document
    • (OneToMany) Choice
    • (OneToMany) wordCloud

설문 조회 DB 쿼리 - fetchJoin, batchSize

밑에 있는 조회 쿼리는 밑의 구성으로 된 정보를 조회할 때 생긴 쿼리이다.

  • suveydocument #1

    • questiondocument #1
      • choice #1
      • wordCloud #1
      • choice #2
      • wordCloud #2
    • questiondocument #2
      • choice #3
      • wordCloud #3
      • choice #4
      • wordCloud #4
  • 조회 쿼리

Hibernate: 
    select
        s1_0.survey_document_id,
        s1_0.accept_response,
        s1_0.answer_count,
        d1_0.date_id,
        d1_0.survey_deadline,
        d1_0.survey_enable,
        d1_0.survey_start_date,
        s1_0.survey_description,
        d2_0.design_id,
        d2_0.back_color,
        d2_0.font,
        d2_0.font_size,
        s1_0.is_deleted,
        s1_0.reliability,
        s1_0.survey_title,
        s1_0.survey_type,
        s1_0.user_id 
    from
        survey_document s1_0 
    left join
        date_management d1_0 
            on s1_0.survey_document_id=d1_0.survey_document_id 
    left join
        design d2_0 
            on s1_0.survey_document_id=d2_0.survey_document_id 
    where
        s1_0.survey_document_id=? 
        and (
            s1_0.is_deleted = 0
        )
2023-10-18T00:32:17.373+09:00  INFO 3496 --- [nio-8082-exec-3] p6spy                                    : #1697556737373 | took 13ms | statement | connection 6| url jdbc:mysql://localhost:3306/surveydb
select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d1_0.date_id,d1_0.survey_deadline,d1_0.survey_enable,d1_0.survey_start_date,s1_0.survey_description,d2_0.design_id,d2_0.back_color,d2_0.font,d2_0.font_size,s1_0.is_deleted,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join date_management d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join design d2_0 on s1_0.survey_document_id=d2_0.survey_document_id where s1_0.survey_document_id=? and (s1_0.is_deleted = 0)
select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d1_0.date_id,d1_0.survey_deadline,d1_0.survey_enable,d1_0.survey_start_date,s1_0.survey_description,d2_0.design_id,d2_0.back_color,d2_0.font,d2_0.font_size,s1_0.is_deleted,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join date_management d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join design d2_0 on s1_0.survey_document_id=d2_0.survey_document_id where s1_0.survey_document_id=4 and (s1_0.is_deleted = 0);
2023-10-18T00:32:17.395+09:00  INFO 3496 --- [nio-8082-exec-3] p6spy                                    : #1697556737395 | took 0ms | commit | connection 6| url jdbc:mysql://localhost:3306/surveydb

;
Hibernate: 
    select
        q1_0.survey_document_id,
        q1_0.question_document_id,
        q1_0.is_deleted,
        q1_0.question_type,
        q1_0.question_title 
    from
        question_document q1_0 
    where
        q1_0.survey_document_id=? 
        and (
            q1_0.is_deleted = 0
        ) 
2023-10-18T00:32:17.405+09:00  INFO 3496 --- [nio-8082-exec-3] p6spy                                    : #1697556737405 | took 1ms | statement | connection 6| url jdbc:mysql://localhost:3306/surveydb
select q1_0.survey_document_id,q1_0.question_document_id,q1_0.is_deleted,q1_0.question_type,q1_0.question_title from question_document q1_0 where q1_0.survey_document_id=? and (q1_0.is_deleted = 0) 
select q1_0.survey_document_id,q1_0.question_document_id,q1_0.is_deleted,q1_0.question_type,q1_0.question_title from question_document q1_0 where q1_0.survey_document_id=4 and (q1_0.is_deleted = 0) ;
Hibernate: 
    select
        c1_0.question_id,
        c1_0.choice_id,
        c1_0.choice_count,
        c1_0.is_deleted,
        c1_0.choice_title 
    from
        choice c1_0 
    where
        c1_0.question_id=? 
        and (
            c1_0.is_deleted = 0
        ) 
2023-10-18T00:32:17.409+09:00  INFO 3496 --- [nio-8082-exec-3] p6spy                                    : #1697556737409 | took 0ms | statement | connection 6| url jdbc:mysql://localhost:3306/surveydb
select c1_0.question_id,c1_0.choice_id,c1_0.choice_count,c1_0.is_deleted,c1_0.choice_title from choice c1_0 where c1_0.question_id=? and (c1_0.is_deleted = 0) 
select c1_0.question_id,c1_0.choice_id,c1_0.choice_count,c1_0.is_deleted,c1_0.choice_title from choice c1_0 where c1_0.question_id=14 and (c1_0.is_deleted = 0) ;
Hibernate: 
    select
        w1_0.question_id,
        w1_0.word_cloud_id,
        w1_0.word_cloud_count,
        w1_0.is_deleted,
        w1_0.word_cloud_title 
    from
        word_cloud w1_0 
    where
        w1_0.question_id=? 
        and (
            w1_0.is_deleted = 0
        ) 
2023-10-18T00:32:17.412+09:00  INFO 3496 --- [nio-8082-exec-3] p6spy                                    : #1697556737412 | took 1ms | statement | connection 6| url jdbc:mysql://localhost:3306/surveydb
select w1_0.question_id,w1_0.word_cloud_id,w1_0.word_cloud_count,w1_0.is_deleted,w1_0.word_cloud_title from word_cloud w1_0 where w1_0.question_id=? and (w1_0.is_deleted = 0) 
select w1_0.question_id,w1_0.word_cloud_id,w1_0.word_cloud_count,w1_0.is_deleted,w1_0.word_cloud_title from word_cloud w1_0 where w1_0.question_id=14 and (w1_0.is_deleted = 0) ;
Hibernate: 
    select
        c1_0.question_id,
        c1_0.choice_id,
        c1_0.choice_count,
        c1_0.is_deleted,
        c1_0.choice_title 
    from
        choice c1_0 
    where
        c1_0.question_id=? 
        and (
            c1_0.is_deleted = 0
        ) 
2023-10-18T00:32:17.414+09:00  INFO 3496 --- [nio-8082-exec-3] p6spy                                    : #1697556737414 | took 0ms | statement | connection 6| url jdbc:mysql://localhost:3306/surveydb
select c1_0.question_id,c1_0.choice_id,c1_0.choice_count,c1_0.is_deleted,c1_0.choice_title from choice c1_0 where c1_0.question_id=? and (c1_0.is_deleted = 0) 
select c1_0.question_id,c1_0.choice_id,c1_0.choice_count,c1_0.is_deleted,c1_0.choice_title from choice c1_0 where c1_0.question_id=15 and (c1_0.is_deleted = 0) ;
Hibernate: 
    select
        w1_0.question_id,
        w1_0.word_cloud_id,
        w1_0.word_cloud_count,
        w1_0.is_deleted,
        w1_0.word_cloud_title 
    from
        word_cloud w1_0 
    where
        w1_0.question_id=? 
        and (
            w1_0.is_deleted = 0
        ) 
2023-10-18T00:32:17.416+09:00  INFO 3496 --- [nio-8082-exec-3] p6spy                                    : #1697556737416 | took 0ms | statement | connection 6| url jdbc:mysql://localhost:3306/surveydb
select w1_0.question_id,w1_0.word_cloud_id,w1_0.word_cloud_count,w1_0.is_deleted,w1_0.word_cloud_title from word_cloud w1_0 where w1_0.question_id=? and (w1_0.is_deleted = 0) 
select w1_0.question_id,w1_0.word_cloud_id,w1_0.word_cloud_count,w1_0.is_deleted,w1_0.word_cloud_title from word_cloud w1_0 where w1_0.question_id=15 and (w1_0.is_deleted = 0) ;
2023-10-18T00:32:17.424+09:00  INFO 3496 --- [nio-8082-exec-3] c.e.s.s.service.SurveyDocumentService    : SurveyDetailDto(id=4, title=test2, description=test2, countAnswer=0, questionList=[QuestionDetailDto(id=14, title=test, questionType=1, choiceList=[ChoiceDetailDto(id=15, title=test1, count=0), ChoiceDetailDto(id=16, title=test2, count=0)], wordCloudDtos=[]), QuestionDetailDto(id=15, title=test, questionType=1, choiceList=[ChoiceDetailDto(id=17, title=test3, count=0), ChoiceDetailDto(id=18, title=test4, count=0)], wordCloudDtos=[])], reliability=true, startDate=2023-10-02 00:27:17.1, endDate=2023-10-02 00:27:17.1, enable=true, design=DesignResponseDto(font=font, fontSize=0, backColor=font))
  1. 조회 쿼리에서 SurveyDocument@OneToOne 관계인 Design 과 DateMangaementleft join 해서 가져 온다.

  2. SurveyDocument에 연관된 @OneToMany 관계인 QuestionDocument 들을 가져올 때 지연 로딩이 발생하여 다시 쿼리를 날려서 가져온다.

  3. QuestionDocument1@OneToMany 관계로 연관된 Choice 를 가져올 때도 지연 로딩이 발생하여 다시 쿼리를 날려서 가져온다.

  4. QuestionDocument1@OneToMany 관계로 연관된 wordCloud 를 가져올 때도 지연 로딩이 발생하여 다시 쿼리를 날려서 가져온다.

  5. QuestionDocument2@OneToMany 관계로 연관된 Choice 를 가져올 때도 지연 로딩이 발생하여 다시 쿼리를 날려서 가져온다.

  6. QuestionDocument2@OneToMany 관계로 연관된 wordCloud 를 가져올 때도 지연 로딩이 발생하여 다시 쿼리를 날려서 가져온다.

즉, N + 1 문제가 발생하고 있다.

N + 1 문제는 이름에서 알 수 있듯이, 먼저 1의 쿼리가 실행되어 주 엔티티를 조회하고, 그 후에 각각의 하위 엔티티를 조회하기 위해 "N"의 추가 쿼리가 실행되는 패턴을 말합니다. 이 경우 N은 하위 엔티티의 수에 해당하며, 따라서 하위 엔티티의 수가 많을수록 데이터베이스에 부담을 줍니다.)

  • 설문 조회 쿼리는 지정된 idsurveydocument에 관한 모든 칼럼을 조회해야 한다.

fetch join을 통해서 컬렉션 조회 최적화를 해야한다.

  • queryDsl 을 이용한 쿼리 작성
queryFactory
  .selectFrom(surveyDocument)
  .join(surveyDocument.questionDocumentList, questionDocument).fetchJoin()
  .leftJoin(questionDocument.choiceList, choice).fetchJoin()
  .fetchOne();
  • 이때 MultipleBagFetchException 이 발생하였다. (Fetch Join은 xx**ToOne은 여러 개 적용이 가능하지만, xx**ToMany와 같이 1:N 의 관계에서 N 에 대해서는 여러 개 사용할 수 없다.)

  • application.propertiesbatch size 설정 추가 (Hibernate는 한 번에 지정된 배치 크기만큼의 엔티티를 데이터베이스에서 가져오게 됩니다.)

spring.jpa.properties.hibernate.default_batch_fetch_size=1000
  • 그 이후 in 절이 사용되며 쿼리 수를 줄였다. (6번→4번)
  • 쿼리
Hibernate: 
    select
        s1_0.survey_document_id,
        s1_0.accept_response,
        s1_0.answer_count,
        d1_0.date_id,
        d1_0.survey_deadline,
        d1_0.survey_enable,
        d1_0.survey_start_date,
        s1_0.survey_description,
        d2_0.design_id,
        d2_0.back_color,
        d2_0.font,
        d2_0.font_size,
        s1_0.is_deleted,
        s1_0.reliability,
        s1_0.survey_title,
        s1_0.survey_type,
        s1_0.user_id 
    from
        survey_document s1_0 
    left join
        date_management d1_0 
            on s1_0.survey_document_id=d1_0.survey_document_id 
    left join
        design d2_0 
            on s1_0.survey_document_id=d2_0.survey_document_id 
    where
        s1_0.survey_document_id=? 
        and (
            s1_0.is_deleted = 0
        )
2023-10-18T00:30:14.584+09:00  INFO 20032 --- [io-8082-exec-10] p6spy                                    : #1697556614584 | took 0ms | statement | connection 22| url jdbc:mysql://localhost:3306/surveydb
select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d1_0.date_id,d1_0.survey_deadline,d1_0.survey_enable,d1_0.survey_start_date,s1_0.survey_description,d2_0.design_id,d2_0.back_color,d2_0.font,d2_0.font_size,s1_0.is_deleted,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join date_management d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join design d2_0 on s1_0.survey_document_id=d2_0.survey_document_id where s1_0.survey_document_id=? and (s1_0.is_deleted = 0)
select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d1_0.date_id,d1_0.survey_deadline,d1_0.survey_enable,d1_0.survey_start_date,s1_0.survey_description,d2_0.design_id,d2_0.back_color,d2_0.font,d2_0.font_size,s1_0.is_deleted,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join date_management d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join design d2_0 on s1_0.survey_document_id=d2_0.survey_document_id where s1_0.survey_document_id=4 and (s1_0.is_deleted = 0);
2023-10-18T00:30:14.585+09:00  INFO 20032 --- [io-8082-exec-10] p6spy                                    : #1697556614585 | took 0ms | commit | connection 22| url jdbc:mysql://localhost:3306/surveydb

;
Hibernate: 
    select
        q1_0.survey_document_id,
        q1_0.question_document_id,
        q1_0.is_deleted,
        q1_0.question_type,
        q1_0.question_title 
    from
        question_document q1_0 
    where
        q1_0.survey_document_id=? 
        and (
            q1_0.is_deleted = 0
        ) 
2023-10-18T00:30:14.587+09:00  INFO 20032 --- [io-8082-exec-10] p6spy                                    : #1697556614587 | took 0ms | statement | connection 22| url jdbc:mysql://localhost:3306/surveydb
select q1_0.survey_document_id,q1_0.question_document_id,q1_0.is_deleted,q1_0.question_type,q1_0.question_title from question_document q1_0 where q1_0.survey_document_id=? and (q1_0.is_deleted = 0) 
select q1_0.survey_document_id,q1_0.question_document_id,q1_0.is_deleted,q1_0.question_type,q1_0.question_title from question_document q1_0 where q1_0.survey_document_id=4 and (q1_0.is_deleted = 0) ;
Hibernate: 
    select
        c1_0.question_id,
        c1_0.choice_id,
        c1_0.choice_count,
        c1_0.is_deleted,
        c1_0.choice_title 
    from
        choice c1_0 
    where
        c1_0.question_id in(?,?) 
        and (
            c1_0.is_deleted = 0
        ) 
2023-10-18T00:30:14.591+09:00  INFO 20032 --- [io-8082-exec-10] p6spy                                    : #1697556614591 | took 0ms | statement | connection 22| url jdbc:mysql://localhost:3306/surveydb
select c1_0.question_id,c1_0.choice_id,c1_0.choice_count,c1_0.is_deleted,c1_0.choice_title from choice c1_0 where c1_0.question_id in(?,?) and (c1_0.is_deleted = 0) 
select c1_0.question_id,c1_0.choice_id,c1_0.choice_count,c1_0.is_deleted,c1_0.choice_title from choice c1_0 where c1_0.question_id in(14,15) and (c1_0.is_deleted = 0) ;
Hibernate: 
    select
        w1_0.question_id,
        w1_0.word_cloud_id,
        w1_0.word_cloud_count,
        w1_0.is_deleted,
        w1_0.word_cloud_title 
    from
        word_cloud w1_0 
    where
        w1_0.question_id in(?,?) 
        and (
            w1_0.is_deleted = 0
        ) 
2023-10-18T00:30:14.601+09:00  INFO 20032 --- [io-8082-exec-10] p6spy                                    : #1697556614601 | took 8ms | statement | connection 22| url jdbc:mysql://localhost:3306/surveydb
select w1_0.question_id,w1_0.word_cloud_id,w1_0.word_cloud_count,w1_0.is_deleted,w1_0.word_cloud_title from word_cloud w1_0 where w1_0.question_id in(?,?) and (w1_0.is_deleted = 0) 
select w1_0.question_id,w1_0.word_cloud_id,w1_0.word_cloud_count,w1_0.is_deleted,w1_0.word_cloud_title from word_cloud w1_0 where w1_0.question_id in(14,15) and (w1_0.is_deleted = 0) ;
2023-10-18T00:30:14.601+09:00  INFO 20032 --- [io-8082-exec-10] c.e.s.s.service.SurveyDocumentService    : SurveyDetailDto(id=4, title=test2, description=test2, countAnswer=0, questionList=[QuestionDetailDto(id=14, title=test, questionType=1, choiceList=[ChoiceDetailDto(id=15, title=test1, count=0), ChoiceDetailDto(id=16, title=test2, count=0)], wordCloudDtos=[]), QuestionDetailDto(id=15, title=test, questionType=1, choiceList=[ChoiceDetailDto(id=17, title=test3, count=0), ChoiceDetailDto(id=18, title=test4, count=0)], wordCloudDtos=[])], reliability=true, startDate=2023-10-02 00:27:17.1, endDate=2023-10-02 00:27:17.1, enable=true, design=DesignResponseDto(font=font, fontSize=0, backColor=font))

  • N + 1 문제가 완화되었으며 성능 향상도 있다.
    • 데이터의 크기가 작아서 1.43 배 정도 속도가 빨라졌지만
    • 데이터의 크기가 커지면 효과는 더 커질 것이다.

fetch join을 1:N 관계에 대해 하나만 사용할 수 있다면(@xxToMany) 쿼리를 두 개로 나누는 방법은 얼마나 최적화 될 지 궁금하였다.

  • surveydocument와 연관된 question들을 fetch join으로 가져오는 쿼리 하나
  • 가져온 question들에 대해서 choice 들을 fetch join으로 가져오는 쿼리 하나
  • 총 2 개로 나누었다.
@Override
public SurveyDocument findSurveyById(Long surveyDocumentId) {
    SurveyDocument survey = queryFactory
            .selectFrom(surveyDocument)
            .leftJoin(surveyDocument.design, design).fetchJoin()
            .leftJoin(surveyDocument.date, dateManagement).fetchJoin()
            .leftJoin(surveyDocument.questionDocumentList, questionDocument).fetchJoin()
            .where(surveyDocument.id.eq(surveyDocumentId))
            .fetchOne();

    if (survey != null && survey.getQuestionDocumentList() != null) {
        List<QuestionDocument> questionDocuments = queryFactory
                .selectFrom(questionDocument)
                .leftJoin(questionDocument.choiceList, choice).fetchJoin()
                .where(questionDocument.in(survey.getQuestionDocumentList()))
                .fetch();
    }

    return survey;
}
  • 그 결과로 쿼리는 총 3번이 나갔다
    • 쿼리
      Hibernate: 
          select
              s1_0.survey_document_id,
              s1_0.accept_response,
              s1_0.answer_count,
              d2_0.date_id,
              d2_0.survey_deadline,
              d2_0.survey_enable,
              d2_0.survey_start_date,
              s1_0.survey_description,
              d1_0.design_id,
              d1_0.back_color,
              d1_0.font,
              d1_0.font_size,
              s1_0.is_deleted,
              q1_0.survey_document_id,
              q1_0.question_document_id,
              q1_0.is_deleted,
              q1_0.question_type,
              q1_0.question_title,
              s1_0.reliability,
              s1_0.survey_title,
              s1_0.survey_type,
              s1_0.user_id 
          from
              survey_document s1_0 
          left join
              design d1_0 
                  on s1_0.survey_document_id=d1_0.survey_document_id 
          left join
              date_management d2_0 
                  on s1_0.survey_document_id=d2_0.survey_document_id 
          left join
              question_document q1_0 
                  on s1_0.survey_document_id=q1_0.survey_document_id 
                  and (
                      q1_0.is_deleted = 0
                  )  
                  and (
                      q1_0.is_deleted = 0
                  )  
          where
              (
                  s1_0.is_deleted = 0
              ) 
              and s1_0.survey_document_id=?
      2023-10-18T01:02:28.500+09:00  INFO 12756 --- [io-8082-exec-10] p6spy                                    : #1697558548500 | took 0ms | statement | connection 9| url jdbc:mysql://localhost:3306/surveydb
      select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d2_0.date_id,d2_0.survey_deadline,d2_0.survey_enable,d2_0.survey_start_date,s1_0.survey_description,d1_0.design_id,d1_0.back_color,d1_0.font,d1_0.font_size,s1_0.is_deleted,q1_0.survey_document_id,q1_0.question_document_id,q1_0.is_deleted,q1_0.question_type,q1_0.question_title,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join design d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join date_management d2_0 on s1_0.survey_document_id=d2_0.survey_document_id left join question_document q1_0 on s1_0.survey_document_id=q1_0.survey_document_id and (q1_0.is_deleted = 0)  and (q1_0.is_deleted = 0)  where (s1_0.is_deleted = 0) and s1_0.survey_document_id=?
      select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d2_0.date_id,d2_0.survey_deadline,d2_0.survey_enable,d2_0.survey_start_date,s1_0.survey_description,d1_0.design_id,d1_0.back_color,d1_0.font,d1_0.font_size,s1_0.is_deleted,q1_0.survey_document_id,q1_0.question_document_id,q1_0.is_deleted,q1_0.question_type,q1_0.question_title,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join design d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join date_management d2_0 on s1_0.survey_document_id=d2_0.survey_document_id left join question_document q1_0 on s1_0.survey_document_id=q1_0.survey_document_id and (q1_0.is_deleted = 0)  and (q1_0.is_deleted = 0)  where (s1_0.is_deleted = 0) and s1_0.survey_document_id=4;
      Hibernate: 
          select
              q1_0.question_document_id,
              c1_0.question_id,
              c1_0.choice_id,
              c1_0.choice_count,
              c1_0.is_deleted,
              c1_0.choice_title,
              q1_0.is_deleted,
              q1_0.question_type,
              q1_0.survey_document_id,
              q1_0.question_title 
          from
              question_document q1_0 
          left join
              choice c1_0 
                  on q1_0.question_document_id=c1_0.question_id 
                  and (
                      c1_0.is_deleted = 0
                  )  
                  and (
                      c1_0.is_deleted = 0
                  )  
          where
              (
                  q1_0.is_deleted = 0
              ) 
              and q1_0.question_document_id in(?,?)
      2023-10-18T01:02:28.505+09:00  INFO 12756 --- [io-8082-exec-10] p6spy                                    : #1697558548505 | took 1ms | statement | connection 9| url jdbc:mysql://localhost:3306/surveydb
      select q1_0.question_document_id,c1_0.question_id,c1_0.choice_id,c1_0.choice_count,c1_0.is_deleted,c1_0.choice_title,q1_0.is_deleted,q1_0.question_type,q1_0.survey_document_id,q1_0.question_title from question_document q1_0 left join choice c1_0 on q1_0.question_document_id=c1_0.question_id and (c1_0.is_deleted = 0)  and (c1_0.is_deleted = 0)  where (q1_0.is_deleted = 0) and q1_0.question_document_id in(?,?)
      select q1_0.question_document_id,c1_0.question_id,c1_0.choice_id,c1_0.choice_count,c1_0.is_deleted,c1_0.choice_title,q1_0.is_deleted,q1_0.question_type,q1_0.survey_document_id,q1_0.question_title from question_document q1_0 left join choice c1_0 on q1_0.question_document_id=c1_0.question_id and (c1_0.is_deleted = 0)  and (c1_0.is_deleted = 0)  where (q1_0.is_deleted = 0) and q1_0.question_document_id in(14,15);
      Hibernate: 
          select
              w1_0.question_id,
              w1_0.word_cloud_id,
              w1_0.word_cloud_count,
              w1_0.is_deleted,
              w1_0.word_cloud_title 
          from
              word_cloud w1_0 
          where
              w1_0.question_id in(?,?) 
              and (
                  w1_0.is_deleted = 0
              ) 
      2023-10-18T01:02:28.510+09:00  INFO 12756 --- [io-8082-exec-10] p6spy                                    : #1697558548510 | took 1ms | statement | connection 9| url jdbc:mysql://localhost:3306/surveydb
      select w1_0.question_id,w1_0.word_cloud_id,w1_0.word_cloud_count,w1_0.is_deleted,w1_0.word_cloud_title from word_cloud w1_0 where w1_0.question_id in(?,?) and (w1_0.is_deleted = 0) 
      select w1_0.question_id,w1_0.word_cloud_id,w1_0.word_cloud_count,w1_0.is_deleted,w1_0.word_cloud_title from word_cloud w1_0 where w1_0.question_id in(14,15) and (w1_0.is_deleted = 0) ;

즉, 결론을 내리자면

  • hibernate.default_batch_fetch_size 를 글로벌 설정으로 사용해 N+1 문제를 최대한 in 쿼리로 기본적인 성능을 보장하게 한다.
  • @OneToOne, @ManyToOne과 같이 1 관계의 자식 엔티티에 대해서는 모두 Fetch Join을 적용하여 한방 쿼리를 수행한다.
  • @OneToMany, @ManyToMany와 같이 N 관계의 자식 엔티티에 관해서는 가장 데이터가 많은 자식쪽에 Fetch Join을 사용한다.
  • Fetch Join 이 없는 자식 엔티티에 관해서는 위에서 선언한 hibernate.default_batch_fetch_size 적용으로 100~1000개의 in 쿼리로 성능을 보장한다.

  • 코드 설명
@Override
public SurveyDocument findSurveyById(Long surveyDocumentId) {
    // SurveyDocument 조회 시 design, date에 대해서는 fetchJoin을 사용
    // questionDocumentList에 대해서도 fetchJoin을 사용하여 한 번의 쿼리로 로딩
    SurveyDocument survey = queryFactory
            .selectFrom(surveyDocument)
            .leftJoin(surveyDocument.design, design).fetchJoin()
            .leftJoin(surveyDocument.date, dateManagement).fetchJoin()
            .leftJoin(surveyDocument.questionDocumentList, questionDocument).fetchJoin()
            .where(surveyDocument.id.eq(surveyDocumentId))
            .fetchOne();

    if (survey != null && survey.getQuestionDocumentList() != null) {
        // questionDocumentList가 존재하는 경우, 해당 questionDocument의 choiceList를 fetchJoin으로 로딩
        // questionDocument에 대한 추가 쿼리 실행
        List<QuestionDocument> questionDocuments = queryFactory
                .selectFrom(questionDocument)
                .leftJoin(questionDocument.choiceList, choice).fetchJoin()
                // 여기서 wordCloud는 fetchJoin을 사용하지 않고, hibernate.default_batch_fetch_size 설정에 의존
                .where(questionDocument.in(survey.getQuestionDocumentList()))
                .fetch();
        
        // Hibernate의 default_batch_fetch_size 설정에 의존하여 wordCloud를 로딩
    }

    return survey;
}
  • 그 결과로
  • 처음 결과: 쿼리 총 6
  • batchsize:1000 적용: 쿼리 총 4
  • + fetch join 적용: 쿼리 총 3
  • 속도 측면에서 아주 눈에 띄는 차이는 아니지만 처음 결과에 비해 거의 2배 이상 상승한 결과이며 DB에 날리는 쿼리는 1/2로 줄어 들었다. 또한, 데이터의 크기가 커지면 커질 수록 효과는 더욱 더 증가할 것으로 보인다. (여기서는 question 2개, choice 4개인 아주 작은 크기의 설문으로 테스트 하였다)

OneToMany 관계의 entity를 Querydsl로 조회할 때 fetchjoin을 사용하면 데이터가 중복되어 조회될 수 있다.

  • distinct를 추가하여 중복된 row를 제거할 수 있다. 이는 SQL 수준에서는 중복된 로우를 제거하지만, 실제로는 데이터베이스에서 모든 중복된 결과를 가져온 후, 애플리케이션 메모리 내에서 엔티티의 중복을 제거하기 때문에 완전히 중복 조회를 피한다고는 할 수 없다.
    @Override
    public Optional<SurveyDocument> findSurveyById(Long surveyDocumentId) {
        SurveyDocument survey = queryFactory
                .selectFrom(surveyDocument)
                .leftJoin(surveyDocument.design, design).fetchJoin()
                .leftJoin(surveyDocument.date, dateManagement).fetchJoin()
                .leftJoin(surveyDocument.questionDocumentList, questionDocument).fetchJoin()
                .where(surveyDocument.id.eq(surveyDocumentId))
                .distinct()
                .fetchOne();

        if (survey != null && survey.getQuestionDocumentList() != null) {
            List<QuestionDocument> questionDocuments = queryFactory
                    .selectFrom(questionDocument)
                    .leftJoin(questionDocument.choiceList, choice).fetchJoin()
                    .where(questionDocument.in(survey.getQuestionDocumentList()))
                    .distinct()
                    .fetch();
        }

        return Optional.ofNullable(survey);
    }

설문 관리 조회 DTO로 바로 조회 - @QueryProjection

  • 설문 관리 조회에서 필요한 데이터는 단 3개이다.
{
    "startDate": "2023-10-01T15:27:17.100+00:00",
    "endDate": "2023-10-01T15:27:17.100+00:00",
    "enable": true
}
  • 하지만 사용되는 쿼리는 survey의 모든 정보를 쿼리하고 있다
Hibernate: 
    select
        s1_0.survey_document_id,
        s1_0.accept_response,
        s1_0.answer_count,
        d1_0.date_id,
        d1_0.survey_deadline,
        d1_0.survey_enable,
        d1_0.survey_start_date,
        s1_0.survey_description,
        d2_0.design_id,
        d2_0.back_color,
        d2_0.font,
        d2_0.font_size,
        s1_0.is_deleted,
        s1_0.reliability,
        s1_0.survey_title,
        s1_0.survey_type,
        s1_0.user_id 
    from
        survey_document s1_0 
    left join
        date_management d1_0 
            on s1_0.survey_document_id=d1_0.survey_document_id 
    left join
        design d2_0 
            on s1_0.survey_document_id=d2_0.survey_document_id 
    where
        s1_0.survey_document_id=? 
        and (
            s1_0.is_deleted = 0
        )
2023-10-18T01:53:55.051+09:00  INFO 19692 --- [io-8082-exec-10] p6spy                                    : #1697561635051 | took 1ms | statement | connection 22| url jdbc:mysql://localhost:3306/surveydb
select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d1_0.date_id,d1_0.survey_deadline,d1_0.survey_enable,d1_0.survey_start_date,s1_0.survey_description,d2_0.design_id,d2_0.back_color,d2_0.font,d2_0.font_size,s1_0.is_deleted,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join date_management d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join design d2_0 on s1_0.survey_document_id=d2_0.survey_document_id where s1_0.survey_document_id=? and (s1_0.is_deleted = 0)
select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d1_0.date_id,d1_0.survey_deadline,d1_0.survey_enable,d1_0.survey_start_date,s1_0.survey_description,d2_0.design_id,d2_0.back_color,d2_0.font,d2_0.font_size,s1_0.is_deleted,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join date_management d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join design d2_0 on s1_0.survey_document_id=d2_0.survey_document_id where s1_0.survey_document_id=4 and (s1_0.is_deleted = 0);
2023-10-18T01:53:55.067+09:00  INFO 19692 --- [io-8082-exec-10] p6spy
  • 사용되는 코드가 db에서 findById로 설문을 조회하고 거기서 필요한 데이터 3개를 뽑아서 DTO 에 넣어서 반환하고 있었다.

  • 이 코드를 최적화 하기 위해 db에서 바로 dto로 조회하는 방법으로 수정할 것이다.

  • @QueryProjection 활용

  • 이 방법은 컴파일러로 타입을 체크할 수 있으므로 가장 안전한 방법이다.

  • 다만 DTOQueryDSL 어노테이션을 유지해야 하는 점과 DTO까지 Q 파일 을 생성해야 하는 단점이 있다.

  • QueryDsl 수정

@Override
public ManagementResponseDto findManageById(Long surveyDocumentId) {
    return queryFactory.select(new QManagementResponseDto(dateManagement.startDate, dateManagement.deadline, dateManagement.isEnabled))
            .from(dateManagement)
            .where(dateManagement.surveyDocument.id.eq(surveyDocumentId))
            .fetchOne();
}
  • 쿼리 결과
Hibernate: 
    select
        d1_0.survey_start_date,
        d1_0.survey_deadline,
        d1_0.survey_enable 
    from
        date_management d1_0 
    where
        d1_0.survey_document_id=?
  • 한 눈에 봐도 쿼리의 길이가 확연히 줄어든 것을 볼 수 있다.

설문 활성화/비활성화 수정 쿼리 - 직접 업데이트 쿼리

  • 설문의 활성/비활성화는 survey에 연결된 DateManagementis Enabled만 바꿔주면 된다.

  • 하지만 지금은 불필요하게 많은 데이터를 가져와서 update를 해주고 있다.

  • 보통 JPA 사용 시 엔티티를 먼저 조회(fetch)한 후, 해당 엔티티의 상태를 변경하고 트랜잭션 커밋 시점에서 변경 감지(dirty checking)를 통해 SQL Update문이 실행되는 방식을 사용한다.

  • 하지만 이 경우, 불필요하게 엔티티를 메모리에 로딩하는 상황이 발생할 수 있다. 특히, 엔티티가 복잡하거나 연관 관계가 많은 경우에는 성능 저하의 원인이 될 수 있다.

  • 수정 전 조회 쿼리

Hibernate: 
    select
        s1_0.survey_document_id,
        s1_0.accept_response,
        s1_0.answer_count,
        d1_0.date_id,
        d1_0.survey_deadline,
        d1_0.survey_enable,
        d1_0.survey_start_date,
        s1_0.survey_description,
        d2_0.design_id,
        d2_0.back_color,
        d2_0.font,
        d2_0.font_size,
        s1_0.is_deleted,
        s1_0.reliability,
        s1_0.survey_title,
        s1_0.survey_type,
        s1_0.user_id 
    from
        survey_document s1_0 
    left join
        date_management d1_0 
            on s1_0.survey_document_id=d1_0.survey_document_id 
    left join
        design d2_0 
            on s1_0.survey_document_id=d2_0.survey_document_id 
    where
        s1_0.survey_document_id=? 
        and (
            s1_0.is_deleted = 0
        )
2023-10-18T02:32:54.241+09:00  INFO 3588 --- [nio-8082-exec-1] p6spy                                    : #1697563974241 | took 1ms | statement | connection 9| url jdbc:mysql://localhost:3306/surveydb
select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d1_0.date_id,d1_0.survey_deadline,d1_0.survey_enable,d1_0.survey_start_date,s1_0.survey_description,d2_0.design_id,d2_0.back_color,d2_0.font,d2_0.font_size,s1_0.is_deleted,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join date_management d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join design d2_0 on s1_0.survey_document_id=d2_0.survey_document_id where s1_0.survey_document_id=? and (s1_0.is_deleted = 0)
select s1_0.survey_document_id,s1_0.accept_response,s1_0.answer_count,d1_0.date_id,d1_0.survey_deadline,d1_0.survey_enable,d1_0.survey_start_date,s1_0.survey_description,d2_0.design_id,d2_0.back_color,d2_0.font,d2_0.font_size,s1_0.is_deleted,s1_0.reliability,s1_0.survey_title,s1_0.survey_type,s1_0.user_id from survey_document s1_0 left join date_management d1_0 on s1_0.survey_document_id=d1_0.survey_document_id left join design d2_0 on s1_0.survey_document_id=d2_0.survey_document_id where s1_0.survey_document_id=4 and (s1_0.is_deleted = 0);
Hibernate: 
    update
        date_management 
    set
        survey_deadline=?,
        survey_enable=?,
        survey_start_date=?,
        survey_document_id=? 
    where
        date_id=?
2023-10-18T02:32:54.244+09:00  INFO 3588 --- [nio-8082-exec-1] p6spy                                    : #1697563974244 | took 1ms | statement | connection 9| url jdbc:mysql://localhost:3306/surveydb
update date_management set survey_deadline=?, survey_enable=?, survey_start_date=?, survey_document_id=? where date_id=?
update date_management set survey_deadline='2023-10-02T00:27:17.100+0900', survey_enable=true, survey_start_date='2023-10-02T00:27:17.100+0900', survey_document_id=4 where date_id=7;
2023-10-18T02:32:54.290+09:00  INFO 3588 --- [nio-8082-exec-1] p6spy                                    : #1697563974290 | took 44ms | commit | connection 9| url jdbc:mysql://localhost:3306/surveydb
  • Querydsl를 사용하여 데이터베이스에 직접적으로 업데이트 쿼리를 실행하도록 최적화를 해주었다.
@Override
@Transactional
public void updateManage(Long id, Boolean enable) {
    queryFactory.update(dateManagement)
            .where(dateManagement.surveyDocument.id.eq(id))
            .set(dateManagement.isEnabled, enable)
            .execute();
}
  • 수정 후 쿼리
Hibernate: 
    update
        date_management 
    set
        survey_enable=? 
    where
        survey_document_id=?
2023-10-18T02:45:33.646+09:00  INFO 16500 --- [nio-8082-exec-3] p6spy                                    : #1697564733646 | took 1ms | statement | connection 17| url jdbc:mysql://localhost:3306/surveydb
update date_management set survey_enable=? where survey_document_id=?
update date_management set survey_enable=true where survey_document_id=4;
  • 쿼리의 길이는 매우 많이 줄어들었다.
  • 더티 체크를 사용하지 않았으므로 select로 불필요한 정보를 가져올 필요도 없어졌다.
  • Querydslupdate 메서드를 사용하여, 필요한 엔티티의 상태만을 직접 변경하는 방식을 채택하였다.
  • 이는 불필요한 엔티티 로딩을 방지하고, 데이터베이스 차원에서 즉시 업데이트를 수행함으로써 성능을 향상시키는 효과가 있다.
profile
가오리의 개발 이야기

0개의 댓글