지직전기
[트러블 슈팅]JPA 사용 중 N+1 문제 본문
상황 및 원인
최종 프로젝트 식당 예약 프로그램에서 카테고리, 편의시설, 매장명을 조건으로 검색 시 검색 결과에 해당하는 식당 리스트를 보여주는 리스트로 처음 findAll을 이용해 검색 조건이 없으면 모든 식당을 출력하고, 조건이 있으면 하나씩 비교하여 조건에 하나라도 맞는 식당을 출력하는 메소드.
findAll()로 쿼리를 한번 실행하고 각 Restaurant에 대해 연관된 Category와 Facility를 처리할 때 N+1 문제가 발생함.
N+1 문제란?
연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 된다. 이를 N+1 문제라고 한다.
해결방법
Fetch join
JPQL을 사용하여 Fetch join을 사용하는 쿼리문을 작성해 categories와 facilities를 한번에 불러오도록 하여 N+1 문제를 해결하고 매장명, 카테고리, 편의시설별로 필터링하여 원하는 조건에 부합하는 리스트만 출력되도록 메소드를 수정하였다.
Why?
기본적으로 JPA는 연관된 엔티티들을 지연 로딩(lazy loading)한다.
즉, Restaurant 엔티티를 조회한 후 각 Restaurant 엔티티의 categories와 facilities를 개별적으로 조회하게 되는데 이로 인해 N+1 문제가 발생합니다. 이를 방지하기 위해 Fetch join을 사용하여 한 번의 쿼리로 필요한 모든 데이터를 가져오도록 수정하여 N+1문제 해결 및 성능 최적화를 시킬 수 있다.
결과
기존 식당데이터가 800개 정도 들어가 있어 N+1문제로 인해 식당 검색 결과를 가져오는데 수 십초가 소요되었는데 해당 쿼리문으로 수정하여 문제를 해결하고 검색 결과가 나오기까지 약 1~2초 정도로 성능이 최적화 된 모습을 볼 수 있었음.
수정 후 코드)
@Query("SELECT r FROM Restaurant r " +
"LEFT JOIN FETCH r.categories c " +
"LEFT JOIN FETCH r.facilities f")
List<Restaurant> findAllWithDetails();
public List<RestaurantListDTO> findByFilters(List<CategoryName> categoryNames, List<FacilityName> facilityNames, String search) {
Map<Long, List<Review>> reviewsGroupedByRestaurant = getReviewsGroupedByRestaurant();
Map<Long, Double> averageScores = calculateAverageScores(reviewsGroupedByRestaurant);
return restaurantListRepository.findAllWithDetails().stream()
.map(restaurant -> {
int matchCount = 0;
// 카테고리 필터
if (categoryNames != null && !categoryNames.isEmpty() &&
restaurant.getCategories().stream()
.map(Category::getName)
.anyMatch(categoryNames::contains)) {
matchCount++;
}
// 시설 필터
if (facilityNames != null && !facilityNames.isEmpty() &&
restaurant.getFacilities().stream()
.map(Facility::getName)
.anyMatch(facilityNames::contains)) {
matchCount++;
}
// 검색어 필터
if (search != null && !search.isEmpty() &&
(restaurant.getName().toLowerCase().contains(search.toLowerCase()) ||
restaurant.getCategories().stream()
.map(Category::getName)
.map(Enum::name)
.anyMatch(category -> category.contains(search)) ||
restaurant.getFacilities().stream()
.map(Facility::getName)
.map(Enum::name)
.anyMatch(facility -> facility.contains(search)))) {
matchCount++;
}
// DTO 객체 생성
List<CategoryName> categories = restaurant.getCategories().stream()
.map(Category::getName)
.collect(Collectors.toList());
List<FacilityName> facilities = restaurant.getFacilities().stream()
.map(Facility::getName)
.collect(Collectors.toList());
// 평균 점수 가져오기
double averageScore = averageScores.getOrDefault(restaurant.getId(), 0.0);
RestaurantListDTO dto = RestaurantListDTO.builder()
.categoryNames(categories)
.restaurant_id(restaurant.getId())
.restaurant_name(restaurant.getName())
.restaurant_img(restaurant.getImg())
.restaurant_tel(restaurant.getTel())
.restaurant_deposit(restaurant.getDeposit())
.restaurant_crowd(restaurant.getCrowd())
.restaurant_table_count(restaurant.getTableCount())
.restaurant_address(restaurant.getAddress())
.facilityNames(facilities)
.score(averageScore)
.build();
dto.setMatchCount(matchCount);
return dto;
})
.filter(dto -> {
if ((categoryNames == null || categoryNames.isEmpty()) &&
(facilityNames == null || facilityNames.isEmpty()) &&
(search == null || search.isEmpty())) {
return true;
}
return dto.getMatchCount() > 0;
})
.sorted(Comparator.comparingInt(RestaurantListDTO::getMatchCount).reversed())
.collect(Collectors.toList());
}'STUDY > Troubleshooting' 카테고리의 다른 글
| [최적화] OpenGL 렌더링 성능향상 - Display List (0) | 2025.04.20 |
|---|---|
| [트러블 슈팅] 항적 관리 간 삭제, 업데이트 충돌 문제 - Race Condition(경쟁 상태) (1) | 2025.04.18 |
| [트러블슈팅] 프로시저를 이용한 더미데이터 생성 (0) | 2024.05.30 |
| [트러블 슈팅]VUE.js 설치 간 실행 오류 (0) | 2024.04.22 |