[쿼리 최적화 테스트 - 로컬환경] JPA LEFT JOIN FETCH vs @EntityGraph 성능 비교, 비용 절감 및 최적화 테스트
테스트 개요
1. 목표
- JPA를 이용한 취향 데이터 조회 성능 최적화
- N+1 문제 해결 및 쿼리 실행 시간 단축
- 최적화된 방식으로 TPS 증가 및 비용 절감 효과 분석
- @EntityGraph, LEFT JOIN FETCH, Batch Size 등을 활용하여 어떤 방식이 가장 효율적인 조회인지 확인
2. 목적
- N+1 문제 해결 : JPA의 Lazy Loading으로 인해 발생하는 다중 쿼리 문제 개선
- TPS 분석 : 최적화 기법 적용 전후의 트랜잭션 처리량 비교
- 쿼리 비용 절감
- 최종프로젝트 진행 시 외부 AI API를 연동하여 데이터를 분석하는 기능을 포함
- 매 쿼리 실행 시 AI API가 호출되며, 이는 AI 토큰을 소비하면서 비용이 발생
- 불필요한 API 호출을 줄이고 쿼리를 최적화하여 AI 사용 비용을 절감하는 것이 목적
- 최적화 전략 수립 : 다양한 기법을 조합하여 가장 성능이 뛰어난 방법을 도출
3. 테스트 환경
항목 | 사양 |
테스트 도구 | Apache JMeter |
테스트 기준 | 1000개 요청 기준 |
DBMS | MySQL |
서버 환경 | Spring Boot 기반 REST API |
적용 기술 | @EntityGraph, LEFT JOIN FETCH, Batch Size |
테스트 방식 | TPS 기반 성능 테스트 |
4. 테스트 시나리오
- 기본 코드 실행(Lazy Loading) : 최적화 적용 없이 Taste 데이터를 조회하여 기본 성능 측정
- @EntityGraph 적용 : @EntityGraph를 활용하여 Lazy Loading 문제를 해결
- LEFT JOIN FETCH(통합) + Batch Size : 하나의 쿼리로 통합하여 조회하고 Batch Size를 활용하여 최적화
- LEFT JOIN FETCH(분리) + Batch Size : LEFT JOIN FETCH를 개별적으로 실행하여 최적화 및 성능 향상 기대
테스트 결과
1. 테스트 결과 분석 및 비교
테스트 방식 | 최적화 기법 | 성능 결과 | 비용 | 주요 문제점 |
기본 코드 실행 (Lazy Loading) |
Lazy Loading (최적화 없음) |
가장 느림 | $0.25(최고 비용) | 다중 쿼리 발생 |
@EntityGraph 적용 | 연관 데이터 즉시 로딩 | 개선 미미 | $0.23 | 최적화 효과 부족 |
LEFT JOIN FETCH(통합) + Batch Size |
Taste 데이터를 하나의 쿼리로 통합 조회 | 테스트 실패 | 확인 불가 | FETCH JOIN 과다 사용 |
LEFT JOIN FETCH(분리) + Batch Size |
여러 개의 최적화된 쿼리로 조회 | 가장 빠름 | $0.15(최저 비용) | 최적의 성능 |
2. 세부 분석 및 그래프
*기본 코드(최적화 없음, Lazy Loading)
- Lazy Loading으로 인해 N+1 문제 발생
- 개별적으로 Taste를 조회하면서 불필요한 다중 쿼리 실행
- 성능이 가장 낮고, 비용은 가장 높음 -> $0.25


- 결론
- Lazy Loading을 그대로 사용할 경우, 쿼리 최적화가 반드시 필요
- 성능이 가장 낮고, AI API 호출 비용이 가장 큼
*@EntityGraph 적용
//5개의 중간테이블 Repository에 @EntityGraph 각각 적용
@EntityGraph(attributePaths = {"dietaryPreferences"})
List<TasteDietaryPreferences> findByMember(Member member);
- @EntityGraph를 활용하여 Lazy Loading을 일부 해결
- Taste 데이터를 조회하는 쿼리 개수는 줄었지만, TPS나 비용 절감의 효과는 크지 않음 -> $0.23


- 결론
- @EntityGraph만으로는 N+1 문제를 완전히 해결할 수 없음
- 추가적인 최적화 고려가 필요
*LEFT JOIN FETCH(통합) + Batch Size 적용
#application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
// MemberRepository
@Query("SELECT DISTINCT m FROM Member m " +
"LEFT JOIN FETCH m.tasteGenres tg LEFT JOIN FETCH tg.genres " +
"WHERE m.id = :id")
Optional<Member> findMemberWithTasteGenresById(@Param("id") Long id);
@Query("SELECT DISTINCT m FROM Member m " +
"LEFT JOIN FETCH m.tasteLikeFoods tl LEFT JOIN FETCH tl.likeFoods " +
"LEFT JOIN FETCH m.tasteDislikeFoods td LEFT JOIN FETCH td.dislikeFoods " +
"LEFT JOIN FETCH m.tasteDietaryPreferences tp LEFT JOIN FETCH tp.dietaryPreferences " +
"LEFT JOIN FETCH m.tasteSpicyLevels ts LEFT JOIN FETCH ts.spicyLevel " +
"WHERE m IN :member")
Optional<Member> findMemberWithOtherTastesByMembers(@Param("member") Member member);
- Hibernate의 Too many fetch joins 오류 발생
- @OneToMany 관계를 여러 개 JOIN FETCH 하면서 MultipleBagFetchException 발생
- 테스트가 정상적으로 진행되지 못함


- 결론
- 과도한 LEFT JOIN FETCH를 사용 시 Hibernate가 처리하지 못함
- 쿼리를 개별적으로 분리하여 실행시키는 방식을 사용해야 함
*LEFT JOIN FETCH(분리) + Batch Size 적용
//MemberRepository
@Query("SELECT DISTINCT m FROM Member m " +
"LEFT JOIN FETCH m.tasteGenres tg LEFT JOIN FETCH tg.genres " +
"WHERE m.id = :id")
Optional<Member> findMemberWithTasteGenresById(@Param("id") Long id);
@Query("SELECT DISTINCT m FROM Member m " +
"LEFT JOIN FETCH m.tasteLikeFoods tl LEFT JOIN FETCH tl.likeFoods " +
"WHERE m IN :member")
Optional<Member> findMemberWithTasteLikeFoodsByMember(@Param("member") Member member);
@Query("SELECT DISTINCT m FROM Member m " +
"LEFT JOIN FETCH m.tasteDislikeFoods td LEFT JOIN FETCH td.dislikeFoods " +
"WHERE m IN :member")
Optional<Member> findMemberWithTasteDislikeFoodsByMember(@Param("member") Member member);
@Query("SELECT DISTINCT m FROM Member m " +
"LEFT JOIN FETCH m.tasteDietaryPreferences tp LEFT JOIN FETCH tp.dietaryPreferences " +
"WHERE m IN :member")
Optional<Member> findMemberWithTasteDietaryPreferencesByMember(@Param("member") Member member);
@Query("SELECT DISTINCT m FROM Member m " +
"LEFT JOIN FETCH m.tasteSpicyLevels ts LEFT JOIN FETCH ts.spicyLevel " +
"WHERE m IN :member")
Optional<Member> findMemberWithTasteSpicyLevelsByMember(@Param("member") Member member);
- 가장 빠른 응답 속도
- Batch Size 적용과 쿼리 분리를 통해 쿼리 실행 횟수가 최적화됨
- AI API 호출 비용도 가장 낮아짐 -> $0.15


- 결론
- LEFT JOIN FETCH를 개별적으로 실행하면 Hibernate 오류 없이 최적화 가능
- 비용 절감 효과도 가장 뛰어나며, AI API 호출이 최적화됨
결론 및 전략
1. 결론
: LEFT JOIN FETCH(분리) + Batch Size 가 최적의 방법 = 가장 높은 성능과 비용 절감 효과
- N+1 문제 해결 -> Lazy Loading으로 인해 발생하던 다중 쿼리 문제를 효과적으로 개선
- AI API 호출 비용 절감 -> 불필요한 외부 API 호출을 줄여 비용 $0.25 -> $0.15로 절감
- Hibernate MultipleBagFetchException 문제 해결 -> LEFT JOIN FETCH를 개별적으로 분리하여 안정적인 쿼리 실행
- Batch Size 적용으로 데이터 조회 성능 개선 -> 대량의 데이터를 한 번에 조회하면서 최적화 효과 증가
2. 향후 개선 방향 및 추가 테스트 전략
- 부하 테스트 진행(대량 트래픽 환경에서 성능 검증)
- 현재는 1000개 요청을 기준으로 테스트가 진행되었지만, 실제 서비스에서는 더 높은 부하가 예상됨
- 테스트 목표
- 대량 요청에서 TPS 유지 여부 확인
- 응답 속도가 일정하게 유지되는지 확인
- AI API 호출 최적화를 통해 대량 트래픽에서도 비용 절감 가능성 검증
- Redis 캐싱 적용 테스트(API 호출 최적화)
- Redis 캐싱을 적용하여 동일한 데이터 요청을 최소화
- 현재 캐싱이 적용되어있지만, 제대로 동작하는지 확인 필요
- 테스트 목표
- Taste 데이터 조회 시, Redis에 캐싱된 데이터를 먼저 확인
- 캐시에 데이터가 없는 경우만 DB 조회 후, AI API 호출 진행
- 조회된 데이터는 일정 시간 동안 Redis에 저장하여 중복 API 호출 방지
- Batch Size 최적화(500/1000/1500 설정 후 비교)
- 현재 가장 많이 쓰이는 Batch Size = 1000으로 설정했지만, 값이 달라질 경우 성능 차이가 발생할 가능성이 있음
- 테스트 목표
- Batch Size = 500, 1000, 1500 설정 후 쿼리 실행 시간 및 응답 속도 비교
- 대량 데이터 조회 시 가장 적절한 Batch Size 설정 도출
+ <Too many fetch joins 오류 원인> -3번째 test 실행 실패 이유
*테스트 결과
- Hibernate Too many fetch joins 오류 발생 -> 테스트 실패
- @OneToMany 관계를 여러 개 JOIN FETCH 하면서 MultipleBagFetchException 발생
- 테스트가 정상적으로 진행되지 못하고 쿼리 실행 오류 발생
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags
*원인 분석
- FETCH JOIN을 과도하게 사용하여 Hibernate가 처리 불가능
- LEFT JOIN FETCH를 사용하면 JPA가 Lazy Loading을 우회하여 즉시 데이터를 가져옴
- 하지만, 한 번의 쿼리에서 여러개의 @OneToMany 관계를 FETCH JOIN 하면 문제 발생
- Hibernate는 두 개 이상의 컬렉션(List<T>)을 FETCH JOIN 할 경우 이를 "Bag"(=MultiSet)으로 처리하며, 동시에 여러개의 "Bag"을 조회하면 데이터 중복 및 변환 문제가 발생
- MultipleBagFetchException 오류 발생
- @OneToMany 관계가 여러 개 존재할 때, 이를 한 번의 쿼리에서 FETCH JOIN 하면 발생
- 데이터가 중복되어 조회되는 문제와 Hibernate 내부에서 처리 불가능한 구조 때문에 실행 불가
*해결 방법
- LEFT JOIN FETCH를 쿼리를 분리하여 실행 (현재 사용)
- Hibernate Batch Size를 적용하여 Lazy Loading 최적화 (현재 사용)
- List<T> 대신 Set<T>를 사용하여 MultipleBagFetchException 방지
++ 만약 Set<T>를 사용하여 개선한다면? (예상코드)
//원래 코드
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<TasteGenres> tasteGenres = new ArrayList<>();
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<TasteLikeFoods> tasteLikeFoods = new ArrayList<>();
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<TasteDislikeFoods> tasteDislikeFoods = new ArrayList<>();
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<TasteDietaryPreferences> tasteDietaryPreferences = new ArrayList<>();
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<TasteSpicyLevel> tasteSpicyLevels = new ArrayList<>();
//수정 예상 코드
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private Set<TasteGenres> tasteGenres = new HashSet<>();
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private Set<TasteLikeFoods> tasteLikeFoods = new HashSet<>();
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private Set<TasteDislikeFoods> tasteDislikeFoods = new HashSet<>();
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private Set<TasteDietaryPreferences> tasteDietaryPreferences = new HashSet<>();
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
private Set<TasteSpicyLevel> tasteSpicyLevels = new HashSet<>();
-Set<T>는 Hibernate 내부적으로 중복을 허용하지 않는 컬렉션이라 "Bag" 문제가 발생하지 않음
따라서 코드를 이렇게 수정할 경우 3번 테스트에서 오류가 나지 않고 한 번의 쿼리에 여러개의 LEFT JOIN FETCH를 사용할 수 있음
하지만 순서가 보장되지 않는다는 단점이 있음
*프로젝트에서 @EntityGraph와 LEFT JOIN FETCH가 함께 사용
1. @EntityGraph로 취향을 가져오는 중간 테이블에서 데이터를 먼저 조회
- TasteGenreRepository, TasteDislikeFoodsRepository, TasteLikeFodsRepository 등 5개의 중간 테이블에서 각각 취향 데이터를 가져올 때 @EntityGraph 사용
- 특정 취향 데이터를 조회할 때, 개별적으로 데이터를 가져옴
2. LEFT JOIN FETCH를 사용해 Member 기준으로 취향 데이터를 한번에 조회
- 개별적인 취향 데이터를 조회한 후, 이 취향들이 Member와 연관된 것이므로, 최종적으로 Member 기준으로 묶어서 가져오기 위해 LEFT JOIN FETCH 사용
- 여러 개의 취향 데이터를 쿼리를 통해 가져와 성능 최적화
*만약 @EntityGraph와 LEFT JOIN FETCH를 각각 사용한다고 했을 때 차이는?
(둘 다 JPA에서 연관된 엔티티를 한 번에 가져오기 위한 방법)
+ <@EntityGraph vs LEFT JOIN FETCH>
비교항목 | @EntityGraph | LEFT FETCH JOIN |
동작 방식 | JPA 내부적으로 Lazy Loading을 무시하고 즉시 로딩(Eager) 수행 | SQL 수준에서 JOIN을 실행하여 한 번의 쿼리로 연관 데이터 조회 |
쿼리 작성 | @Query 없이도 사용 가능(JPA가 자동으로 적용) | 직접 JPQL을 작성 |
쿼리 실행 방식 | SELECT 실행 후, 연관 데이터를 개별적으로 조회 | JOIN을 사용하여 한 번의 SQL 쿼리로 모든 데이터 조회 |
Lazy Loading 해결 | SQL에서 JOIN을 실행하지 않을 수도 있음 | 강제로 JOIN을 실행하여 Lazy Loading을 방지 |
N+1 문제 해결 | 가능하지만 JOIN FETCH만큼 효과적이지 않음 | 가장 효과적 |
쿼리 최적화 수준 | 자동 최적화에 의존 | 개발자가 직접 쿼리를 최적화 가능 |
적용 대상 | 단순 관계 조회(@OneToOne, @ManyToOne) | 컬렉션 조회(@OneToMany)에서 효과적 |
결론 : @EntityGraph는 내부적으로 연관 데이터를 로딩하는 방식이고, LEFT JOIN FETCH는 SQL에서 직접 JOIN을 실행하는 방식
* @EntityGraph를 사용하면 좋은 경우
- 간단한 연관 관계(@ManyToOne, @OneToOne)
- FETCH JOIN 없이도 연관 데이터를 가져오고 싶을 때
- 쿼리 최적화가 필요 없는 경우
* LEFT JOIN FETCH를 사용하면 좋은 경우
- @OneToMany 관계에서 N+1 문제가 발생할 때
- 한 번의 SQL 실행으로 데이터를 최적화해서 가져와야 할 때
- 성능이 중요한 API에서 쿼리 실행 횟수를 최소화하고 싶을 때