문제상황
배달 서비스의 서버를 개발하며, N+1 문제가 발생했습니다. 아래와 같이 테이블을 설계했는데, 주문 상세 내역을 조회할 때 유저, 주소, 가게, 메뉴, 세부 메뉴(ex. 짜장면), 메뉴의 옵션 그룹(ex. 추가메뉴), 옵션 그룹의 옵션(ex. 콜라, 단무지)가 함께 조회되는 구조입니다.
위와 같은 구조에서 3개의 메뉴와 각 메뉴마다 3개의 옵션그룹 그리고 각 옵션그룹마다 3개의 옵션있는 주문을 조회하면, 아래와 같이 총 번의 조회가 발생합니다.
총 18번 = Order(1번) + CanceledOrder(1번) + Store(1번) + User(1번) + Address(1번) + OrderMenu(1번) + OrderGroup(3번) + OrderOption(9번)
참고로 주문에서 실제 음식 정보를 담고 있는 (Menu, Group, Option)은 항상 함께 조회되기 때문에, 즉시로딩을 적용했습니다.
Hibernate:
select
order0_.order_id as order_id1_12_,
...
Hibernate:
select
canceledor0_.canceled_order_id as canceled1_1_0_,
...
Hibernate:
select
store0_.store_id as store_id1_13_0_,
...
Hibernate:
select
user0_.user_id as user_id1_14_0_,
...
Hibernate:
select
address0_.address_id as address_1_0_0_,
...
Hibernate:
select
ordermenus0_.order_id as order_id4_10_0_,
...
Hibernate:
select
ordergroup0_.order_menu_id as order_me3_9_0_,
...
Hibernate:
select
orderoptio0_.order_group_id as order_gr3_11_0_,
...
Hibernate:
select
orderoptio0_.order_group_id as order_gr3_11_0_,
...
Hibernate:
select
orderoptio0_.order_group_id as order_gr3_11_0_,
...
Hibernate:
select
ordergroup0_.order_menu_id as order_me3_9_0_,
...
Hibernate:
select
orderoptio0_.order_group_id as order_gr3_11_0_,
...
Hibernate:
select
orderoptio0_.order_group_id as order_gr3_11_0_,
...
Hibernate:
select
orderoptio0_.order_group_id as order_gr3_11_0_,
...
Hibernate:
select
ordergroup0_.order_menu_id as order_me3_9_0_,
...
Hibernate:
select
orderoptio0_.order_group_id as order_gr3_11_0_,
...
Hibernate:
select
orderoptio0_.order_group_id as order_gr3_11_0_,
...
Hibernate:
select
orderoptio0_.order_group_id as order_gr3_11_0_,
...
위와 같이 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 문제를 N+1 문제라고 하며, 서버에 큰 부하를 주게 되어 서버 장애의 이유가 될 수도 있기 때문에 이를 개선해야했습니다.
원인: JPA 프록시
기본적으로 JPA는 연관관계의 Entity들을 우선 프록시 객체로 채운 후, Eager Loading인 경우에는 바로 각 연관관계 엔티티를 따로 조회하여 조회된 진짜 객채로 바꾸고, Lazy Loading인 경우에는 해당 객체에 접근하는 시점에 조회하여 진짜 객체로 바꾸는 방법을 사용합니다. 이러한 방법을 통해 불필요한 조회를 피하면서도, 객체로서 Entity를 표현할 수 있습니다.
하지만 위와 같이 연관관계 엔티티가 많고 각 엔티티를 모두 조회해야하는 경우에는 너무 많은 수의 쿼리가 추가로 발생하는 문제(N+1)가 생깁니다.
해결 방법 (Fetch Join)
N+1문제를 해결하는 기본적인 방법은 Fetch Join입니다. JpaRepository에서 @Query를 통해 Fetch join을 사용하면, 프록시 객체를 사용하는 대신에 한번에 조회하여 N+1문제를 해결할 수 있습니다.
그래서 아래와 같이 메서드를 만들었습니다.
문제: MultipleBagFetchException
그 후 테스트를 해보니,,, MultipleBagFetchException이 발생했습니다.
원인: 카타시안 곱
Hibernate에서는 OneToMany 관계의 엔티티를 조회할 때 Bag이라는 데이터 타입을 사용합니다. Bag는 순서를 보장하지 않으면서 중복을 허용하는 데이터 타입으로, 순서를 정의하는 것이 Overhead이기 때문에 이 데이터 타입을 사용하며, XXXToMany 관계에 적합합니다. 그런데 이 Bag를 둘 이상 한번에 조회하면 '카타시안 곱' 문제가 발생하기 때문에, 이를 방지하고자 Hibernate 에서는 예외를 던지는 것입니다.
객체와 달리, 관계형 DB에서 데이터를 읽어올 때는 행과 열로 표현해야합니다. 그런데 만약 XXXToMany관계를 둘 이상 조회를 하게 된다면 아래처럼 모든 조합을 다 표현해야하기 때문에, 중복되는 값이 기하급수적으로 많아지는 문제가 생기며, 이것이 카타시안 곱 문제입니다.
주문 | 메뉴 | 옵셥그룹 | 옵션 |
1 | 짜장면 | 사이드 | 콜라 |
1 | 짜장면 | 사이드 | 단무지 |
1 | 짜장면 | 추가재료 | 새우 |
1 | 짜장면 | 추가재료 | 소고기 |
1 | 짬뽕 | 사이드 | 콜라 |
.. | ... | ... | ... |
추가적으로 결과를 매핑할 데이터 타입이 List인 것도 문제의 원인이었는데요. OneToMany 관계의 객체를 순서를 보장하는 List 데이터 타입을 사용하고 있지만, DB관점에서 보면 사실상 순서가 중요하지 않습니다. 그래서 하이버네이트에서는 Bag라는 데이터 타입을 사용하는 것이구요. 이를 다시 List 타입으로 매핑할 때 내부적으로 문제가 발생하기 때문에 예외를 발생시킵니다.
따라서 OneToMany관계의 엔티티는 Fetch Join이 아닌 다른 방법이 필요합니다.
해결: In 절로 조회
해결 방법으로는 크게 세 가지 옵션이 있습니다.
1. OneToMany 객체의 타입을 List에서 Set으로 바꾸는 방법
2. 하나의 OneToMany만 Fetch Join을 이용하고, 나머지는 Eager로 따로 조회하는 방법
3. BatchSize를 통해 in 절로 조회하는 방법
첫 번째. Set을 사용 -> 카타시안 곱 문제가 여전함으로 부적절
먼저 아래와 같이 객체 타입을 Set으로 바꾸면 위의 예외가 발생하지 않습니다. Set은 중복을 허용하지 않고 순서도 없기 때문에, List와 달리 Hibernate가 Set으로 매핑할 수 있는 것입니다. 아래처럼 수정한 뒤 실행해보았습니다.
그 결과 쿼리 한번으로 모든 과정이 수행되었습니다.
Hibernate:
select
distinct order0_.order_id as order_id1_12_0_,
canceledor1_.canceled_order_id as canceled1_1_1_,
user2_.user_id as user_id1_14_2_,
address3_.address_id as address_1_0_3_,
store4_.store_id as store_id1_13_4_,
ordermenus5_.order_menu_id as order_me1_10_5_,
menu6_.menu_id as menu_id1_6_6_,
ordergroup7_.order_group_id as order_gr1_9_7_,
group8_.group_id as group_id1_7_8_,
orderoptio9_.order_option_id as order_op1_11_9_,
option10_.option_id as option_i1_8_10_,
order0_.created_at as created_2_12_0_,
order0_.last_modified_at as last_mod3_12_0_,
order0_.food_total_price as food_tot4_12_0_,
order0_.request as request5_12_0_,
order0_.status as status6_12_0_,
order0_.store_id as store_id7_12_0_,
order0_.user_id as user_id8_12_0_,
canceledor1_.customer_requested_cancel as customer2_1_1_,
canceledor1_.order_id as order_id4_1_1_,
canceledor1_.reason as reason3_1_1_,
user2_.created_at as created_2_14_2_,
user2_.last_modified_at as last_mod3_14_2_,
user2_.address_id as address_9_14_2_,
user2_.email as email4_14_2_,
user2_.nick_name as nick_nam5_14_2_,
user2_.password as password6_14_2_,
user2_.phone as phone7_14_2_,
user2_.role as role8_14_2_,
address3_.address as address2_0_3_,
address3_.delivery_area_id as delivery4_0_3_,
address3_.detail_address as detail_a3_0_3_,
store4_.address as address2_13_4_,
store4_.close_time as close_ti3_13_4_,
store4_.delivery_fee as delivery4_13_4_,
store4_.delivery_time as delivery5_13_4_,
store4_.introduction as introduc6_13_4_,
store4_.main_photo1 as main_pho7_13_4_,
store4_.main_photo2 as main_pho8_13_4_,
store4_.main_photo3 as main_pho9_13_4_,
store4_.name as name10_13_4_,
store4_.open_time as open_ti11_13_4_,
store4_.registration_number as registr12_13_4_,
store4_.tel as tel13_13_4_,
store4_.user_user_id as user_us14_13_4_,
ordermenus5_.amount as amount2_10_5_,
ordermenus5_.menu_id as menu_id3_10_5_,
ordermenus5_.order_id as order_id4_10_5_,
ordermenus5_.order_id as order_id4_10_0__,
ordermenus5_.order_menu_id as order_me1_10_0__,
menu6_.description as descript2_6_6_,
menu6_.name as name3_6_6_,
menu6_.photo as photo4_6_6_,
menu6_.price as price5_6_6_,
menu6_.store_store_id as store_st6_6_6_,
ordergroup7_.group_id as group_id2_9_7_,
ordergroup7_.order_menu_id as order_me3_9_7_,
ordergroup7_.order_menu_id as order_me3_9_1__,
ordergroup7_.order_group_id as order_gr1_9_1__,
group8_.choice_type as choice_t2_7_8_,
group8_.menu_menu_id as menu_men4_7_8_,
group8_.title as title3_7_8_,
orderoptio9_.option_id as option_i2_11_9_,
orderoptio9_.order_group_id as order_gr3_11_9_,
orderoptio9_.order_group_id as order_gr3_11_2__,
orderoptio9_.order_option_id as order_op1_11_2__,
option10_.group_id as group_id4_8_10_,
option10_.name as name2_8_10_,
option10_.price as price3_8_10_
from
orders order0_
left outer join
canceled_order canceledor1_
on order0_.order_id=canceledor1_.order_id
inner join
users user2_
on order0_.user_id=user2_.user_id
inner join
address address3_
on user2_.address_id=address3_.address_id
inner join
store store4_
on order0_.store_id=store4_.store_id
inner join
order_menu ordermenus5_
on order0_.order_id=ordermenus5_.order_id
inner join
menu menu6_
on ordermenus5_.menu_id=menu6_.menu_id
inner join
order_group ordergroup7_
on ordermenus5_.order_menu_id=ordergroup7_.order_menu_id
inner join
option_group group8_
on ordergroup7_.group_id=group8_.group_id
inner join
order_option orderoptio9_
on ordergroup7_.order_group_id=orderoptio9_.order_group_id
inner join
optionz option10_
on orderoptio9_.option_id=option10_.option_id
where
order0_.order_id=?
이 방법은 연관관계가 복잡하지 않은 경우, 즉 카타시안 곱 문제가 발생하더라도 그 영향이 적은 경우에는 적절해보입니다. List를 단순히 Set으로 바꾸면 되기 때문에 수정이 쉽다는 이점도 있습니다.
하지만 저의 상황은 연관관계가 복잡해서 카타시안 곱 문제가 크기 때문에 적절하지 못한 해결책이라고 판단했습니다.
두 번째 OneToMany 관계 중 하나만 FetchJoin 적용 -> N+1 문제를 해결 못함으로 부적절
이 방법 또한 연관관계가 복잡하지 않은 경우에는 고려해볼 수도 있을 것 같습니다. 하지만, 여전히 N+1 문제와 카타시안 곱 문제를 야기 하기 때문에 채택하지 않았습니다.
세 번째 Batch size 설정 -> N+1문제와 카타시안 곱 문제 해결함으로 채택
이 방법은 OneToMany 관계를 조회할 때 Fetch Join을 사용하지 않고, In 절로 모아서 쿼리를 보내는 것입니다. 저의 상황을 예로 들면, 1:1관계 또는 N:1관계에 있는 CanceledOrder, Store, User, Address는 Fetch Join을 사용해서 한번에 조회하고, 1:N 관계에 있는 OrderMenu, OrderGroup, OrderOption는 Batch size를 적용하여 in 절로 조회하는 것입니다.
그러면 orderOption을 조회할 때 기존처럼 orderGroup의 수 많큼 따로 조회하지 않고, orderGroup과 orderOption을 조인해서 where 절의 In으로 아래 처럼 하나로 묶어서 조회하게 됩니다.
이 방법을 사용하면, 하나의 쿼리로는 보낼 수 없지만, XXXToMany관계의 엔티티의 수만큼만 추가로 쿼리가 보내져서 N+1 문제를 해결할 수 있고, 또한 카테시안 곱 문제도 해결할 수 있습니다.
Hibernate에서 Batch Size를 설정하는 방법은 두 가지로,
1. properties에 hibernate.default_batch_fetch_size를 지정 하는 방법과
2. 1:N 관계의 엔티티에 @BatchSize를 각각 달아주는 방법이 있습니다.
1번 방법은 모든 XXXToMany관계를 지연로딩으로 조회할 때, 전 애플리케이션에 모두 기본 적용되는 방법이고,
2번 방법은 각 엔티티마다 맞춰서 적용할 수 있습니다.
XXXToMany관계이면서, 전부 따로따로 쿼리를 보내야할 경우가 없기 때문에, 첫번째 방법을 이용했습니다.
다음으로는 적절한 Batch size를 설정해야합니다. batch size는 in절로 묶을 최대 개수를 설정하는 것입니다. 이를 초과하면 batch size로 나눠서 쿼리를 보내게 됩니다. batch size를 너무 크게 하면, 오히려 부하가 커져서 성능이 떨어질 수 있기 때문에 대용량 데이터를 다룰 경우 서버의 성능이나 상황에 따라 적절한 size를 설정해야합니다.
저의 경우 위의 예시에서도 최대 in 절의 크기가 9이고, 아무리 커도 20을 초과하지 않을 것이라고 판단해 batch size를 20으로 설정했습니다.
네 번째, 두 번째 방법(하나만 Fetch Join)와 세 번째 방법(Batch Size)을 섞어서 사용하는 것 -> BEST!
이 다음으로 고민되는 것은 Order와 1:N관계인 OrderMenu를 FetchJoin할 지(XXXToMany 관계 최대 한 개 Fetch Join 가능(List)) 아니면 BatchSize를 설정할지입니다. 전자의 경우는 작은 카테시안 곱이 발생하지만 쿼리 수를 한 번 줄일 수 있고, 후자의 경우는 쿼리를 한번 더 보내는 대신 카테시안 곱 문제를 방지할 수 있습니다.
둘 중에 무엇이 더 적합한 방법인지 확인하기 위해 각각의 경우를 100번 씩 테스트하여 소요시간을 테스트 해보았습니다.
그 결과 전자의 경우는 평균 34.99ms, 후자는 37.82ms로, 전자가 후자에 비해 약 10% 나은 성능이라고 판단했습니다.
따라서 최종적으로 다음과 같이 쿼리를 수정했습니다.
결과: 쿼리 18번 -> 3번
복잡한 연관관계로 인해 N+1 문제가 발생하여, 메뉴 3개 / 옵션그룹 3개 / 옵션 3개인 경우에 총 18번의 쿼리를 보내고 있었습니다.
총 18번 = Order(1번) + CanceledOrder(1번) + Store(1번) + User(1번) + Address(1번) + OrderMenu(1번) + OrderGroup(3번) + OrderOption(9번)
이를 1:1 또는 N:1 관계의 테이블과 하나의 1:N 테이블을 Fetch로 Join하고, 나머지 1:N관계의 테이블은 Batch Size를 설정하여 in 절로 조회하는 것으로 수정했습니다. 그 결과, Order / CanceledOrder / Store / User / Address / OrderMenu (1번) + OrderGroup(1번) + OrderOption(1번)으로 총 3번의 쿼리로 줄일 수 있었습니다.
참조
Your 2 best options to fix Hibernate's MultipleBagFetchException
Your best option to fix Hibernate's MultipleBagFetchException depends on your data structure. Here are your 2 best options and when to choose which of them.
thorben-janssen.com
'Back-end > Project' 카테고리의 다른 글
[Cache] 다수 key로 CacheEvict, Page 직렬화 (CacheResolver, Mixin, DefaultTyping, Custom Se/Deserializer) (0) | 2023.06.16 |
---|