카테고리 없음

최종 프로젝트 38일차

sooyeoneo 2025. 2. 9. 00:04

📌 테스트 개요

취향(Taste) 카테고리 데이터를 효율적으로 조회하기 위해 여러 가지 최적화 방안을 실험하였습니다. 총 네 가지 방식으로 테스트를 진행했으며, 성능과 비용 측면에서 가장 최적화된 방법을 찾는 것이 목표였습니다.

테스트 환경

  • JMeter를 이용한 부하 테스트
  • TPS(Transactions Per Second) 측정
  • 테스트 기준: 1000개 요청
  • 쿼리 최적화 성능 변화도 측정 및 Redis 캐싱 검증

테스트 시나리오

  1. 최적화를 적용하지 않은 기본 코드
  2. @EntityGraph를 TasteRepository 5개에 각각 적용
  3. @EntityGraph + LEFT JOIN FETCH(통합) + Batch Size 적용
  4. @EntityGraph + LEFT JOIN FETCH(분리) + Batch Size 적용

각 방식의 성능 차이를 분석하고, 가장 최적화된 방안을 선정하였습니다.


🔍 테스트 결과 비교

테스트 방식 테스트 성공 여부 최적화 수준 비용 절감 효과

1️⃣ 기본 코드 (최적화 없음) ☑️ 성공 ❌ 최적화 없음 -$0.25
2️⃣ @EntityGraph (TasteRepository 5개) ☑️ 성공 🔸 부분 최적화 -$0.23
3️⃣ @EntityGraph + LEFT JOIN FETCH (통합) + Batch Size ❌ 실패 ❌ Redis 캐싱 실패로 중단 -$0.15
4️⃣ @EntityGraph + LEFT JOIN FETCH (분리) + Batch Size ☑️ 성공 ☑️ 최적화 최상 **-$0.23 → -$0.15로
비용 절감**      
  • 📌 비용 절감 효과를 한눈에 확인하기
  • 비용 그래프
  •  

상세 분석

🟥 1️⃣ 기본 코드 (최적화 없음)

  • 테스트 성공, 비용 -$0.25
  • TasteRepository에서 단순 조회
  • 최적화 없음 → 다수의 쿼리 발생 → 비용 비효율적

📌 성능이 가장 낮으며, 비용도 가장 높음

📸 테스트 결과 그래프

 

🟧 2️⃣ @EntityGraph (TasteRepository 5개 각각 적용)

  • @EntityGraph 적용한 이유
  • 문제점:
    • 최적화 이전 코드에서는 TasteRepository 5개와 Member가 @OneToMany 관계로 연결
    • 중간 테이블 5개 (@ManyToOne)을 통해 양방향으로 연관관계를 맺고 있음
    • 이 과정에서 데이터를 가져올 때 N+1 문제가 발생할 가능성이 높음
  • 해결 방법:
    • @EntityGraph를 사용하여 TasteRepository 5개 각각에 적용
    • 이를 통해 연관된 Taste 데이터를 Lazy Loading 없이 한 번에 가져오도록 설정
    • 코드 스니펫
    • // TasteGenresRepository @EntityGraph(attributePaths = {"genres"}) List<TasteGenres> findByMember(Member member); void deleteByMember(Member member); // TasteLikeFoodsRepository @EntityGraph(attributePaths = {"likeFoods"}) List<TasteLikeFoods> findByMember(Member member); void deleteByMember(Member member); // TasteDislikeFoodsRepository @EntityGraph(attributePaths = {"dislikeFoods"}) List<TasteDislikeFoods> findByMember(Member member); void deleteByMember(Member member); // TasteDietaryPreferencesRepository @EntityGraph(attributePaths = {"dietaryPreferences"}) List<TasteDietaryPreferences> findByMember(Member member); void deleteByMember(Member member); // TasteSpicyLevelRepository @EntityGraph(attributePaths = {"spicyLevel"}) Optional <TasteSpicyLevel> findByMember(Member member); void deleteByMember(Member member);

📌 하지만 테스트 결과, 성능 최적화 효과는 크지 않았음

  • 테스트 성공, 비용 -$0.23
  • TasteRepository의 각각 5개에서 개별적으로 @EntityGraph 적용
  • N+1 문제가 일부 해결되었지만, 최적화 수준이 낮음
  • 비용이 최적화 이전 코드와 차이가 없음 (-$0.23)

📸 테스트 결과 그래프

 

🟨 3️⃣ @EntityGraph + LEFT JOIN FETCH (통합) + Batch Size

  • 문제점:
    • @EntityGraph를 적용했지만 테스트 결과, 성능 최적화 효과가 크지 않음
    • 쿼리가 개별적으로 발생하며 TPS(초당 처리량) 개선이 부족함
  • 추가적인 최적화 시도:
    • Batch Size를 적용하여 JPA가 한 번에 여러 개의 엔티티를 조회하도록 개선
    • MemberRepository에서 Taste 데이터를 가져오는 쿼리를 LEFT JOIN FETCH를 사용해 하나의 쿼리로 통합
    • 코드 스니펫 Batch Size
    • #application.properties spring.jpa.properties.hibernate.default_batch_fetch_size=1000
    • 코드 스니펫 LEFT JOIN FETCH
    •  
  • 결과:
    • 기대했던 만큼 성능이 개선되지 않았으며, 심지어 테스트가 실패
    • Redis 캐싱 과정에서 문제가 발생하여 중단됨
#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 m.tasteDislikeFoods td LEFT JOIN td.dislikeFoods " +
            "LEFT JOIN m.tasteDietaryPreferences tp LEFT JOIN tp.dietaryPreferences " +
            "LEFT JOIN m.tasteSpicyLevels ts LEFT JOIN ts.spicyLevel " +
            "WHERE m IN :member")

    Optional<Member> findMemberWithOtherTastesByMembers(@Param("member") Member member);

 

📌 쿼리를 하나로 통합하면 최적화될 것 같았지만, 결과적으로 성능 개선이 미흡했음

  • 테스트 실패 (Redis 캐싱 문제로 중단), 비용 -$0.14
  • TasteRepository의 @EntityGraph 적용 + LEFT JOIN FETCH로 통합된 단일 쿼리 실행
  • Batch Size 적용하여 한 번에 가져오도록 설정
  • 하지만 Redis 캐싱 실패로 인해 테스트가 중단됨
  • 비용 측정도 의미 없음

📸 테스트 결과 그래프

 

 

🟩 4️⃣ @EntityGraph + LEFT JOIN FETCH (분리) + Batch Size

  • 문제점:
    • LEFT JOIN FETCH를 하나의 쿼리로 통합하면 성능이 크게 개선될 것이라 예상했지만, 실제 테스트 결과는 달랐음
    • 쿼리를 통합하면서 성능 최적화 효과가 미비했고, MultipleBagFetchException 발생 가능성도 높아짐
  • 해결 방법:
    • 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 IN :member")
    Optional<Member> findMemberWithTasteGenresByMember(@Param("member") Member member);

    @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);

 

📌 결과적으로 가장 성능이 잘 최적화되었고, 비용 절감 효과도 가장 컸음

  • 테스트 성공, 비용 최적화 (-$0.23에서 -$0.15)
  • 📌 테스트 결과 그래프
  • 최적화된 비용 결과

 

  • TasteRepository 5개에 @EntityGraph 적용
  • MemberRepository에서 LEFT JOIN FETCH를 각각의 쿼리로 분리
  • Batch Size 적용하여 쿼리 최적화
  • 가장 빠른 성능과 최적의 비용 절감 효과를 보임

📸 테스트 결과 그래프

 

 

📊 결론: 최적의 성능과 비용 절감 방법은?

  1. 3번 방법은 Redis 캐싱 실패로 의미 없음
  2. 2번 방법은 일부 최적화만 이루어져 최상의 결과는 아님
  3. 4번 방법 (LEFT JOIN FETCH 분리 + Batch Size + @EntityGraph)가 최상의 성능과 비용 절감 효과를 보임
  • 📌 총 API 요청 수 및 토큰 사용량 그래프
  • API 요청량

 

  • 토큰 사용량

 

💡 최적화 효과 요약

  • 쿼리 최적화(LEFT JOIN FETCH + Batch Size)로 N+1 문제 해결
    • 쿼리를 나눠서 실행하면 MultipleBagFetchException 방지 가능
  • TPS 증가 (Batch Size 적용으로 대량 데이터 로딩 최적화)
    • 쿼리를 분리했더니 TPS 증가 & 성능 최적화 효과 극대화
  • 비용이 기존 -$0.23에서 -$0.15로 감소

⚙️ 최적화 적용 시 고려할 점

  • LEFT JOIN FETCH를 통합하지 말고 분리하여 쿼리를 실행하는 것이 효과적
  • Batch Size를 적절히 조정하여 데이터를 한 번에 로딩
  • Redis 캐싱 안정성을 확보해야 최적화 효과가 온전히 유지됨

☑️ 최적의 방법: LEFT JOIN FETCH (분리) + Batch Size + EntityGraph


📌 추가 테스트 계획

현재 1000개 기준 테스트에서는 N+1 문제로 인한 심각한 성능 저하를 확인하기 어려움

향후 테스트에서 더 높은 부하(50000 이상)로 검증 필요

 

 

 

 

 

 

TIL 2월 8일