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


하지만 JPA 기본 로딩 전략 때문에 원치않은 쿼리가 발생
JPA에서 연관관계 데이터를 언제 불러올지 결정하는 전략
EAGER는 N+1문제의 주요 원인으로 기본은 LAZY로 두고 특정화면에서만 fetch join으로 한 번에 조회함
📝기본 FetchType
| 관계 | 기본 로딩 전략 |
|---|---|
@ManyToOne, @OneToOne | EAGER |
@OneToMany, @ManyToMany | LAZY |
즉, ProductReview엔티티에서는 product와 vendor가 즉시 로딩
@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;
}
💡 어떤 문제가 있는건지 자세히 살펴보자
리뷰 평점/개수만 필요EAGER 때문에 리뷰 20건을 조회하면 :-> 화면에서 쓰지 않는 데이터도 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 = ?
@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 쿼리 실행
리뷰 목록 API 요구사항
@OneToMany(mappedBy = "productReview", cascade = CascadeType.ALL)
private List<ReviewImage> images; // 해당 부분에서 N+1, 페이징 문제 발생
-- 메인 조회 쿼리
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번 실행
🤔 어떻게 하면 연관 엔티티를 한 번의 쿼리로 가져올 수 있을까?
@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🤔 왜 메모리 페이징으로 동작할까?
OneToMany에서 fetch join을 하는 경우 :
첫 페이지 10개 리뷰를 원했는데이렇듯 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
| 리뷰 ID | 이미지 ID | row 번호 |
|---|---|---|
| 1 | 1 | 1 |
| 1 | 2 | 2 |
| 1 | 3 | 3 |
| 1 | 4 | 4 |
| 1 | 5 | 5 |
| 2 | 6 | 6 |
| 2 | 7 | 7 |
| 2 | 8 | 8 |
| 2 | 9 | 9 |
| 2 | 10 | 10 |
리뷰 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 (?, ?, ...);
맨 처음 쿼리에서
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
해결 방법:
첫 번째 방법을 선택한 이유: DB마다 문법 차이 없이 순서 보장 가능, 코드 상 직관적, 성능 부담도 크지 않음