장바구니 탐색 API 리팩토링 (N+1 문제 해결)

허석진·2023년 3월 10일
0
post-thumbnail

관련 ERD 구조와 기능 설명

장바구니 탐색 API는 고객에게 상품 정보와 이미지까지 보여줄 필요가 있기 때문에 현재 로그인한 유저의 Profile_id를 이용해 Cart, Product, Product_Image 정보를 가져와야한다. 이때 Category DetailCategory는 장바구니를 보여줄 때 필요한 정보가 아니니 제외되어야한다.

이전코드

// CartServiceImpl.java
@Transactional(readOnly = true)
@Override
public Page<CartResponse> readCarts(Pageable pageable, CustomPrincipal principal) {
    return cartRepository.findAllByProfile_Id(pageable, principal.profileId()).map(CartResponse::from);
}

// CartRepository.java
public interface CartRepository extends JpaRepository<Cart, Long>, CartRepositoryCustom {
    Page<Cart> findAllByProfile_Id(Pageable pageable, Long profileId);
}

이전 코드는 Spring Data JPA에서 제공해주는 QueryMethod를 활용해 Profile_Id를 가지고 있는 모든 Cart에 대한 정보만 뽑아냈다.
하지만 이런 코드는 위에 설명한 기능을 구현하기에는 굉장히 큰 문제가 있다.

왜?

내가 이 코드를 리팩토링 하는 이유는 간단하다. 너무나도 비효율적인 Query를 발생시키기 때문이다.

Hibernate: 
    select
        count(p1_0.id) 
    from
        product p1_0 
    where
        p1_0.id in(?,?,?)      
Hibernate: 
    select
        c1_0.id,
        c1_0.created_at,
        c1_0.created_by,
        c1_0.modified_at,
        c1_0.modified_by,
        c1_0.product_id,
        c1_0.profile_id,
        c1_0.quantity 
    from
        cart c1_0 
    where
        c1_0.profile_id=? 
    order by
        c1_0.created_at desc offset ? rows fetch first ? rows only
Hibernate: 
    select
        p1_0.id,
        p1_0.allergy_info,
        p1_0.brand,
        c1_0.id,
        c2_0.id,
        c2_0.category_code,
        c2_0.name,
        c1_0.category_detail_code,
        c1_0.name,
        p1_0.content,
        p1_0.country_of_origin,
        p1_0.created_at,
        p1_0.created_by,
        p1_0.modified_at,
        p1_0.modified_by,
        p1_0.name,
        p1_0.packaging,
        p1_0.price,
        p1_0.seller,
        p1_0.shipping,
        p1_0.unit,
        p1_0.weight 
    from
        product p1_0 
    join
        category_detail c1_0 
            on c1_0.category_detail_code=p1_0.category_detail_code 
    left join
        category c2_0 
            on c2_0.category_code=c1_0.category_code 
    where
        p1_0.id=?
Hibernate: 
    select
        p1_0.product_id,
        p1_0.id,
        p1_0.img_url 
    from
        product_image p1_0 
    where
        p1_0.product_id=?
Hibernate: 
    select
        p1_0.id,
        p1_0.allergy_info,
        p1_0.brand,
        c1_0.id,
        c2_0.id,
        c2_0.category_code,
        c2_0.name,
        c1_0.category_detail_code,
        c1_0.name,
        p1_0.content,
        p1_0.country_of_origin,
        p1_0.created_at,
        p1_0.created_by,
        p1_0.modified_at,
        p1_0.modified_by,
        p1_0.name,
        p1_0.packaging,
        p1_0.price,
        p1_0.seller,
        p1_0.shipping,
        p1_0.unit,
        p1_0.weight 
    from
        product p1_0 
    join
        category_detail c1_0 
            on c1_0.category_detail_code=p1_0.category_detail_code 
    left join
        category c2_0 
            on c2_0.category_code=c1_0.category_code 
    where
        p1_0.id=?
Hibernate: 
    select
        p1_0.product_id,
        p1_0.id,
        p1_0.img_url 
    from
        product_image p1_0 
    where
        p1_0.product_id=?
Hibernate: 
    select
        p1_0.id,
        p1_0.allergy_info,
        p1_0.brand,
        c1_0.id,
        c2_0.id,
        c2_0.category_code,
        c2_0.name,
        c1_0.category_detail_code,
        c1_0.name,
        p1_0.content,
        p1_0.country_of_origin,
        p1_0.created_at,
        p1_0.created_by,
        p1_0.modified_at,
        p1_0.modified_by,
        p1_0.name,
        p1_0.packaging,
        p1_0.price,
        p1_0.seller,
        p1_0.shipping,
        p1_0.unit,
        p1_0.weight 
    from
        product p1_0 
    join
        category_detail c1_0 
            on c1_0.category_detail_code=p1_0.category_detail_code 
    left join
        category c2_0 
            on c2_0.category_code=c1_0.category_code 
    where
        p1_0.id=?
Hibernate: 
    select
        p1_0.product_id,
        p1_0.id,
        p1_0.img_url 
    from
        product_image p1_0 
    where
        p1_0.product_id=?

위에 Query를 살펴보면 Count Query 1개 + Cart select Query 1개 + Product select Query N개 + Product Image Query M개, 총합 2 + N + M 개의 Query가 발생한다. (M은 소유한 Cart 갯수, N은 소유한 CartProduct가 가진 Product Image 갯수)
즉, 장바구니에 30개의 아이템을 담아뒀고 각 아이템마다 이미지가 3개씩만 있다면 한번의 요청에 총 122개의 Query가 발생하게된다.

어떻게?

처음 생각

CartProduct, Product Image 모두fetch join을 통해 Eager Loading을 하도록해 Count Query 1개 + Cart select Query (fetch join) 1개 총합 2개의 Query로 해결하려했다.

그러나 이런 방식엔 치명적인 문제가 2가지나 있었다.
1. 어째서인지 CategoryLazy Loading이 되지 않는다.
2. DB에서 Cart 데이터를 전부 메모리에 올려 OOM 문제가 발생할 수 있게되며 성능 자체에도 하자가 생긴다.

그래서...

Lazy Loading 문제를 해결하기 위해 DTO Projection도 시도해보고 DB 변경도 생각해봤지만 끝끝내 방법을 찾아 최종적으로는 fetch join + default_batch_fetch_size 조정을 통해 항상 3개의 Query만 보내는 것으로 완성했다.

간단한건데 왜 이렇게 빙빙돌았어? 라고 생각할 수 있지만 그 이유는 위에서 설명한 1번 때문...
이후 차차 설명하겠다.


리팩토링 이후 코드

// CartRepositoryCustomImpl.java
@Override
public Page<Cart> findAllByProfileId(Pageable pageable, Long profileId) {
    QCart cart = QCart.cart;
    QProduct product = QProduct.product;

    JPQLQuery<Cart> query =
            from(cart)
                    .select(cart)
                    .where(cart.profile.id.eq(profileId))
                    .offset(pageable.getOffset())
                    .limit(pageable.getPageSize())
                    .innerJoin(cart.product, product).fetchJoin();

    List<Cart> carts = getQuerydsl().applyPagination(pageable, query).fetch();

    long count = query.fetchCount();

    return new PageImpl<>(carts, pageable, count);
}

사실 Query 자체는 Spring Data JPA의 Query Method를 사용하던걸 QueryDSL로 변경하면서 fetch join하나 추가된 것 뿐이나, 이 아무 문제 없어 보이는 코드가 Category select 문을 발생시킨다.

이유가 뭘까? 개고생 끝에 찾아낸 이유는 바로...

hibernate의 버그? (1번 문제 해결)

이유 없는 Category select 문이 추가로 발생하는 이유는 바로 아래의 사진과 코드에 담겨있다.

@Entity
public class CategoryDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 20)
    private String name;

    @Column(nullable = false , unique = true, name ="categoryDetailCode", length = 20)
    String categoryDetailCode;

    @ManyToOne(optional = false)
    @JoinColumn(name ="categoryCode", referencedColumnName = "categoryCode")
    private Category category;

    @OneToMany(mappedBy = "categoryDetail")
    private List<Product> products = new ArrayList<>();
    
    ...
}
@Entity
public class Product extends AuditingFields {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    ...

    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name="categoryDetailCode", referencedColumnName = "categoryDetailCode")
    private CategoryDetail categoryDetail;
    
    ...
}

절대 FetchType.Lazy를 잊은 것이 아니고 CategoryDetailPK아닌 값, categoryDetailCodeProduct의 FK로 사용했기 때문에 Productfetch join으로 불러왔을 때 CategoryDetailLazy Loading이 적용되지 않은 것이다!!!
Lazy Loading이 동작하지 않는 2가지 경우
같은 문제가 발생한 Stack Overflow
위 링크를 보면 알겠지만 이런 현상이 발생하는 이유를 필자와 저 질문을 본 2만명은 모른다.
hibernate 개발자에게 물어보거나 스펙문서를 다 뒤져보면 알 수 있을까? 이후 시도해보자

아무튼 결과적으로 ProductPK가 아닌 것을 FK로 썼기 때문에 Lazy Loading이 적용되지 않았었다.
Category DetailCategoryDetailCodePK 역할을 하기에 어떤 문제도 없고 기존의 id Column이 따로 필요한 기능도 없었기 때문에 PKCategoryDetailCode로 변경해서 1번 문제를 해결했다.

이렇게까지 변경했을 때는 Count Query 1개 + Cart select Query 1개 + Product select Query N개 + Product Image Query M개가 되어 기존보다 N개의 Query가 줄어들게 된다.
그럼이제 나머지 M개의 Query를 줄일 차례다.

@OneToMany와 fetch join과 MMO (2번 문제 해결)

[Spring/JPA] JPA fetch join + Paging (limit) 처리 시 발생하는 문제 및 해결
사실 위에 작성한 QueryDSL으로 작성한 Query문에서 Product Image까지 fetch join하면 Query문은 총 2개로 줄고 정상적으로 작동.. 하는 것 처럼 보인다.
하지만 콘솔을 살펴 보면 아래와 같은 로그가 출력되어 있다.

HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

Application Memory에 적재한 후 페이징을 수행했다는 의미, 즉 limit 키워드 없이 DB 내에 존재하는 Cart Entity를 모두 가져온다는 것이다.
실제 발생한 Query 문을 확인해 보면 실제로 limit 키워드가 빠져 있으며 사실 Cart Entity를 모두 가져올 뿐만이 아니라 join으로 생성된 모든 경우의 수를 가져왔을 것이다.
예를 들어 Cart가 1000개가 있고 각 Cart가 가르키는 Product마다 Product Image가 10개 씩 있다면 1000개의 데이터를 10000개로 복사해서 메모리에 올리고 그걸 가져와서 페이징을 하는 것이다. (결국 다 읽는다는 것)

위에서 예시든 것 정도는 사실 메모리가 감당할 수도 있지만 만약 이것 이외의 불러와야할 @OneToMany가 있다면? 데이터가 늘어남에 따라 제곱으로 데이터가 늘어나버리는 쓰레기 로직이 되어 OOM을 유발할 수 있는 치명적인 문제로 이어진다.

결국 Spring Data JPA의 작동 방식을 생각하면 @OneToManyfetch join은 있을 수 없다.
그럼 뭘로 할까? 바로 default_batch_fetch_size 옵션이다.
해당 옵션은 Lazy Loading으로 발생하는 Query를 모아모아 in을 사용한 1개의 Query로 처리한다.
따라서 Product Image는 즉시 로딩이 아닌 Lazy Loading 이후 batch fetching을 통해 해결 하는 것이다.
이는 application 설정 파일에 단 한줄만 추가하면 된다.

// 일반적으로 100 ~ 1000을 사용한고한다
spring.jpa.properties.hibernate.default_batch_fetch_size=1000

최종적으로 발생하는 Query

Hibernate: 
    select
        c1_0.id,
        c1_0.created_at,
        c1_0.created_by,
        c1_0.modified_at,
        c1_0.modified_by,
        p1_0.id,
        p1_0.allergy_info,
        p1_0.brand,
        p1_0.category_detail_code,
        p1_0.content,
        p1_0.country_of_origin,
        p1_0.created_at,
        p1_0.created_by,
        p1_0.modified_at,
        p1_0.modified_by,
        p1_0.name,
        p1_0.packaging,
        p1_0.price,
        p1_0.seller,
        p1_0.shipping,
        p1_0.unit,
        p1_0.weight,
        c1_0.profile_id,
        c1_0.quantity 
    from
        cart c1_0 
    join
        product p1_0 
            on p1_0.id=c1_0.product_id 
    where
        c1_0.profile_id=? 
    order by
        c1_0.created_at desc offset ? rows fetch first ? rows only
Hibernate: 
    select
        count(c1_0.id) 
    from
        cart c1_0 
    where
        c1_0.profile_id=?
Hibernate: 
    select
        p1_0.product_id,
        p1_0.id,
        p1_0.img_url 
    from
        product_image p1_0 
    where
        p1_0.product_id in(?,?,?)

Count Query 1개 + Cart select Query 1개 + Product Image Query 1개 + Product select Query N개 + Product Image Query M개
단 3개!! Cart의 갯수가 몇 개건 Product Image가 몇 개건 발생하는 Query는 단 3개!!
아주 만족스러운 N+1 문제 해결이다.


마치며

이번 문제를 해결하며 처음 코드를 작성할 때 얼마나 무지하고 책임감 없이 코드를 작성했는지 알게됐다. 앞으로도 남은 프로젝트를 리팩토링하면서 대가리 박아가며 공부해서 완벽하게 내 것으로 만드는 것을 목표로 노력해야겠다 ㅎ

0개의 댓글