프로젝트를 개선하며 불필요한 쿼리가 발생되는 부분을 발견하였습니다. Survey를 조회할 때 Perfume객체가 함께 조회되는 현상이었습니다. 면접준비하면서 가장 많이 들었던 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를 사용해서 문제를 해결해보았습니다. 다음번엔 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=?
네이티브 쿼리를 사용한 부분에서도 문제가 발생하였습니다.