지직전기

[트러블 슈팅]JPA 사용 중 N+1 문제 본문

STUDY/Troubleshooting

[트러블 슈팅]JPA 사용 중 N+1 문제

MSH103 2024. 5. 23. 11:17

 

상황 및 원인

최종 프로젝트 식당 예약 프로그램에서 카테고리, 편의시설, 매장명을 조건으로 검색 시 검색 결과에 해당하는 식당 리스트를 보여주는 리스트로 처음 findAll을 이용해 검색 조건이 없으면 모든 식당을 출력하고, 조건이 있으면 하나씩 비교하여 조건에 하나라도 맞는 식당을 출력하는 메소드.

findAll()로 쿼리를 한번 실행하고 Restaurant에 대해 연관된 CategoryFacility를 처리할 때 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());
    }