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

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

1️⃣ 마이페이지 전체 조회 성능 최적화 : 4.85s -> 202ms

마이페이지 전체 조회 시 발생하는 N+1 문제에 대해서 다루어 보겠다.

List<OrderItem> orderItemList = order.getOrderItemList();

List<String> accommodationImages = orderItemList.stream()
    .map(orderItem -> orderItem.getProduct() // 주문 항목에서 상품 정보를 가져온다.
        .getAccommodation() // 상품에서 숙소 정보를 가져온다.
        .getImages() // 숙소에서 이미지 정보의 목록을 가져온다.
        .get(0) // 첫 번째 이미지 정보만 가져온다.
        .getImageUrl()) // 이미지에서 이미지 주소 정보를 가져온다.
    .distinct()
    .collect(Collectors.toList());

해당 로직은 주문 항목에 연결된 상품을 통해 해당 상품이 속한 숙소의 이미지 목록을 가져오려는 목적이 있다.

객체 간의 관계를 순차적으로 따라가면서 정보를 추출하는 객체 그래프 탐색을 이용하였다.

객체 그래프는 객체들 간의 연결된 구조를 나타내며, 객체 그래프 탐색은 이러한 구조를 따라가면서 객체 간의 관계를 조사하거나 조작하는 과정을 의미한다.

이렇게 객체 그래프 탐색을 통해 정보를 추출하는 방식은 편리하지만, 성능 측면에선 주의가 필요할 것이라 판단했다.

역시나 콘솔을 확인해보니 알 수 없는 쿼리문이 어마어마하게 실행된 상태였다.

어디서부터 손을 봐야할 지 막막했지만 하나하나 살펴보며 최소한으로 줄여보고자 다짐했다.

❗ 문제 사항

  1. 특정 order_id 에 해당하는 order_item 테이블을 기준으로 product 테이블과 review 테이블을 패치 조인한다.
  2. review 테이블과 many-to-one 연관관계를 가진 product 테이블과 user 테이블도 패치 조인이 이루어진다.
  3. order_id에 해당하는 order_item 테이블만 검색 했을 뿐인데, product 테이블과 review 테이블, review 테이블과 many-to-one 연관관계인 product 테이블과 user 테이블도 패치 조인이 이루어진다.

💡 원인 분석 및 해결 방안

  1. 여러 테이블 간의 연관관계에서 fetch type을 eager로 설정하면 해당 엔티티를 조회할 때 연관된 엔티티들도 즉시 로딩되기 때문에, 필요한 경우가 아니라면 성능 문제를 유발할 수 있다.
    ( many-to-one 연관관계에서 default fetch type은 eager이다.)
  2. 이러한 상황에서는 fetch type을 lazy로 설정하여 지연 로딩을 사용하는 것이 일반적인 해결 방법이다.
public class Review {

    // ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;

    // ...
}

이렇게 설정하면 리뷰 엔티티에 접근할 때 해당 엔티티와 연관된 사용자 엔티티와 상품 엔티티에 대한 정보가 지연 로딩 처리되어 추가적인 쿼리가 발생하지 않는다.

두 엔티티에 대한 정보가 실제로 필요한 시점에서 데이터베이스에 접근하여 가져올 수 있다.

사실 목적은 주문 상품 엔티티에 대한 정보만 필요하므로 상품 엔티티 및 리뷰 엔티티와도 패치 조인이 불필요하다. 곧바로 두 엔티티에 대한 패치 전략을 지연 로딩으로 변경했다.

public class OrderItem {

    // ...
    
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;
    
    @OneToOne(mappedBy = "orderItem", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    private Review review;

    // ...
}

❗ 문제 사항

product 엔티티에 대해서는 lazy loading 이 정상적으로 적용이 되었으나 review 엔티티에 대해서는 여전히 eager loading 이 동일하게 유지되고 있었다.

💡 원인 분석 및 해결 방안

  1. fk를 보유하고 있는 연관관계의 주인인 엔티티는 fk가 null이면 연관관계를 맺는 엔티티가 없음을 알 수 있다.
  • 연관관계의 주인 엔티티에서는 연관된 엔티티를 참조할 때, 연관된 엔티티가 존재하면 프록시 객체를 생성하여 실제 객체를 참조할 수 있다.
  • 연관된 엔티티가 없을 경우엔, 프록시 객체를 생성하는 대신 null을 반환하거나 처리할 수 있다.
  1. fk를 보유하고 있지 않는 연관관계의 주인이 아닌 엔티티는 주인 엔티티의 존재 유무를 알 수 없다.
  • 프록시 객체는 자체적으로 null이 될 수 없다.
  1. 실제 연관관계를 맺는 엔티티를 조회해볼 수 밖에 없기 때문에 즉시 로딩이 적용된다.
  2. 해결 방안은 두 테이블 간의 양방향 연관관계를 맺지 않고 단방향 관계로만 설정한다.
public class Review {

    // ...
    
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_item_id")
    private OrderItem orderItem;
   
   // ...

}

public class OrderItem {

    // ...
    
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;
    
    // Review 필드 제거

    // ...
}

더이상 주문 상품 엔티티에 접근할 때 리뷰 엔티티와의 연관관계로 인한 패치 조인이 발생하지 않는다.

❗ 문제 사항

상품 엔티티 접근 시에도 주문 상품 엔티티에 접근 했을 때와 동일한 현상이 발생했다.

💡 해결 방안

  1. many-to-one 연관관계의 fetch type을 lazy로 설정한다.
  2. one-to-one 양방향 연관관계를 끊어준다.
  • product 테이블과 prouct_facility 테이블은 서로 필수적인 관계라 두 테이블 모두 fk를 가지도록 설계를 하고, fetch type을 lazy로 설정했다.
public class Accommodation {
    // ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;  
    // ...
}
public class ProductFacility {
    // ...
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;
    // ...
}

public class Product {
    // ...
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_facility_id")
    private ProductFacility productFacility;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "accommodation_id")
    private Accommodation accommodation;
   // ...
}

public class AccommodationFacility {
    
    // ...
    
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "accommodation_id")
    private Accommodation accommodation;
    
    // ...
}

public class Accommodation {
   
    // ...

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "accommodation_facility_id")
    private AccommodationFacility facility;
    
    // ...
    
}

fetch type을 lazy로 변경하면서 불필요한 데이터가 로딩되는 걸 막았으나, 여전히 스크롤은 길었다. 이게 바로 말로만 듣던 n+1 문제인가 싶었다.

❓ n+1 문제?

n+1 문제는 연관관계를 가진 엔티티를 조회할 때, 해당 엔티티와 연관된 다른 엔티티를 가져오는 과정에서 발생하는 성능 문제이다.
이 문제는 일반적으로 JPA의 lazy loading 때문에 발생한다. one-to-many 연관관계의 default fetch type은 lazy로 데이터가 필요한 시점에 로딩하기 때문에 추가적인 쿼리가 발생한다.

❗ 문제 사항 및 원인

  1. 앞서 언급했듯 orders > order_item > product > accommodation > accommodation_image 테이블 순으로 연관관계가 맺어져 있다.
  2. orders 테이블과 order_item 테이블은 one-to-many 연관관계이다.
  3. 즉 orders 테이블을 1번 조회, order_item 테이블을 n번 조회하면서 총 n+1번의 쿼리문이 발생한다.
  4. 3의 과정 속에서 order_item 테이블과 many-to-one 연관관계인 product 테이블, product 테이블과 many-to-one 연관관계인 accommodation 테이블을 조회하는 추가 쿼리문이 상황에 따라 몇 번씩 더 발생한다.

💡 해결 방안

주문 내역의 연관된 엔티티들에 대한 패치 조인을 통해 N+1 문제를 최적화한다.

public interface OrderRepository extends JpaRepository<Orders, Long> {
    @Query("select distinct o " +
            "from Orders o " +
            "join fetch o.orderItemList oi " +
            "join fetch oi.product p " +
            "join fetch p.accommodation a " +
            "join fetch a.images " +
            "where o.user = :user")
    List<Orders> getMyPage(@Param("user") User user);
}
  • 패치 조인을 사용하여 즉시 로딩으로 설정하며, 연관된 엔티티들을 한 번의 쿼리로 로딩한다.
  • 주문 아이템(OrderItem), 상품(Product), 숙소(Accommodation), 이미지(AccommodationImage) 등 모든 연관 엔티티를 패치 조인한다.
  • 이로써 N+1 문제를 해결하고 효율적인 쿼리를 수행한다.
  • 다만, 패치조인은 모든 관련 데이터를 함께 가져오기 때문에, 필요하지 않은 경우에는 성능 저하가 발생할 수 있다.
  1. 주문 내역을 조회할 때 distinct 키워드를 사용하여 중복된 객체를 제거한다.
  • 패치 조인을 사용하면 연관된 엔티티들이 조인되면서 중복된 결과가 발생할 수 있다.
  • 하나의 주문 내역에 여러 상품이 있을 경우, 중복된 결과가 발생할 수 있다.
  • distinct 키워드를 사용하지 않았을 경우
  • distinct 키워드를 사용해도 rdb에선 row를 기준으로 중복을 검사하기 때문에 중복 제거가 되지 않는다.
  • 위와 같이 jpql에서 제공하는 distinct 키워드를 사용하여 객체의 중복을 제거한다.
    ( 동일한 객체라는 건 동일한 메모리 주소를 지녔다는 의미이다. )

❗ 문제 사항

하지만 실행시 다음과 같은 MultipleBagFetchException이 발생했다.

🚨 MultipleBagFetchException

  • 엔티티에 대한 fetch join에서 여러 컬렉션을 동시에 eager fetch하려고 할 때 발생한다. (one-to-many 연관관계 혹은 many-to-many 연관관계)
  • Hibernate에서 한 엔티티에 대해 eager fetch를 사용하는 컬렉션이 여러 개인 경우, 각각의 컬렉션을 어떤 순서로 로딩해야 할 지 결정하는 것이 어렵다는 것이다.

이러한 이유로 인해 fetch join을 적용하지 않았을 때, 특정 accommodation_id 값에 해당하는 accommodation_image 엔티티를 가져오기 위해 11번의 추가 쿼리가 발생했다.

💡 fetch join 대신 batch size 사용하여 해결하기

  • @BatchSize 어노테이션을 사용하여 Hibernate의 batch fetch 기능을 활용하면 지정된 개수만큼 엔티티를 한 번에 가져올 수 있다. 이렇게 함으로써 여러 개의 추가 쿼리를 줄이고 성능을 향상시킬 수 있다.
  • 여기서 @BatchSize 어노테이션은 컬렉션을 로딩할 때 일괄 처리 크기를 설정하는 데 사용된다. 나는 size = 100으로 설정하여 최대 100개까지의 엔티티를 한 번에 가져올 수 있도록 했다. 이로 인해 여러 번의 추가 쿼리를 한 번의 쿼리로 대체할 수 있었다.
public class Accommodation {

    // ...
    
    @OneToMany(mappedBy = "accommodation", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    @BatchSize(size = 100)
    private List<AccommodationImage> images = new ArrayList<>();
    
    //...
}

accommodation_id = ? 였던 조회 쿼리가 accommodation_id in(?,?,?,?,?,?,?,?,?,?,?) 으로 변경 되었다.

batch size를 100으로 설정하여 최대 100개 만큼 in절에 숙소 이미지 엔티티의 부모인 숙소 엔티티의 키 값을 사용하게 해준다.

조회 한 번당 발생하는 전체 쿼리는 다음과 같다.

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
        distinct o1_0.order_id,
        o1_0.order_create_date,
        o2_0.order_id,
        o2_0.order_item_id,
        o2_0.end_date,
        o2_0.person_number,
        o2_0.price,
        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,
        o2_0.review_written,
        o2_0.start_date,
        o1_0.payment,
        o1_0.total_price,
        o1_0.user_id 
    from
        orders o1_0 
    join
        order_item o2_0 
            on o1_0.order_id=o2_0.order_id 
    join
        product p1_0 
            on p1_0.product_id=o2_0.product_id 
    join
        accommodation a1_0 
            on a1_0.accommodation_id=p1_0.accommodation_id 
    where
        o1_0.user_id=?
Hibernate: 
    select
        a1_0.accommodation_id,
        a1_0.accommodation_image_id,
        a1_0.image_url 
    from
        accommodation_image a1_0 
    where
        a1_0.accommodation_id in(?,?,?,?,?)

2️⃣ 마이페이지 상세 조회 성능 최적화 : 594ms -> 160ms

@Repository
public interface OrderRepository extends JpaRepository<Orders, Long> {
    @Query("select distinct o " +
            "from Orders o " +
            "join fetch o.orderItemList oi " +
            "join fetch oi.product p " +
            "join fetch p.accommodation a " +
            "where o.user = :user")
    List<Orders> getUserOrdersWithDetails(@Param("user") User user);
}
  1. 주문 내역과 연관된 엔티티들에 대한 패치 조인을 통해 N+1 문제를 최적화한다.
  2. 패치 조인을 사용하여 즉시 로딩으로 설정하며, 연관된 엔티티들을 한 번의 쿼리로 로딩한다.
  3. 주문 아이템 (OrderItem), 상품 (Product), 숙소 (Accommodation) 등 모든 연관 엔티티를 패치 조인한다.
  4. 주문 내역 (Orders) 조회할 때 관련된 엔티티들도 함께 로딩하므로 추가적인 쿼리가 발생하지 않는다.
@Transactional
public MyPageDetailResponse getMyPageDetails(Long orderId) {
    User user = getUser();

    Orders order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderException(OrderExceptionCode.ORDER_NOT_FOUND));

    MyPageDetailResponse myPageDetailResponse = MyPageDetailResponse.fromEntity(order);

    return myPageDetailResponse;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class OrderItemDetailResponse {
    private String accommodationName;
    private String accommodationAddress;
    private String productImage;
    private String productName;

    public static OrderItemDetailResponse fromEntity(OrderItem orderItem) {
        return OrderItemDetailResponse.builder()
                .accommodationName(orderItem.getProduct().getAccommodation().getName())
                .accommodationAddress(orderItem.getProduct().getAccommodation().getAddress())
                .productImage(orderItem.getProduct().getProductImageList().get(0).getImageUrl())
                .productName(orderItem.getProduct().getName())
                .build();
    }
}
  1. 해당 사용자의 특정 주문을 가져오면서 관련된 데이터들을 패치 조인을 통해 즉시 로딩한다.
  2. 관련된 데이터엔 상품 및 숙소에 대한 정보가 포함되어 있다.
  3. 상품 이미지에 대한 정보는 배치 사이즈 설정을 통해 해당 개수만큼 즉시 로딩한다.
  4. 패치 조인을 통해 미리 가져온 주문에 대한 정보를 MyPageDetailResponse 로 변환한다.

조회 한 번당 발생하는 전체 쿼리는 다음과 같다.

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
        o1_0.order_id,
        o1_0.order_create_date,
        o2_0.order_id,
        o2_0.order_item_id,
        o2_0.end_date,
        o2_0.person_number,
        o2_0.price,
        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,
        o2_0.review_written,
        o2_0.start_date,
        o1_0.payment,
        o1_0.total_price,
        o1_0.user_id 
    from
        orders o1_0 
    join
        order_item o2_0 
            on o1_0.order_id=o2_0.order_id 
    join
        product p1_0 
            on p1_0.product_id=o2_0.product_id 
    join
        accommodation a1_0 
            on a1_0.accommodation_id=p1_0.accommodation_id 
    where
        o1_0.order_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개의 댓글