1. 문제 상황
이력서 관리 시스템을 개발하면서 다음과 같은 성능 이슈가 발생했다:
- 이력서 조회 시 연관된 섹션과 콘텐츠를 가져오는 과정에서 N+1 문제 발생
- 여러 개의
@OneToMany
연관관계에 대한 fetch join 시도 시 Hibernate의 MultipleBagFetchException 발생
도메인 구조
Resume (1) --- (*) ResumeSection (1) --- (*) SectionContent
2. 성능 테스트 결과
=== 단일 이력서 조회 성능 테스트 ===
기존 방식 실행 시간: 9.149166ms
기존 방식 쿼리 수: 3
분리 쿼리 실행 시간: 6.0795ms
분리 쿼리 수: 2
=== 회원별 이력서 목록 조회 성능 테스트 ===
기존 방식 실행 시간: 48.222042ms
기존 방식 쿼리 수: 3
분리 쿼리 실행 시간: 15.58375ms
분리 쿼리 수: 2
3. 해결 과정
3.1. Multiple Bag Fetch Exception 발생
처음에는 단순히 fetch join을 적용하려 했다:
@Query("SELECT DISTINCT r FROM Resume r " +
"LEFT JOIN FETCH r.sections s " +
"LEFT JOIN FETCH s.contents " +
"WHERE r.id = :resumeId")
이 접근 방식은 MultipleBagFetchException
예외를 발생시켰다.
3.2. 최종 해결안: 쿼리 분리
연관관계 조회를 두 단계로 분리하여 구현했다:
@Query("SELECT DISTINCT r FROM Resume r " +
"LEFT JOIN FETCH r.sections " +
"WHERE r.id = :resumeId")
Optional<Resume> findByIdWithSections(@Param("resumeId") Long resumeId);
@Query("SELECT DISTINCT s FROM ResumeSection s " +
"LEFT JOIN FETCH s.contents " +
"WHERE s.resume.id = :resumeId")
List<ResumeSection> findSectionsByResumeIdWithContents(@Param("resumeId") Long resumeId);
4. 개선된 코드의 특징
4.1. 리스트 일괄 조회 최적화
@Query("SELECT DISTINCT s FROM ResumeSection s " +
"LEFT JOIN FETCH s.contents " +
"WHERE s.resume.id IN :resumeIds")
List<ResumeSection> findSectionsByResumeIdsWithContents(@Param("resumeIds") List<Long> resumeIds);
4.2. 양방향 연관관계 관리
public void updateSections(List<ResumeSection> sections) {
this.sections.clear();
sections.forEach(section -> section.setResume(this));
this.sections.addAll(sections);
}
5. 결론 및 시사점
- N+1 문제 해결 전략
- 단순 fetch join으로는 해결할 수 없는 상황이 존재했다
- 쿼리 분리를 통한 단계적 데이터 로딩이 효과적이었다
- Hibernate의 제약사항 이해
- MultipleBagFetchException의 발생 원인과 해결 방법을 이해했다
- 연관관계 매핑 설계 시 고려사항을 파악했다
이번 최적화 경험을 통해 JPA의 N+1 문제를 해결하는 과정에서 단순히 fetch join을 적용하는 것을 넘어, 실제 서비스의 요구사항과 제약사항을 고려한 해결방안 도출의 중요성을 배웠다.