인:향 N+1문제 해결기

김준석·2023년 12월 29일
0

향수 추천 서비스

목록 보기
20/21

프로젝트를 개선하며 불필요한 쿼리가 발생되는 부분을 발견하였습니다. Survey를 조회할 때 Perfume객체가 함께 조회되는 현상이었습니다. 면접준비하면서 가장 많이 들었던 N+1 문제였지만, 실제로 겪어본 적이 없었기에 해결하는 과정을 작성하였습니다.

N+1 문제란?

데이터를 1번 조회할때 N개의 데이터가 추가로 조회되는 현상입니다. 서로 연관관계가 있는 부모자식 엔티티 사이에서 데이터 조회시에 발생되는 현상입니다.
문제 상황에서는 Survey 조회시에 Perfume이 추가적으로 1개 조회되는 비교적 간단한 이슈였지만, 큰 서비스에서 N+1문제가 발생한다면 큰 성능저하를 일으킬 수 있습니다.

언제 발생하나?

두가지 상황이 있습니다.
즉시 로딩은 엔티티를 로드할 때 연관된 엔티티를 즉시 로드하는 방식입니다.

즉시 로딩

    @ManyToOne(fetch = FetchType.EAGER)
    private Perfume perfume;
    
    혹은
    @ManyToOne
    private Perfume perfume;

EAGER라고 명시적으로 설정하거나 Default 상태가 즉시로딩 상태입니다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity(name = "perfume_review_board")
@EntityListeners(AuditingEntityListener.class)
public class PerfumeReviewBoard {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "review_board_id", nullable = false)
    private Long boardId;

    @NotNull
    @CreatedDate
    private LocalDateTime createdDateTme;

    @NotNull
    @ManyToOne
    private Member writer;
    
    ... 중략

다음과 같이 설정되어 있을 때 게시글 조회를 하면 연관된 Member Enitity까지 즉시로딩 됩니다.

Hibernate: select perfumerev0.review_board_id as review_b1_5, perfumerev0.image_url as image_ur2_5, perfumerev0.text as text3_5, perfumerev0.created_date_tme as created_4_5, perfumerev0.like_count as like_cou5_5, perfumerev0.title as title6_5, perfumerev0.unlike_count as unlike_c7_5, perfumerev0.writer_member_id as writer_m8_5 from perfumereview_board perfumerev0 where perfumerev0.review_board_id=?
Hibernate: select member0
.memberid as member_i1_2_0, member0.email as email2_2_0, member0.kakao_id as kakao_id3_2_0, member0.nickname as nickname4_2_0, member0.thumbnail_image as thumbnai5_2_0 from member member0 where member0.member_id=?

즉시 로딩은 초기 데이터 로드시에 연관된 데이터를 함께 가져오기 때문에 초기 성능이 느릴 수 있습니다. 관련된 모든 데이터가 필요한 경우가 아니라면 성능과 메모리에 이슈가 발생할 수 있습니다.

지연 로딩

지연 로딩은 즉시 로딩과 달리 연관된 엔티티가 사용되는 시점에 로딩되는 방식입니다. 초기 로드시에 연관된 엔티티는 실제로 로드되지 않습니다.

    @NotNull
    @ManyToOne(fetch = FetchType.LAZY)
    private Member writer;

LazyLoading으로 설정한 채로 똑같이 게시글 조회를 해보았습니다.

Hibernate: select perfumerev0.review_board_id as review_b1_5, perfumerev0.image_url as image_ur2_5, perfumerev0.text as text3_5, perfumerev0.created_date_tme as created_4_5, perfumerev0.like_count as like_cou5_5, perfumerev0.title as title6_5, perfumerev0.unlike_count as unlike_c7_5, perfumerev0.writer_member_id as writer_m8_5 from perfumereview_board perfumerev0 where perfumerev0_.review_board_id=?

쿼리가 한번만 발생하는 것을 볼 수 있습니다.
여기서 만약에 자식 엔티티에 접근하는 로직이 추가된다면 추가적인 쿼리가 발생하게 됩니다.

지연 로딩은 실제로 데이터가 사용되기 전까지 로딩하지 않기 때문에 초기 로드시에 성능을 향상시킬 수 있습니다.

문제 상황

Survey 조회시에 Perfume까지 함께 로딩되는데, Survey 조회를 위한 쿼리와 Perfume 조회를 위한 쿼리 2개가 동시에 나가는 상황이었습니다.

이 문제를 해결하기 위해선 다음과 같은 방법들이 있습니다.

JOIN FETCH

  • 한번의 쿼리로 모든 데이터를 로드하여 성능을 향상시킬 수 있습니다.
  • 단 쿼리가 복잡해질 수 있고, 많은 양에 데이터를 한번에 로드하게 되어 메모리 사용량이 증가할 수 있습니다.
  • JPA가 제공하는 Pageable 기능 사용 불가(Pageable 사용 불가) → 페이징 단위로 데이터 가져오기 불가능

@EntityGraph

  • JPA에서 제공하는 기능으로, 특정 엔티티 로드할때 관련 엔티티를 함께 로드할 것인지 설정할 수 있습니다.
    Fetch Join은 특정 쿼리에 대해 최적화된 로딩 전략을 사용할 때 유용하고, Entity Grapth는 동적으로 로딩 전략을 변경하고 싶을 때 유용합니다.

해결

두가지 방법 다 사용해본 적이 없기에 이번에는 Join Fetch를 사용해서 문제를 해결해보았습니다. 다음번엔 EntityGraph를 사용해봐야겠습니다.

   @Query("SELECT s FROM survey s JOIN FETCH s.perfume WHERE s.surveyId = :surveyId")
    Optional<Survey> findBySurveyId(@Param("surveyId") Long surveyId);

    @Query("SELECT s FROM survey s JOIN FETCH s.perfume WHERE s.question.genderAnswer LIKE %:genderAnswer% AND s.question.scentAnswer = :scentAnswer AND s.question.moodAnswer LIKE %:moodAnswer% AND s.question.seasonAnswer LIKE %:seasonAnswer% AND s.question.styleAnswer LIKE %:styleAnswer%")
    List<Survey> findSurveysByAnswers(@Param("genderAnswer") String genderAnswer,
                                      @Param("scentAnswer") String scentAnswer, @Param("moodAnswer") String moodAnswer,
                                      @Param("seasonAnswer") String seasonAnswer,
                                      @Param("styleAnswer") String styleAnswer);

    @Query("SELECT s FROM survey s JOIN FETCH s.perfume WHERE s.question.genderAnswer LIKE %:genderAnswer% AND s.question.scentAnswer = :scentAnswer AND s.question.moodAnswer LIKE %:moodAnswer%")
    List<Survey> findSurveysByGenderScentAndMood(@Param("genderAnswer") String genderAnswer,
                                                 @Param("scentAnswer") String scentAnswer,
                                                 @Param("moodAnswer") String moodAnswer);

    @Query("SELECT s FROM survey s JOIN FETCH s.perfume WHERE s.question.genderAnswer LIKE %:genderAnswer% AND s.question.scentAnswer = :scentAnswer AND s.question.moodAnswer LIKE %:moodAnswer% AND s.question.styleAnswer LIKE %:styleAnswer%")
    List<Survey> findSurveysByGenderScentMoodAndStyle(@Param("genderAnswer") String genderAnswer,
                                                      @Param("scentAnswer") String scentAnswer,
                                                      @Param("moodAnswer") String moodAnswer,
                                                      @Param("styleAnswer") String styleAnswer);

이렇게 해줌으로써 Survey 조회시에 쿼리가 2번에서 1번만 발생되도록 개선되었습니다.

~~ inner join perfume perfume1 on survey0.perfumeperfume_id=perfume1.perfumeid where survey0.survey_id=?

문제 2 : 네이티브 쿼리?

네이티브 쿼리를 사용한 부분에서도 문제가 발생하였습니다.

profile
기록하면서 성장하기!

0개의 댓글