리뷰 엔티티 조회 최적화: N+1 제거와 DB/메모리 페이징

mseo39·2025년 9월 8일
0

TIL

목록 보기
7/10
post-thumbnail

리뷰 엔티티는 여러 화면에서 재사용됩니다

  • 상품 목록 화면 : 리뷰 평점 + 리뷰 개수만 필요
  • 리뷰 목록 화면 : 리뷰 본문, vendor, 리뷰 이미지 등 필요

하지만 JPA 기본 로딩 전략 때문에 원치않은 쿼리가 발생


FetchType

JPA에서 연관관계 데이터를 언제 불러올지 결정하는 전략

  • EAGER (즉시 로딩) : 엔티티를 조회할 때 연관된 엔티티도 즉시 함께 조회
  • LAZY (지연 로딩) : 엔티티를 조회할 때는 프록시(가짜 객체)만 가져오고 실제 연관 엔티티는 접근할 때 select 발생

    EAGER는 N+1문제의 주요 원인으로 기본은 LAZY로 두고 특정화면에서만 fetch join으로 한 번에 조회함

📝기본 FetchType

관계기본 로딩 전략
@ManyToOne, @OneToOneEAGER
@OneToMany, @ManyToManyLAZY

즉, ProductReview엔티티에서는 productvendor가 즉시 로딩

@Entity
@Table(name = "product_reviews")
public class ProductReview {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_review_id")
    private Long productReviewId;

    @ManyToOne
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @ManyToOne
    @JoinColumn(name = "vendor_id", nullable = false)
    private Vendor vendor;

    @Column(name = "review_id")
    private Long reviewId;
}

💡 어떤 문제가 있는건지 자세히 살펴보자


문제 1. 과다 로딩

  • 상품 목록 화면에서는 리뷰 평점/개수만 필요
  • 그러나 EAGER 때문에 리뷰 20건을 조회하면 :
    • 리뷰 20건
    • 각 리뷰의 product
    • 각 리뷰의 vendor

-> 화면에서 쓰지 않는 데이터도 DB에서 전부 가져와 과다 응답 발생

-- 리뷰 목록 조회
select pr1_0.product_review_id, pr1_0.review_id, pr1_0.score, ...
from product_reviews pr1_0
where pr1_0.product_id = ?

-- product/vendor 즉시 로딩 (리뷰마다 추가 조회)
select p1_0.product_id, p1_0.name, ...
from products p1_0
where p1_0.product_id = ?

select v1_0.vendor_id, v1_0.name, ...
from vendors v1_0
where v1_0.vendor_id = ?

🔎LAZY 적용

@ManyToOne(fetch = FetchType.LAZY) // 👈 변경
@JoinColumn(name = "product_id", nullable = false)
private Product product;

@ManyToOne(fetch = FetchType.LAZY) // 👈 변경
@JoinColumn(name = "vendor_id", nullable = false)
private Vendor vendor;

아래처럼 필요한 시점에만 데이터를 불러와서 불필요한 데이터 로딩을 방지

ProductReview review = reviewRepository.findById(1L).get();
review.getVendor(); // 이 시점에서 SELECT 쿼리 실행

문제 2. @OneToMany N+1, 페이징

리뷰 목록 API 요구사항

  • 리뷰(리뷰 id, userId, score, content, writtenDate), vendor.name, 각 리뷰의 이미지 목록(순서: order_index)
  • 페이징 필요 (page, size) — DB LIMIT/OFFSET로 정확히 동작해야 함
  • 최신순으로 정렬
@OneToMany(mappedBy = "productReview", cascade = CascadeType.ALL)
private List<ReviewImage> images; // 해당 부분에서 N+1, 페이징 문제 발생

A. 기본

  • 리뷰 엔티티를 Pageable로 조회
  • DTO 변환 시 review.getImages() 호출
  • 리뷰마다 이미지 쿼리 발생
-- 메인 조회 쿼리
select
    pr1_0.product_review_id,
    pr1_0.content,
    pr1_0.created_at,
    pr1_0.product_id,
    pr1_0.review_id,
    pr1_0.score,
    pr1_0.user_id,
    pr1_0.vendor_id,
    pr1_0.written_date
from
    product_reviews pr1_0
left join
    products p1_0 on p1_0.product_id=pr1_0.product_id
where
    p1_0.product_id=?
fetch first ? rows only  -- ✅ DB 레벨 페이징 (LIMIT/OFFSET)

-- 카운트 쿼리
select
    count(pr1_0.product_review_id)
from
    product_reviews pr1_0
left join
    products p1_0 on p1_0.product_id=pr1_0.product_id
where
    p1_0.product_id=?

-- 이미지 지연 로딩 쿼리
select
    i1_0.product_review_id,
    i1_0.image_id,
    i1_0.image_url,
    i1_0.order_index
from
    review_images i1_0
where
    i1_0.product_review_id=?    -- ⁉️ 리뷰마다 N번 실행

🤔 어떻게 하면 연관 엔티티를 한 번의 쿼리로 가져올 수 있을까?

B. @EntityGraph

@EntityGraph(attributePaths = {"images"})
Page<ProductReview> findByProduct_ProductId(Long productId, Pageable pageable);
-- 메인 조회 쿼리
select
    pr1_0.product_review_id,
    pr1_0.content,
    pr1_0.created_at,
    i1_0.product_review_id,
    i1_0.image_id,
    i1_0.image_url,
    i1_0.order_index,
    pr1_0.product_id,
    pr1_0.review_id,
    pr1_0.score,
    pr1_0.user_id,
    pr1_0.vendor_id,
    pr1_0.written_date
from
    product_reviews pr1_0
left join
    products p1_0 on p1_0.product_id=pr1_0.product_id
left join
    review_images i1_0 on pr1_0.product_review_id=i1_0.product_review_id
where
    p1_0.product_id=? -- ⁉️ DB 레벨 페이징 존재 X

-- 카운트 쿼리
select
    count(pr1_0.product_review_id)
from
    product_reviews pr1_0
left join
    products p1_0 on p1_0.product_id=pr1_0.product_id
where
    p1_0.product_id=?
  • @EntityGraph를 사용하여 리뷰와 이미지를 한번에 조인 -> N+1은 제거
  • 하지만 메인쿼리에 fetch first ? rows only(= LIMIT/OFFSET)X
    즉, DB 페이지네이션을 포기하고 메모리 페이징으로 동작

🤔 왜 메모리 페이징으로 동작할까?
OneToMany에서 fetch join을 하는 경우 :

  • 리뷰 100만 건이 있고, 각 리뷰에 이미지가 5장씩 붙어있다고 가정
  • 단순히 첫 페이지 10개 리뷰를 원했는데
    Hibernate는 100만 × 5 = 500만 row를 전부 읽고 메모리로 올림 → 그중에서 10개만 잘라냄.
  • DB/네트워크/애플리케이션 메모리 전부 폭발 💥

이렇듯 Hibernate는 OneToMany에서 fetch join시 DB 페이징이 깨지는 걸 알기 때문에 차라리 전체 다 가져와서 엔티티 기준으로 중복 제거하고 그 후에 Pageable을 적용해야지 라고 판단하여 DB 레벨 페이징 포기 → 메모리에서 자르기

🤔 DB 페이징이 깨진다니..?
@OneToMany(리뷰 ↔ 리뷰이미지) 관계에서 fetch join을 쓰면

select pr1_0.product_review_id, pr1_0.content, i1_0.image_id, i1_0.image_url
from product_reviews pr1_0
left join review_images i1_0 on pr1_0.product_review_id = i1_0.product_review_id
  • 위 sql문의 결과는 아래와 같이 리뷰 기준으로는 중복이 발생
  • 이 상태에서 LIMIT 10을 하면 리뷰 10개가 아닌 row 10개를 가져옴
  • 👉 따라서 Hibernate는 DB레벨에서 LIMIT을 하면 리뷰 기준 페이징 보장 X
리뷰 ID이미지 IDrow 번호
111
122
133
144
155
266
277
288
299
21010

C. ID 페이징 + IN 조회

리뷰 ID만 페이징 조회

@Query(value = "select r.productReviewId from ProductReview r where r.product.productId = :productId",
       countQuery = "select count(r) from ProductReview r where r.product.productId = :productId")
Page<Long> findReviewIdsByProductId(@Param("productId") Long productId, Pageable pageable);

리뷰 + 이미지 일괄 조회(IN절 사용)

// 리뷰들 조회
@Query("select r from ProductReview r " +
       "left join fetch r.product " +
       "left join fetch r.vendor " +
       "where r.productReviewId in :ids")
List<ProductReview> findAllWithToOneByIds(@Param("ids") List<Long> ids);

// 이미지 별도 조회
List<ReviewImage> findByProductReview_ProductReviewIdIn(List<Long> ids);
-- 리뷰 ID 페이징
select product_review_id
from product_reviews
where product_id = ?
fetch first 10 rows only; -- ✅ DB 레벨 페이징

-- 리뷰 + vendor join 조회
select pr.*, v.name, v.rating
from product_reviews pr
left join vendors v on pr.vendor_id = v.vendor_id
where pr.product_review_id in (?, ?, ...);

-- 이미지 배치 조회
select *
from review_images
where product_review_id in (?, ?, ...);
  • DB 레벨 페이징 유지 가능
  • N+1 문제 제거
  • ToMany join 폭발 방지 → 대량 데이터에도 안전
  • 쿼리 수 예측 가능 (2~3회)

⁉️ order by 문제

맨 처음 쿼리에서

select product_review_id
from product_reviews
where product_id = ?
order by created_at desc --✅ 정렬
fetch first 10 rows only;

정렬해서 리뷰 ID를 가져오더라도 다음 단계에서

select *
from review_images
where product_review_id in (?, ?, ...);

IN절 기반 조회이므로 review_id 순서가 보장 X

해결 방법:

  • 애플리케이션 레벨에서 정렬 → 첫 번째 쿼리에서 가져온 순서대로 재배치
  • SQL에서 다시 order by → DB마다 문법 달라서 복잡

첫 번째 방법을 선택한 이유: DB마다 문법 차이 없이 순서 보장 가능, 코드 상 직관적, 성능 부담도 크지 않음


더 공부해볼 것

  • Proxy 패턴
  • DTO 변환과 LazyInitializationException
profile
하루하루 성실하게

0개의 댓글