문제
@Entity
public class Recipe {
...
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
Set<Ingredient> ingredients = new HashSet<>();
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@OrderBy("sequence ASC")
Set<CookingMethod> cookingMethods = new HashSet<>();
@ManyToOne(fetch = FetchType.LAZY)
private FoodNation foodNation; //한중일양식
@ManyToOne(fetch = FetchType.LAZY)
private FoodType foodType; //국, 반찬, 찌개...
...
}
@Transactional(readOnly = true)
public interface RecipeRepository extends JpaRepository<Recipe, Long> {
@EntityGraph(attributePaths = {"cookingMethods", "ingredients", "foodNation", "foodType"})
Page<Recipe> findAll(Pageable pageable);
}
@EntityGraph
을 사용해서 findAll()
을 호출할 때 연관 엔티티까지 모두 조회하도록 설정했다. 그리고 findAll()
의 파라미터로 pageable
을 선언했다. findAll()
을 호출했을 때 날아가는 쿼리는 이렇다.
Hibernate:
select
....전체 컬럼 조회..너무 길어서 생략.
from
recipe recipe0_
left outer join
food_nation foodnation1_
on recipe0_.food_nation_id=foodnation1_.id
left outer join
cooking_method cookingmet2_
on recipe0_.id=cookingmet2_.recipe_id
left outer join
food_type foodtype3_
on recipe0_.food_type_id=foodtype3_.id
left outer join
ingredient ingredient4_
on recipe0_.id=ingredient4_.recipe_id
order by
recipe0_.id asc,
cookingmet2_.sequence asc
Hibernate:
select
count(recipe0_.id) as col_0_0_
from
recipe recipe0_
의도한 대로 필요한 테이블을 left join으로 모두 가져온다. 그런데 pageable을 인자로 받는데도 불구하고 limit절을 만들지 않았다. 이건 JPA에서 컬렉션에 Join Fetch를 사용할 때 페이징처리를 하지 못하도록 막아두었기 때문이다. 따라서 컬렉션을 조인한 상태에서 페이징을 시도하면 무시하고 모든 데이터를 조회해온다.
그렇다고 pageable이 작동하지 않는 것은 아니다. 데이터베이스에서 모든 데이터를 조회해서 메모리에 올린 다음 거기서 페이징한다. 그러므로 성능이 매우 저하될 수 있으니 주의해야 한다.
왜 컬렉션을 Join Fetch할 때 페이징 처리를 못하도록 막아뒀을까?
1:N관계인 테이블을 조회하면 데이터의 수가 맞지 않는다. 이게 무슨 말이냐면 조회하는 쪽은 하나의 row를 가지고 있지만 조인하는 쪽은 멀티 row를 가지고 있다.
Recipe는 모두 중복이지만 Ingredient만 달라지는 결과를 얻는다. 알리오 올리오의 재료가 100개라면 알리오 올리오라는 레시피 데이터도 똑같이 100개를 조회해야 하기 때문에 낭비다. 이런 이유로 JPA에서 컬렉션 조인, 즉 @OneToMany연관관계 조인 시 페이징처리를 할 수 없게 막고 있는 것 같다. 물론 이것은 내 추측이다.
해결
그럼 @OneToMany관계에 있는 엔티티를 빼고 조회해오면 된다. 그런데 반드시 @OneToMany까지 한 번에 페이징해야 한다면 Fetch전략을 EAGER로 해야 한다. 이 경우 N+1 문제가 발생하므로 데이터를 전체 조회해오는 것과 마찬가지로 성능 저하가 일어날 수 있다.
FetchType을 EAGER로 설정하면
1. 레시피 조회
select
recipe0_.id as id1_6_0_,
foodtype1_.id as id1_2_1_,
foodnation2_.id as id1_1_2_,
recipe0_.calorie as calorie2_6_0_,
recipe0_.cooking_time as cooking_3_6_0_,
recipe0_.description as descript4_6_0_,
recipe0_.food_nation_id as food_nat8_6_0_,
recipe0_.food_type_id as food_typ9_6_0_,
recipe0_.name as name5_6_0_,
recipe0_.servings as servings6_6_0_,
recipe0_.thumbnail as thumbnai7_6_0_,
foodtype1_.type as type2_2_1_,
foodnation2_.nation as nation2_1_2_
from
recipe recipe0_
left outer join
food_type foodtype1_
on recipe0_.food_type_id=foodtype1_.id
left outer join
food_nation foodnation2_
on recipe0_.food_nation_id=foodnation2_.id
order by
recipe0_.id asc limit ?
2. 재료 조회(만약 재료가 100개라면 이 쿼리가 100번 날라간다.)
select
ingredient0_.recipe_id as recipe_i5_3_0_,
ingredient0_.id as id1_3_0_,
ingredient0_.id as id1_3_1_,
ingredient0_.ingredient as ingredie2_3_1_,
ingredient0_.ingredient_type_id as ingredie4_3_1_,
ingredient0_.quantity as quantity3_3_1_,
ingredient0_.recipe_id as recipe_i5_3_1_,
ingredient1_.id as id1_4_2_,
ingredient1_.ingredient_type as ingredie2_4_2_
from
ingredient ingredient0_
left outer join
ingredient_type ingredient1_
on ingredient0_.ingredient_type_id=ingredient1_.id
where
ingredient0_.recipe_id=?
만약 재료가 100개면 100번의 같은 쿼리가 날라간다. 이런 문제점 때문에 EAGER전략을 사용할 수 없다. Fetch전략을 LAZY로 둔 채로 이 문제를 해결하려면 in
절을 이용하면 된다. in
절을 사용하면 where ingredient0_.recipe_id=?
이 조건문을 in
절 안에 묶어서 한 번에 쿼리할 수 있다.
select
ingredient0_.recipe_id as recipe_i5_3_2_,
ingredient0_.id as id1_3_2_,
ingredient0_.id as id1_3_1_,
ingredient0_.ingredient as ingredie2_3_1_,
ingredient0_.ingredient_type_id as ingredie4_3_1_,
ingredient0_.quantity as quantity3_3_1_,
ingredient0_.recipe_id as recipe_i5_3_1_,
ingredient1_.id as id1_4_0_,
ingredient1_.ingredient_type as ingredie2_4_0_
from
ingredient ingredient0_
left outer join
ingredient_type ingredient1_
on ingredient0_.ingredient_type_id=ingredient1_.id
where
ingredient0_.recipe_id in (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
in
절에서 조건문을 묶어서 한 번에 쿼리하고 있다. 이렇게 N+1 문제를 해결하면서 페이징 처리까지 할 수 있게 된다.
hibernate.default_batch_fetch_size
를 조절해서 이 기능을 사용할 수 있다. in
절에 몇 개의 ID를 받아서 조회할 것인지를 설정해주는 것이다. 보통 1000개 정도로 설정하는 듯하다. 그렇다면 재료가 1000개여도 N+1문제없이 단 한 번의 쿼리로 레시피 데이터를 조회하고 페이징처리까지 할 수 있게 된다.
application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
이렇게 설정해주고 사용하면 된다.
'JPA' 카테고리의 다른 글
객체지향 모델과 관계형 모델의 패러다임의 불일치 (0) | 2021.04.26 |
---|---|
[JPA] 연관관계를 가진 엔티티 save 하기 (0) | 2021.01.16 |
JPA 연관관계 매핑 (0) | 2020.11.14 |