JPA

[JPA] @EntityGraph를 OnetoMany에 적용 시 페이징 처리 안 되는 이슈

voider 2021. 1. 14. 13:58

문제

@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

이렇게 설정해주고 사용하면 된다.

참고 : MultipleBagFetchException 발생시 해결 방법