Skip to content

지연로딩 & 즉시로딩 트러블 슈팅 #41

Description

@jskim2x

검색을 구현하던 중, 하나의 엔티티를 조회하는 쿼리를 작성했지만, 연관된 모든 테이블이 같이 select 되는 것을 확인했다.

1. 상황

  • Product Entity가 있고, 거기에 연관된 엔티티는 SubCategory(하위 카테고리), ProductOption(상품 옵션), Seller(판매자), ProductImage(상품 이미지), Brand(브랜드)가 있다.
@Entity
@Getter
@Builder
@Table(name = "product")
@NoArgsConstructor
@AllArgsConstructor
@AuditOverride(forClass = BaseEntity.class)
public class Product extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_id", nullable = false)
    private Long id;

    @Column(name = "product_name", nullable = false, length = 100)
    private String name;

    @Column(name = "product_description", nullable = false, length = 500)
    private String description;

    @Column(name = "average_rating")
    private Double averageRating; // 평균 평점

    @Column(name = "like_count")
    private int likeCount; // 좋아요 수

    @Column(name = "delivery_fee")
    private int deliveryFee; // 배송비

    @ManyToOne
    @JoinColumn(name = "subcategory_id", nullable = false)
    private SubCategory subCategory;

    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<ProductOption> productOptions = new ArrayList<>();

    @ManyToOne
    @JoinColumn(name = "seller_id", nullable = false)
    private Seller seller;

    @Enumerated(EnumType.STRING)
    @Column(name = "authority_status", nullable = false)
    private AuthorityStatus authorityStatus; // 상품의 허가 상태

    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<ProductImage> productImages = new ArrayList<>();

    @ManyToOne
    @JoinColumn(name = "brand_id", nullable = false)
    private Brand brand;

    ....
}
  • 내가 날린 쿼리는 단순히 Product만 조회하는 것으로, QueryDSL의 fetch() 함수를 통해 특정 키워드를 포함하는 row들을 가져오는 쿼리를 작성했다.
@RequiredArgsConstructor
public class ProductSearchRepositoryImpl implements ProductSearchRepository {
    private final JPAQueryFactory jpaQueryFactory;
    @Override
    public List<Product> findProductsByName(String productName) {
        QProduct product = QProduct.product;

        return jpaQueryFactory.selectFrom(product)
                .where(product.name.containsIgnoreCase(productName))
                .fetch();
    }
}
  • 결과는 Product 조회 1번, Brand/Seller/SubCategory에 대한 조회를 2번씩 하여 총 1+6번의 SELECT 쿼리가 실행되었다.
Hibernate: 
    select
        p1_0.product_id,
        p1_0.authority_status,
        p1_0.average_rating,
        p1_0.brand_id,
        p1_0.created_at,
        p1_0.deleted_at,
        p1_0.delivery_fee,
        p1_0.product_description,
        p1_0.like_count,
        p1_0.modified_at,
        p1_0.product_name,
        p1_0.seller_id,
        p1_0.status,
        p1_0.subcategory_id 
    from
        product p1_0 
    where
        lower(p1_0.product_name) like ? escape '!'
Hibernate: 
    select
        b1_0.brand_id,
        b1_0.authority_status,
        b1_0.created_at,
        b1_0.deleted_at,
        b1_0.description,
        b1_0.like_count,
        b1_0.modified_at,
        b1_0.name,
        s1_0.seller_id,
        s1_0.cp_address,
        s1_0.cp_code,
        s1_0.cp_email,
        s1_0.cp_name,
        s1_0.cp_password,
        s1_0.created_at,
        s1_0.deleted_at,
        s1_0.ep_name,
        s1_0.modified_at,
        s1_0.net_proceeds,
        s1_0.phone_number,
        s1_0.role,
        s1_0.status,
        b1_0.status 
    from
        brand b1_0 
    left join
        seller s1_0 
            on s1_0.seller_id=b1_0.seller_id 
    where
        b1_0.brand_id=?
Hibernate: 
    select
        s1_0.seller_id,
        b1_0.brand_id,
        b1_0.authority_status,
        b1_0.created_at,
        b1_0.deleted_at,
        b1_0.description,
        b1_0.like_count,
        b1_0.modified_at,
        b1_0.name,
        b1_0.status,
        s1_0.cp_address,
        s1_0.cp_code,
        s1_0.cp_email,
        s1_0.cp_name,
        s1_0.cp_password,
        s1_0.created_at,
        s1_0.deleted_at,
        s1_0.ep_name,
        s1_0.modified_at,
        s1_0.net_proceeds,
        s1_0.phone_number,
        s1_0.role,
        s1_0.status 
    from
        seller s1_0 
    left join
        brand b1_0 
            on s1_0.seller_id=b1_0.seller_id 
    where
        s1_0.seller_id=?
Hibernate: 
    select
        sc1_0.sub_category_id,
        sc1_0.created_at,
        sc1_0.deleted_at,
        sc1_0.main_category_id,
        mc1_0.main_category_id,
        mc1_0.created_at,
        mc1_0.deleted_at,
        mc1_0.modified_at,
        mc1_0.name,
        mc1_0.status,
        sc1_0.modified_at,
        sc1_0.name,
        sc1_0.status 
    from
        sub_category sc1_0 
    join
        main_category mc1_0 
            on mc1_0.main_category_id=sc1_0.main_category_id 
    where
        sc1_0.sub_category_id=?
Hibernate: 
    select
        b1_0.brand_id,
        b1_0.authority_status,
        b1_0.created_at,
        b1_0.deleted_at,
        b1_0.description,
        b1_0.like_count,
        b1_0.modified_at,
        b1_0.name,
        s1_0.seller_id,
        s1_0.cp_address,
        s1_0.cp_code,
        s1_0.cp_email,
        s1_0.cp_name,
        s1_0.cp_password,
        s1_0.created_at,
        s1_0.deleted_at,
        s1_0.ep_name,
        s1_0.modified_at,
        s1_0.net_proceeds,
        s1_0.phone_number,
        s1_0.role,
        s1_0.status,
        b1_0.status 
    from
        brand b1_0 
    left join
        seller s1_0 
            on s1_0.seller_id=b1_0.seller_id 
    where
        b1_0.brand_id=?
Hibernate: 
    select
        s1_0.seller_id,
        b1_0.brand_id,
        b1_0.authority_status,
        b1_0.created_at,
        b1_0.deleted_at,
        b1_0.description,
        b1_0.like_count,
        b1_0.modified_at,
        b1_0.name,
        b1_0.status,
        s1_0.cp_address,
        s1_0.cp_code,
        s1_0.cp_email,
        s1_0.cp_name,
        s1_0.cp_password,
        s1_0.created_at,
        s1_0.deleted_at,
        s1_0.ep_name,
        s1_0.modified_at,
        s1_0.net_proceeds,
        s1_0.phone_number,
        s1_0.role,
        s1_0.status 
    from
        seller s1_0 
    left join
        brand b1_0 
            on s1_0.seller_id=b1_0.seller_id 
    where
        s1_0.seller_id=?
Hibernate: 
    select
        sc1_0.sub_category_id,
        sc1_0.created_at,
        sc1_0.deleted_at,
        sc1_0.main_category_id,
        mc1_0.main_category_id,
        mc1_0.created_at,
        mc1_0.deleted_at,
        mc1_0.modified_at,
        mc1_0.name,
        mc1_0.status,
        sc1_0.modified_at,
        sc1_0.name,
        sc1_0.status 
    from
        sub_category sc1_0 
    join
        main_category mc1_0 
            on mc1_0.main_category_id=sc1_0.main_category_id 
    where
        sc1_0.sub_category_id=?
  • 이건 N+1 케이스라고 생각했다. 현재 내가 구현한 쿼리는 단순하게 Product만을 조회하지만, N개의 연관된 테이블도 같이 조회가 되는 현상이라고 볼 수 있었기 때문이다.
  • 사실 이 때 Product 테이블에 저장된 데이터 수는 16개였다. 그리고 검색을 했던 키워드는 팬츠였다.
  • 그래서 의문이 든 것은, 총 4*3 = 12번의 추가 select가 되지않고 2번씩만 조회가 되는 것인가? 였다.

2. 원인

  • 로그를 보면 Brand, Seller 등의 연관 엔티티가 두 번씩만 조회되고 있다. 이는 N+1 문제의 전형적인 패턴은 아니지만, JPA의 1차 캐시 덕분에 나머지의 10번 이상의 추가 쿼리가 없어진 것으로 판단했다.
  • 왜냐하면 현재 Product 테이블에 저장된 데이터 형태는 다음과 같다.
  • 팬츠에 관한 검색 결과는 다음과 같다.

이 사진은 product_idid라는 이름으로 DTO 변환해서 가져온 것이고, 실제 상품에서 보일 수 있는 데이터 중에서 가장 기본적인 상품 엔티티에서만 가져올 수 있는 데이터를 가져왔다.

  • 위 두 사진을 종합해서 보면 brand_id(Brand FK) - seller_id(Seller FK) - subcategory_id(SubCategory FK) 조합이 product_id = 3, 11에 대해서는 1-1-3 / product_id = 4, 12에 대해서는 2-2-4구성이 반복됨을 알 수 있다.

  • 이렇게 Product 엔티티들이 서로 동일한 Brand, Seller, SubCategory 엔티티를 공유하고 있기 때문에 JPA 1차 캐시에서 재사용된 것이라 볼 수 있다. 즉, 동일한 Brand, Seller가 여러 Product에 연결된 경우 처음에만 쿼리가 발생하고 그 이후에는 캐시된 값을 사용한 것이라 볼 수 있는 것이다.

  • Hibernate는 처음에만 쿼리를 실행하고, 캐시된 결과를 사용해 추가 쿼리를 방지한다.

3. 해결

  • FetchType.LAZY 적용
    • ~toOne으로 기재된 JoinColumn들에 대해서 지연로딩을 적용시켰다.
    • 참고로 ~toMany가 붙은 것에 대해서는 Lazy Loading이 Default로 적용되고, ~toOne이 붙은 것에 대해서는 Eager Loading이 Default로 적용된다.
  • 적용된 사진

4. TODO

  • 검색 키워드가 매칭되는 범위를 더 늘려야한다. 현재는 Product의 상품 이름만을 참조하게 해뒀음
    • 키워드 범위로는 브랜드, 카테고리까지로 늘리려고 한다.

참고

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions