[패스트캠퍼스X야놀자 : 미니 프로젝트] Spring JPA N+1 문제 해결 - 리뷰 조회

꼬마요리사레미·2023년 12월 4일

1️⃣ 숙소 전체 리뷰 조회 성능 최적화 : 497ms -> 181ms

@Transactional
public List<ProductReviewResponse> getProductReviews(Long accommodationId) {
    
    // ...
    
    private Accommodation getAccommodation(Long accommodationId) {
        Accommodation accommodation = accommodationRepository.getAccommodationWithProductsById(accommodationId)
                .orElseThrow(() -> new AccommodationException(ACCOMMODATION_NOT_FOUND));
        return accommodation;
    }
    
    // ...
}


@Repository
public interface AccommodationRepository extends JpaRepository<Accommodation, Long> {
    @Query("select a from Accommodation a join fetch a.productList where a.id = :accommodationId")
    Optional<Accommodation> getAccommodationWithProductsById(@Param("accommodationId") Long accommodationId);
}
  1. accommodation_id 를 이용하여 accommdation 엔티티를 조회할 때 fetch join 을 이용하여 accommdation 엔티티와 연관된 product 엔티티들도 함께 로딩한다.
    ( 영속성 컨텍스트에 저장 )
  2. 이후 accommodation 엔티티나 product 엔티티에 접근할 때, 데이터베이스에 대한 추가 쿼리가 발생하지 않는다. 영속성 컨텍스트에 저장된 데이터를 사용한다.

해당 메서드 호출 시 hibernate 가 생성한 sql 쿼리를 콘솔에서 확인해보면 다음과 같다.

Hibernate: 
    select
        a1_0.accommodation_id,
        a1_0.address,
        a1_0.area_code,
        a1_0.category_id,
        a1_0.accommodation_facility_id,
        a1_0.latitude,
        a1_0.longitude,
        a1_0.name,
        a1_0.phone,
        p1_0.accommodation_id,
        p1_0.product_id,
        p1_0.check_in_time,
        p1_0.check_out_time,
        p1_0.count,
        p1_0.maximum_number,
        p1_0.name,
        p1_0.product_facility_id,
        p1_0.standard_number 
    from
        accommodation a1_0 
    join
        product p1_0 
            on a1_0.accommodation_id=p1_0.accommodation_id 
    where
        a1_0.accommodation_id=?

hibernate 가 생성한 sql 쿼리에서 다른 연관관계에 놓인 테이블에 대한 추가 쿼리문은 발생하지 않았다.

모두 지연 로딩으로 설정했기 때문에 해당 정보를 조회하는 시점까지 데이터베이스에 로딩되지 않는다. 이후 해당 필드에 접근하는 시점에서 jpa 가 추가적인 쿼리를 실행하여 데이터를 가져올 것으로 판단된다.

리뷰 조회 시 필요한 정보가 아니기 때문에 지연 로딩으로 설정하여 불필요한 데이터베이스 조회를 방지할 수 있고 성능 향상을 이끌어 낼 수 있다.

many-to-one 연관관계의 경우 기본 타입이 즉시 로딩이기 때문에 추가 쿼리문 발생

@Entity
@Table (name = "accommodation")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Accommodation {
    // ...
    @ManyToOne
    @JoinColumn(name = "category_id")
    private Category category;
    // ...
}
Hibernate: 
    select
        a1_0.accommodation_id,
        a1_0.address,
        a1_0.area_code,
        a1_0.category_id,
        a1_0.accommodation_facility_id,
        a1_0.latitude,
        a1_0.longitude,
        a1_0.name,
        a1_0.phone,
        p1_0.accommodation_id,
        p1_0.product_id,
        p1_0.check_in_time,
        p1_0.check_out_time,
        p1_0.count,
        p1_0.maximum_number,
        p1_0.name,
        p1_0.product_facility_id,
        p1_0.standard_number 
    from
        accommodation a1_0 
    join
        product p1_0 
            on a1_0.accommodation_id=p1_0.accommodation_id 
    where
        a1_0.accommodation_id=?
Hibernate: 
    select
        c1_0.category_id,
        c1_0.category_code 
    from
        category c1_0 
    where
        c1_0.category_id=?

실제 데이터베이스에서 해당 jpql 쿼리에 해당하는 sql을 실행하면 다음과 같은 형태로 데이터가 나타난다.

select a.*, p.*
from accommodation a
inner join product p on a.accommodation_id = p.accommodation_id
where a.accommodation_id = 25;

@Transactional
public List<ProductReviewResponse> getProductReviews(Long accommodationId) {
    
    // ...

    List<Product> products = accommodation.getProductList();
    List<ProductReviewResponse> productReviewResponseList = new ArrayList<>();

    // ...
}
  1. 특정 숙소에 대한 정보는 위의 과정을 통해서 즉시 로딩된 상태이다.
  2. 영속성 컨텍스트에서 이미 해당 데이터를 가지고 있어 추가로 데이터베이스에 접근하지 않는다.
  3. 그렇기 때문에 accommodation 엔티티 접근 시 추가 쿼리가 발생하지 않는다.
@Transactional
public List<ProductReviewResponse> getProductReviews(Long accommodationId) {
    
    // ...
    
    for (Product product : products) {
        List<Review> reviews = product.getReviewList();

        productReviewResponseList.addAll(reviews.stream()
                .map(ProductReviewResponse::fromEntity)
                .collect(Collectors.toList()));
    }
}


@Entity
@Table(name = "product")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product {

   // ...

    @OneToMany(mappedBy = "product", cascade = CascadeType.REMOVE)
    @BatchSize(size = 100)
    private List<Review> reviewList = new ArrayList<>();
    
    // ...
}

@Entity
@Table (name = "review")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Review {
    
    // ...
    
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "user_id")
    private User user;
    
    // ...

}    
  1. product 엔티티 접근 시에도 마찬가지로 추가 쿼리가 발생하지 않는다.
  2. product 엔티티와 one-to-many 연관관계인 review 엔티티에 대한 @BatchSize 어노테이션을 작성했고, size = 100 으로 설정한 상태이다.
  3. review 엔티티와 many-to-one 연관관계인 user 엔티티에 대한 fetch type = eager 로 설정한 상태이다.
    ( review 조회 시 user 정보는 필수적이라 default 값인 eager 로 설정했다 )
  4. 이후 review 엔티티에 접근 시 지정한 size 만큼 in 절을 사용하여 조회하며, user 에 관한 정보도 즉시 로딩한다.

review 엔티티 접근 시 hibernate 가 생성한 sql 쿼리를 콘솔에서 확인해보면 다음과 같다.

Hibernate: 
    select
        r1_0.product_id,
        r1_0.review_id,
        r1_0.content,
        r1_0.order_item_id,
        r1_0.review_date,
        r1_0.score,
        u1_0.user_id,
        u1_0.authority,
        u1_0.cart_id,
        u1_0.email,
        u1_0.name,
        u1_0.password 
    from
        review r1_0 
    left join
        user u1_0 
            on u1_0.user_id=r1_0.user_id 
    where
        r1_0.product_id in(?,?,?,?) // 95, 96, 97, 98

실제 데이터베이스에서 해당 sql을 실행하면 다음과 같은 형태로 데이터가 나타난다.

select r.*
from review r left join user u r.user_id = u.user_id 
where r.product_id in (95, 96, 97, 98);

이외에 reveiw 엔티티와 연관된 모든 엔티티에 대해서 지연 로딩으로 설정했다. 그렇기 때문에 review 엔티티에 접근하는 시점에서 추가적인 쿼리는 발생하지 않는다.

one-to-one 연관관계의 경우 기본 타입이 즉시 로딩이기 때문에 추가적인 쿼리문 발생

@Entity
@Table (name = "review")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Review {
    // ...
    @OneToOne
    @JoinColumn(name = "order_item_id")
    private OrderItem orderItem;
    // ...
}
Hibernate: 
    select
        r1_0.product_id,
        r1_0.review_id,
        r1_0.content,
        o1_0.order_item_id,
        o1_0.end_date,
        o1_0.order_id,
        o1_0.person_number,
        o1_0.price,
        o1_0.product_id,
        o1_0.review_written,
        o1_0.start_date,
        r1_0.review_date,
        r1_0.score,
        u1_0.user_id,
        u1_0.authority,
        u1_0.cart_id,
        u1_0.email,
        u1_0.name,
        u1_0.password 
    from
        review r1_0 
    left join
        order_item o1_0 
            on o1_0.order_item_id=r1_0.order_item_id 
    left join
        user u1_0 
            on u1_0.user_id=r1_0.user_id 
    where
        r1_0.product_id in(?,?,?,?)
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ProductReviewResponse {

    // ... 

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public static class ProductDetailsResponse {
        private Long productId;
        private String productImage;
        private String productName;

        public static ProductDetailsResponse fromEntity(Review review) {

            return ProductDetailsResponse.builder()
                    .productId(review.getProduct().getId())
                    .productImage(review.getProduct().getProductImageList().get(0).getImageUrl())
                    .productName(review.getProduct().getName())
                    .build();
        }
    }

@Entity
@Table(name = "product")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product {

   // ...

    @OneToMany(mappedBy = "product", cascade = CascadeType.REMOVE)
    @BatchSize(size = 100)
    private List<ProductImage> productImageList = new ArrayList<>();

  // ...
  
}
  1. product 엔티티와 one-to-many 연관관계인 product_image_list 엔티티에 대한 @BatchSize 어노테이션을 작성했고, size = 100 으로 설정한 상태이다.
  2. 이후 product_image_list 엔티티에 접근 시 지정한 size 만큼 in 절을 사용하여 조회한다.

product_image_list 엔티티 접근 시 hibernate 가 생성한 sql 쿼리를 콘솔에서 확인해보면 다음과 같다.

Hibernate: 
    select
        p1_0.product_id,
        p1_0.product_image_id,
        p1_0.image_url 
    from
        product_image p1_0 
    where
        p1_0.product_id in(?,?,?,?) // 95, 96, 97, 98

실제 데이터베이스에서 해당 jpql 쿼리에 해당하는 sql을 실행하면 다음과 같은 형태로 데이터가 나타난다.

select pi.*
from product_image pi
where pi.product_id in (95, 96, 97, 98);

숙소 전체 리뷰 조회시 발생하는 전체 쿼리문은 다음과 같다.

Hibernate: 
    select
        a1_0.accommodation_id,
        a1_0.address,
        a1_0.area_code,
        a1_0.category_id,
        a1_0.accommodation_facility_id,
        a1_0.latitude,
        a1_0.longitude,
        a1_0.name,
        a1_0.phone,
        p1_0.accommodation_id,
        p1_0.product_id,
        p1_0.check_in_time,
        p1_0.check_out_time,
        p1_0.count,
        p1_0.maximum_number,
        p1_0.name,
        p1_0.product_facility_id,
        p1_0.standard_number 
    from
        accommodation a1_0 
    join
        product p1_0 
            on a1_0.accommodation_id=p1_0.accommodation_id 
    where
        a1_0.accommodation_id=?
Hibernate: 
    select
        r1_0.product_id,
        r1_0.review_id,
        r1_0.content,
        r1_0.review_date,
        r1_0.score,
        u1_0.user_id,
        u1_0.authority,
        u1_0.cart_id,
        u1_0.email,
        u1_0.name,
        u1_0.password 
    from
        review r1_0
    left join
        user u1_0 
            on u1_0.user_id=r1_0.user_id 
    where
        r1_0.product_id in(?,?,?,?)
Hibernate: 
    select
        p1_0.product_id,
        p1_0.product_image_id,
        p1_0.image_url 
    from
        product_image p1_0 
    where
        p1_0.product_id in(?,?,?,?)

2️⃣ 사용자 전체 리뷰 조회에 대한 성능 최적화 : 1261ms -> 183ms

@Entity
@Table(name = "product")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product {
    //...
    
    @BatchSize(size = 100)
    private List<ProductImage> productImageList = new ArrayList<>();
    
    //...
}


@Transactional
public List<UserReviewResponse> getUserReviews() {
    User user = getUser();

    List<Review> reviews = reviewRepository.getReviewsByUserWithDetails(user);

    List<UserReviewResponse> userReviewResponseList = reviews.stream()
            .map(UserReviewResponse::fromEntity)
            .collect(Collectors.toList());

    return userReviewResponseList;
}

@Repository
public interface ReviewRepository extends JpaRepository<Review, Long> {
    @Query("select r " +
            "from Review r " +
            "left join fetch r.product p " +
            "left join fetch p.accommodation a " +
            "left join fetch r.orderItem oi " +
            "where r.user = :user")
    List<Review> getReviewsByUserWithDetails(@Param("user") User user);
}
  1. 특정 사용자가 작성한 리뷰를 조회하며, 연관된 엔티티들에 대한 패치 조인을 통해 N+1 문제를 최적화.
  2. 패치 조인을 사용하여 관련 엔티티를 즉시 로딩하도록 설정하여, 한 번의 쿼리로 관련된 엔티티를 로딩한다.
  3. 모든 관련 엔티티에 대해 주문 상품, 상품, 숙소 등에 대해 패치 조인을 사용한다.
    4.리뷰 엔티티 검색할 때 관련된 엔티티가 함께 로딩되므로 추가적인 쿼리가 발생하지 않는다.
  1. 특정 상품의 이미지 리스트에 대한 batch size = 100 으로 설정한 상태이다.
  2. 이후 상품 이미지 엔티티 접근 시 지정한 개수만큼 상품 아이디에 해당하는 상품 이미지 즉시 로딩

사용자 리뷰 전체 조회시 발생하는 전체 쿼리문은 다음과 같다.

Hibernate: 
    select
        u1_0.user_id,
        u1_0.authority,
        u1_0.cart_id,
        u1_0.email,
        u1_0.name,
        u1_0.password 
    from
        user u1_0 
    where
        u1_0.user_id=?
Hibernate: 
    select
        r1_0.review_id,
        r1_0.content,
        o1_0.order_item_id,
        o1_0.end_date,
        o1_0.order_id,
        o1_0.person_number,
        o1_0.price,
        o1_0.product_id,
        o1_0.review_written,
        o1_0.start_date,
        p1_0.product_id,
        a1_0.accommodation_id,
        a1_0.address,
        a1_0.area_code,
        a1_0.category_id,
        a1_0.accommodation_facility_id,
        a1_0.latitude,
        a1_0.longitude,
        a1_0.name,
        a1_0.phone,
        p1_0.check_in_time,
        p1_0.check_out_time,
        p1_0.count,
        p1_0.maximum_number,
        p1_0.name,
        p1_0.product_facility_id,
        p1_0.standard_number,
        r1_0.review_date,
        r1_0.score,
        r1_0.user_id 
    from
        review r1_0 
    join
        product p1_0 
            on p1_0.product_id=r1_0.product_id 
    join
        accommodation a1_0 
            on a1_0.accommodation_id=p1_0.accommodation_id 
    join
        order_item o1_0 
            on o1_0.order_item_id=r1_0.order_item_id 
    where
        r1_0.user_id=?
Hibernate: 
    select
        p1_0.product_id,
        p1_0.product_image_id,
        p1_0.image_url 
    from
        product_image p1_0 
    where
        p1_0.product_id in(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)

0개의 댓글