TIL(Today I Learned)/Apache JMeter Test

[쿼리 최적화 테스트 - 로컬환경] JPA LEFT JOIN FETCH vs @EntityGraph 성능 비교, 비용 절감 및 최적화 테스트

jiyoon0000 2025. 2. 18. 15:49
테스트 개요

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. 향후 개선 방향 및 추가 테스트 전략

  1. 부하 테스트 진행(대량 트래픽 환경에서 성능 검증)
    • 현재는 1000개 요청을 기준으로 테스트가 진행되었지만, 실제 서비스에서는 더 높은 부하가 예상됨
    • 테스트 목표
      • 대량 요청에서 TPS 유지 여부 확인
      • 응답 속도가 일정하게 유지되는지 확인
      • AI API 호출 최적화를 통해 대량 트래픽에서도 비용 절감 가능성 검증
  2. Redis 캐싱 적용 테스트(API 호출 최적화)
    • Redis 캐싱을 적용하여 동일한 데이터 요청을 최소화
    • 현재 캐싱이 적용되어있지만, 제대로 동작하는지 확인 필요
    • 테스트 목표
      • Taste 데이터 조회 시, Redis에 캐싱된 데이터를 먼저 확인
      • 캐시에 데이터가 없는 경우만 DB 조회 후, AI API 호출 진행
      • 조회된 데이터는 일정 시간 동안 Redis에 저장하여 중복 API 호출 방지
  3. 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에서 쿼리 실행 횟수를 최소화하고 싶을 때