[JPA] List가 여러 개 있는 엔티티 조회 문제 해결기(feat. MultipleBagFetchException, 페이징 불가, batch size 설정으로도 해결 불가)
안녕하세요
오늘은 제가 개발을 하면서 겪은 문제와, 그 문제를 해결하기 위해 어떤 방법을 시도했고 어떻게 해결했는지 공유드리려고 합니다.
우선 문제 상황부터 공유드리겠습니다.
🤨 문제 상황
이 화면은 필터를 적용하여 상품을 조회하는 화면입니다.
이 화면에 따르면
상품 조회 API의 Request DTO에는
- 편의점
- 카테고리
- 행사 유형
- 최소 가격
- 최대 가격
Response DTO에는
- 상품 이미지
- 상품 이름
- 상품 가격
- 평점
- 리뷰 개수
- 해당 상품을 판매하는 편의점 + 해당 편의점의 행사 정보
- 현재 사용자의 좋아요 여부
- 현재 사용자의 북마크 여부
가 있어야 합니다.
여기에서 문제는 바로 빨간색으로 표시한 판매 편의점+행사 정보입니다.
response DTO 클래스는 아래와 같습니다.
public class ProductResponseDto {
private final Long productId; // 상품 상세 조회 화면으로 넘어가기 위해 필요
private final String productName;
private final Integer productPrice;
private final String productImageUrl;
private final String manufacturerName;
private final Boolean isLiked;
private final Boolean isBookmarked;
private final Integer reviewCount;
private final Double reviewRating;
private List<ConvenienceStoreEventDto> cvsEvents;
}
response body에는 List<ProductResponsesDto>가 담길 것입니다.
따라서 response는 다음과 같을 것인데요,
[
{
"productId": 2,
"productName": "비요뜨",
"productPrice": 1800,
"productImageUrl": "https://blahblah/product/비요뜨.jpg",
"manufacturerName": "서울우유",
"isLiked": true,
"isBookmarked": false,
"reviewCount": 8,
"reviewRating": 4.5,
"cvsEvents": [
{
"name": "CU",
"eventType": "2+1"
},
{
"name": "GS25",
"eventType": "2+1"
},
{
"name": "세븐일레븐",
"eventType": null
},
{
"name": "이마트24",
"eventType": null
},
{
"name": "미니스톱",
"eventType": null
}
]
},
{
"productId": 7,
"productName": "틈새라면큰컵",
"productPrice": 1350,
"productImageUrl": "https://blahblah/product/틈새라면큰컵.jpg",
"manufacturerName": "유어스",
"isLiked": false,
"isBookmarked": false,
"reviewCount": 1,
"reviewRating": 5.0,
"cvsEvents": [
{
"name": "GS25",
"eventType": null
}
]
}
]
판매 편의점과 해당 편의점의 행사 정보를 담은 cvsEvents만 배열인 것을 볼 수 있습니다.
Java의 클래스에서는 하나의 객체에 List를 담을 수 있지만 데이터베이스에서는 그것이 불가능합니다. flat한 row들을 조회할 수 있을 뿐입니다. 이것이 바로 객체와 관계형 데이터베이스의 큰 차이점이자 저희를 머리 아프게 만드는 부분입니다.
또한 여기에서 하나의 리스트가 필요한 것처럼 보이지만 사실 두 개의 리스트가 필요한데요, 이해를 위해 먼저 ERD를 보여드리겠습니다.
아래는 ERD의 일부인데요,
각각의 테이블을 간단히 소개하자면
- product: 상품
- category: 카테고리
- convenience_store: 편의점
- manufacturer: 제조사
- event: 행사 정보 (어떤 편의점에서 어떤 상품이 어떤 행사를 하는지)
- sell_at: 어떤 상품이 어떤 편의점에 파는지
- user: 사용자
- review: 리뷰
- product_like: 어떤 사용자가 어떤 상품에 좋아요 했는지
- product_bookmark: 어떤 사용자가 어떤 상품에 북마크 했는지
입니다.
여기에서 event 리스트와 sell_at 리스트, 이렇게 두 개의 리스트가 필요한 것입니다.
화면에서 판매 편의점 정보와 해당 편의점의 행사 정보를 묶어서 보여주고 있기 때문에 sellAt 리스트와 event 리스트를 하나의 리스트로 합쳐서 response body에 넣어줄 뿐이고, 저희는 두 개의 리스트를 조회해야 합니다.
자 이제 문제 상황은 공유를 드렸으니 제가 어떤 방식으로 해결을 시도했는지 알아보겠습니다.
참고로 저는 필터 적용을 위해 동적 쿼리를 만들어야 해서 QueryDSL을 사용했습니다.
(사실 QueryDSL은 JPQL에 비해 훨씬 편리하고 컴파일 시점에 오류가 나서 실수도 줄일 수 있어서 꼭 동적쿼리가 아니라도 저는 QueryDSL을 잘 사용합니다. 한 번도 사용해보지 않으셨다면 꼭 한 번 사용해보세요! 아주 편리합니다👍)
첫번째 시도: Product 엔티티 안에 OneToMany로 List 두 개를 만들고 fetch join하여 Product 엔티티 자체를 조회하자
제가 처음으로 시도했던 방법은 Product 엔티티 안에 OneToMany로 List<Event> events, List<SellAt> sellAtList를 만들고 Product 엔티티를 sellAtList, events와 fetch join 하여 조회하는 방법이었습니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
private String name;
private Integer price;
private String imageUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "manufacturer_id")
private Manufacturer manufacturer;
@OneToMany(mappedBy = "product")
private List<Event> events = new ArrayList<>(); // 추가
@OneToMany(mappedBy = "product")
private List<SellAt> sellAtList = new ArrayList<>(); // 추가
}
이렇게 OneToMany로 가져오고 싶은 두 리스트를 Product 엔티티에 추가했습니다.
그리고 이렇게 Product 엔티티를 조회하는 메소드를 작성했습니다.
public List<Product> searchByFilter(User user, ProductSearchRequestDto filter, Pageable pageable) {
return queryFactory
.selectFrom(product)
.leftJoin(product.sellAtList, sellAt).fetchJoin() // sellAtList fetch조인
.leftJoin(product.events, event).fetchJoin() // events fetch조인
.where(
convenienceStoreEq(filter.getConvenienceStoreIds()),
categoryEq(filter.getCategoryIds()),
eventEq(filter.getEventTypes()),
priceLessOrEqual(filter.getHighestPrice()),
priceGreaterOrEqual(filter.getLowestPrice())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
where문에 있는 메소드들은 필터 적용을 위해 동적 쿼리를 만드는 메소드를 호출한 것입니다.
private BooleanExpression convenienceStoreEq(List<Long> convenienceStoreIds) {
return convenienceStoreIds != null && !convenienceStoreIds.isEmpty() ? sellAt.convenienceStore.id.in(convenienceStoreIds) : null;
}
private BooleanExpression categoryEq(List<Long> categoryIds) {
return categoryIds != null && !categoryIds.isEmpty() ? product.category.id.in(categoryIds) : null;
}
private BooleanExpression eventEq(List<EventType> eventTypes) {
return eventTypes != null && !eventTypes.isEmpty() ? event.eventType.in(eventTypes) : null;
}
private BooleanExpression priceLessOrEqual(Integer highestPrice) {
return highestPrice != null ? product.price.loe(highestPrice) : null;
}
private BooleanExpression priceGreaterOrEqual(Integer lowestPrice) {
return lowestPrice != null ? product.price.goe(lowestPrice) : null;
}
이렇게 코드를 작성하고 돌려보았습니다!!
예외가 발생했습니다....
MultipleBagFetchException이 발생하면서 동시에 두 개의 bag을 fetch할 수 없다고 하는데 도대체 bag이 뭔데..?🤨
찾아보니 두 개 이상의 to-many 컬렉션을 fetch join하면 안 된다고 합니다.
List 대신 Set으로 바꾸면 해결된다는 글들도 많아서 해보니 됐지만, 좋은 방법이 아닌 것 같았고 다른 방법을 찾기로 했습니다.
(그리고 하나의 리스트만 있어서 MultipleBagFetchException이 나지 않는다 하더라도 이렇게 컬렉션을 fetch join하는 것은 매우 큰 문제가 있기 때문에 사용하지 않는 것이 좋습니다. 위 이미지의 WARN 로그에 힌트가 숨어있습니다ㅎㅎ)
두번째 시도: SellAt을 기준으로 조회를 한 뒤, 그 데이터를 가공하자
제가 두 번째로 시도했던 것은 첫 번째 시도의 실패를 하고 머리를 굴려서 생각해낸 방법입니다.
product를 기준으로 조회를 하지 말고 sell_at을 기준으로 조회를 한 후, product를 기준으로 grouping을 하는 것입니다.
public List<ProductQueryDto> searchByFilter(User user, ProductSearchRequestDto filter,
Pageable pageable) {
List<ProductQueryDto> results = queryFactory
.select(Projections.constructor(ProductQueryDto.class,
product.id, product.name, product.price,
sellAt.convenienceStore.name, product.manufacturer.name,
event.eventType, product.category.name, productLike, productBookmark))
.from(product)
.leftJoin(sellAt).on(sellAt.product.eq(product))
.leftJoin(event).on(event.product.eq(product))
.leftJoin(productLike).on(productLike.product.eq(product))
.leftJoin(QUser.user).on(productLike.user.eq(user))
.leftJoin(productBookmark).on(productBookmark.product.eq(product))
.leftJoin(QUser.user).on(productBookmark.user.eq(user))
.where(
convenienceStoreEq(filter.getConvenienceStoreIds()),
categoryEq(filter.getCategoryIds()),
eventEq(filter.getEventTypes()),
priceLessOrEqual(filter.getHighestPrice()),
priceGreaterOrEqual(filter.getLowestPrice())
)
.fetch();
return results;
}
일단 이렇게 조회를 하면 row 수가 더 많은 sellAt을 기준으로 가져오기 때문에 아래와 같은 결과가 나올 것입니다.
그런데 이것은 저희가 원하는 데이터 형식이 아닙니다. 그래서 Service layer에서 후가공을 하는 것입니다.
public List<ProductResponseDto> getProductList(User user,
ProductSearchRequestDto request, Pageable pageable) {
List<ProductQueryDto> result = productRepository.searchByFilter(user, request, pageable);
Map<ProductTempDto, List<ConvenienceStoreEventDto>> productMap = result.stream()
.collect(Collectors.groupingBy(product -> ProductTempDto.builder() // <- key
.productId(product.getProductId())
.productName(product.getProductName())
.productPrice(product.getProductPrice())
.manufacturerName(product.getManufacturerName())
.categoryName(product.getCategoryName())
.isBookmarked(product.getIsBookmarked())
.isLiked(product.getIsLiked())
.build(),
Collectors.mapping( // <- value
product -> new ConvenienceStoreEventDto(product.getConvenienceStoreName(),
product.getEventType()), Collectors.toList()
))
);
return productMap.entrySet().stream()
.map(entry -> ProductResponseDto.builder()
.productId(entry.getKey().getProductId())
.productName(entry.getKey().getProductName())
.productPrice(entry.getKey().getProductPrice())
.manufacturerName(entry.getKey().getProductName())
.categoryName(entry.getKey().getCategoryName())
.isLiked(entry.getKey().getIsLiked())
.isBookmarked(entry.getKey().getIsBookmarked())
.cvsEvents(entry.getValue())
.build()
).toList();
}
stream의 groupingBy() 메소드를 이용하여 key로는 상품 정보(상품 ID, 상품 이름, 카테고리, 제조사 등)를 담고 있는 ProductTempDto를,
value로는 해당 제품을 판매하는 편의점 + 해당 편의점의 이벤트 정보 List를 가지는 Map을 만들어줍니다.
(Map의 key는 equals()와 hashCode() 메소드가 모두 같아야 하기 때문에 두 메소드를 꼭 오버라이딩 해줘야 합니다. 그러지 않으면 grouping을 한 후에도 11행일 것입니다.)
그리고 해당 Map을 순회하면서 하나의 entry를 하나의 객체로 만들어주었습니다.
아까 11행이었던 데이터를 이렇게 3행으로 만든 것입니다.
실제로 실행을 하여 로그를 찍어보면
이렇게 그룹핑을 잘 하는 것을 볼 수 있습니다.
와 드디어 문제를 해결했다!! 하고 기뻐하고 있었는데....
이렇게 되면 페이징이 불가능하다는 것을 깨달았습니다.
처음에 SELECT문으로 조회를 할 때 sellAt을 기준으로 가져오기 때문에 페이지네이션이 불가능한 것입니다.
예를 들어 페이지 size가 10일 때,
여기에서 10개를 잘라서 주면 안 됩니다.
이렇게 가공한 뒤에 여기에서 10개를 잘라서 줘야 합니다.
그런데 문제는 가공한 뒤에 product가 총 몇 개일지 예측이 불가능하다는 것입니다.
만약 sell_at을 기준으로 50개씩 잘라서, 가공한 뒤의 product 개수는 매번 다르게 response로 준다고 해도
이렇게 같은 상품의 중간에 잘릴 수 있기 때문에 문제가 발생합니다. 이 상태에서 가공을 하면 잘못된 결과가 나올 것입니다.
그래서 저는 다른 방법을 찾아야 했습니다.
세번째 시도: default_batch_fetch_size 를 늘리고 엔티티를 조회하자
events와 sellAtList를 fetch join을 하지 않고 product 엔티티를 조회하는 것입니다. (두 번째 방법에서는 엔티티를 조회하지 않고 조회할 항목을 지정해서 DTO로 받아왔었습니다.)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
private String name;
private Integer price;
private String imageUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "manufacturer_id")
private Manufacturer manufacturer;
@OneToMany(mappedBy = "product")
private List<Event> events = new ArrayList<>();
@OneToMany(mappedBy = "product")
private List<SellAt> sellAtList = new ArrayList<>();
}
(아까 위에서 본 Product 클래스와 똑같습니다. 다시 위로 스크롤하려면 귀찮으실테니 추가했습니다🙂)
OneToMany는 기본 fetch 전략이 LAZY이기 때문에 상품 엔티티를 조회하면 sellAtList와 events는 가져오지 않을 것입니다.
하지만 product.getEvents() 또는 product.getSellAtList()를 하는 순간 N+1 문제가 발생할 것입니다.
상품 조회 쿼리 SELECT * FROM product;가 1번 나갔는데 조회된 product마다 각각 event와 sell_at을 조회하기 위해 쿼리가 6번(N*2번) 더 나갔습니다.
지금은 조회 결과가 3개이니 6번 더 나간 것이지,
만약 조회 결과가 20개였다면 추가로 40번의 쿼리가 나갔을 것입니다. 정말 끔찍하죠?😂
하지만 batch_fetch_size라는 것이 있습니다.
batch size는
properties 파일의 경우
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
yml 파일의 경우
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
이렇게 정해줄 수 있습니다.
조회 결과가 N개일 때 N개에 대해 각각 1번씩 조회 쿼리를 날리는 것이 아니라 위와 같이 batch size만큼씩 한꺼번에 날리는 것입니다.
batch size를 정하지 않은 경우 조회 결과가 N개일 때 2 * N번 추가 쿼리가 나가서 총 1 + N * 2번 쿼리가 나갔겠지만
batch size를 정해준다면 조회 결과가 N개일 때 조회 결과의 개수와 상관없이 추가 쿼리가 딱 2번 나가서 총 1 + 2번의 쿼리가 나가게 됩니다. (batch size가 조회 결과의 개수보다 큰 경우에 한합니다. 조회 결과의 개수가 batch size보다 큰 경우 1 + ceil(N / batch size)번의 쿼리가 나갈 것입니다.)
아까 to-many 관계는 fetch join을 2개 해줬더니 MultipleBagFetchException이 났었죠?
위는 Hibernate 공식 문서인데요, to-one 관계는 fetch join을 여러 개 해도 완전히 안전하다고 나와있습니다.
따라서 to-one 관계인 category와 manufacturer는 fetch join으로 가져옵시다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
private String name;
private Integer price;
private String imageUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category; // fetch join으로 가져오자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "manufacturer_id")
private Manufacturer manufacturer; // fetch join으로 가져오자
@OneToMany(mappedBy = "product")
private List<Event> events = new ArrayList<>();
@OneToMany(mappedBy = "product")
private List<SellAt> sellAtList = new ArrayList<>();
}
다시 보는 Product 클래스입니다
public List<Product> searchByFilter(User user, ProductSearchRequestDto filter, Pageable pageable) {
return queryFactory
.selectFrom(product)
.leftJoin(product.category, category).fetchJoin() // <- to-one 관계는 feth join
.leftJoin(product.manufacturer, manufacturer).fetchJoin() // <- to-one 관계는 feth join
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
이렇게 가져오면 1번의 쿼리로 가져오고, 저렇게 얻은 products에서
products.stream().map(product -> ProductResponseDto.builder()
.productId(product.getId())
.productName(product.getName())
.productPrice(product.getPrice())
.categoryName(product.getCategory().getName())
.manufacturerName(product.getManufacturer().getName())
.events(product.getEvents()) <- 여기에서 조회 쿼리 나감
.convenienceStores(product.getConvenienceStores()) <- 여기에서 조회 쿼리 나감
.build()
).toList();
이렇게 해주면 이 순간 조회 쿼리가 나갈 것인데 저희는 batch size를 지정해줬기 때문에 딱 2번 더 쿼리가 나갈 것입니다.
그래서 저도 이 방법을 쓰면 되겠다! 했으나 저의 상황에서는 이 방법을 쓸 수 없었습니다.
이 화면에서 사용자가 request로 주는 필터 정보는
- 어느 편의점에서 파는 상품인지 (sell_at 테이블)
- 어떤 카테고리의 상품인지 (category 테이블)
- 어떤 행사를 하는 상품인지 (event 테이블)
- 가격이 얼마 이상 얼마 이하의 상품인지 (product 테이블)
입니다.
이 정보들은 where절에 들어가야 합니다.
따라서 애초에 product를 조회를 할 때 sell_at 테이블, event 테이블이 필요합니다.
그래서 저는 where절에 서브쿼리를 써서 해결해보기로 했습니다.
네 번째 시도: WHERE절에 서브쿼리를 사용하여 해결해보자
제가 두 번째로 시도했던 방법의 쿼리를 서브쿼리로 넣는 것입니다.
두 번째로 시도한 방법은 sell_at 목록을 조회하는 것이었는데, sell_at 테이블에는 product_id 컬럼이 있기 때문에
SELECT 상품 정보들
FROM product
WHERE product.id IN (
SELECT product_id
FROM sell_at
WHERE 필터 조건들
)
이런 식으로 하면 될 것 같았습니다.
public List<SearchProductQueryDto> searchByFilter(User user,
ProductSearchRequestDto filter, Pageable pageable) {
return queryFactory.select(new QSearchProductQueryDto(
product.id,
product.name,
product.price,
product.imageUrl,
product.category.id,
manufacturer.name,
productLike,
productBookmark,
review.count(),
review.rating.avg()))
.from(product)
.leftJoin(productLike).on(productLike.product.eq(product).and(eqProductLikeUser(user)))
.leftJoin(productBookmark).on(productBookmark.product.eq(product).and(eqProductBookmarkUser(user)))
.leftJoin(review).on(review.product.eq(product))
.leftJoin(manufacturer).on(product.manufacturer.eq(manufacturer))
.where(
product.in(
selectDistinct(sellAt.product)
.from(sellAt)
.leftJoin(event).on(sellAt.product.eq(event.product))
.where(
convenienceStoreEq(filter.getConvenienceStoreIds()),
eventTypeEq(filter.getEventTypes()),
categoryEq(filter.getCategoryIds()),
priceLessOrEqual(filter.getHighestPrice()),
priceGreaterOrEqual(filter.getLowestPrice())
))
)
.groupBy(product)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
이렇게 product들을 조회한 뒤,
이렇게 조회한 product들의 id로
public List<ConvenienceStoreEventQueryDto> findConvenienceStoreEventsByProductIds(
List<Long> productIds) {
return queryFactory
.select(new QConvenienceStoreEventQueryDto(
sellAt.product.id,
sellAt.convenienceStore.name,
event.eventType))
.from(sellAt)
.leftJoin(event).on(sellAt.product.eq(event.product).and(sellAt.convenienceStore.eq(event.convenienceStore)))
.where(sellAt.product.id.in(productIds))
.fetch();
}
판매 편의점 + 이벤트 정보를 가져오는 쿼리를 따로 작성했습니다.
sell_at 테이블과 event 테이블을 조인해서 아래 화면처럼 보여줄 수 있게 두 정보를 하나로 합쳐서 조회하는 쿼리입니다.
이렇게 총 2번의 쿼리로 상품 정보와 판매 편의점+이벤트 정보까지 다 가져왔습니다.
필요한 데이터는 다 가져왔고, 이제 Service layer에서 가공을 해주면 됩니다.
이렇게 판매 편의점+이벤트 정보까지 가져온 뒤 productId 기준으로 grouping을 해서 List를 만들어줍니다.
그 다음에 아까 조회한 product 목록을 돌면서 해당 정보를 product에 넣어줍니다.
public List<ProductResponseDto> getProductList(User user, ProductSearchRequestDto request,
Pageable pageable) {
// 상품 조회 - 첫 번째 쿼리
List<SearchProductQueryDto> products = productRepository.searchByFilter(user, request,
pageable);
List<Long> productIds = products.stream().map(SearchProductQueryDto::getProductId).toList();
// 조회한 product들의 ID로 편의점+이벤트 정보 조회 - 두 번째 쿼리
List<ConvenienceStoreEventQueryDto> convenienceStoreEvents = productRepository.findConvenienceStoreEventsByProductIds(productIds);
// 편의점+이벤트 정보를 product id 기준으로 그룹핑
Map<Long, List<ConvenienceStoreEventQueryDto>> productCvsEventsMap = convenienceStoreEvents.stream()
.collect(Collectors.groupingBy(ConvenienceStoreEventQueryDto::getProductId));
return products.stream().map(p -> ProductResponseDto.builder()
.productId(p.getProductId())
.productName(p.getProductName())
.productPrice(p.getProductPrice())
.productImageUrl(p.getProductImageUrl())
.categoryId(p.getCategoryId())
.manufacturerName(p.getManufacturerName())
.isLiked(p.getIsLiked())
.isBookmarked(p.getIsBookmarked())
.reviewCount(p.getReviewCount())
.reviewRating(p.getAvgRating())
.cvsEvents(
productCvsEventsMap.get(p.getProductId()).stream() // <- product id로 그룹핑해놓은 편의점+이벤트 리스트를 product id로 가져오기
.map(c -> ConvenicenceStoreEventDto.of(c.getConvenienceStoreName(), c.getEventType()))
.toList()).build()).toList();
}
이렇게 최종적으로 2번의 쿼리로 완성할 수 있게 되었습니다.
이상 저의 삽질과 해결 방법이었습니다.
감사합니다.
참고