배경
프로젝트 피드백을 받으면서, 튜터분들이 항상 질문하던게 있다. '그렇게 하면 성능이 많이 떨어질 것 같습니다.. 혹시 N+1 문제 고려를 해보셨을까요?', 'DB에서 쿼리를 날려서 가져올 때마다 계산을 진행하게 되면 성능이 많이 저하될것 같은데 다른 방법 혹시 생각해보신거 있을까요?' 등등.. 항상 성능에 관한 것들이 대부분이었던 것 같습니다. 그만큼 현업에서는 성능에 초점을 맞추고 있다는 거겠죠? (물론 비즈니스 로직을 잘 만든다는 가정하에...)
저는 아직까지 비즈니스 로직을 잘 구현하지는 못합니다만..(도메인도 많이 다뤄보질 않았구요 ㅎㅎ) 앞으로는 비즈니스 로직을 구현 하면서 성능 문제도 어느정도 고려를 해봐야 겠다는 생각이 들었습니다. 그중 가장 크게 생각나는 것이 바로 DB와 App간의 소통을 줄여서 성능을 높이는 것, 또 하나는 thread를 어떻게 관리해야 할지에 대한 것인데, 이번에는 DB와 App간 data를 주고받는 방식을 개선해 보려고 합니다.
내용
인프런의 김영한님 강의를 보게 되면, fetch join에 관한 내용이 있습니다. 영한님은 'fetch join 정말정말 중요한 부분이고, 성능 문제의 80% 이상 여기서 해결이 된다'라고 하실 정도로 매우 강조하고 계시죠.
우리가 DB에서 내용을 조회 할 때, entity에 지연로딩을 설정해도 연관된 테이블을 조회하고 싶을 때에는 DB에 쿼리를 날려서 1차 캐시에 정보를 담아두어야 합니다. 한번에 조회를 하는것이 아닌 각각의 조회할 내용들 마다 1차 캐시에 data가 없으면 db에 쿼리를 날려 가져와야 하죠.
이렇게 되면 App과 DB가 계속해서 정보를 주고받아야 하는데 이때 수천, 수만건이 조회 요청이 여러번 반복 된다면 어떻게 될까요? 아마 시간이 오래 걸려서 정상적인 서버 동작을 하지 못할 것입니다. DB에 data를 가져올 때 차라리 연관된 데이터들을 한번에 1차 캐시에 저장해 놓고 빠르게 필요한 값들을 가져오는 것이 훨씬 효율적일 것입니다.(저는 비슷한 예로 context switching을 떠올렸습니다.)
이때 사용할 수 있는것이 Java에서는 fetch join 쿼리를 이용하는 것입니다.
위 그림과 같이 주문에 연관관계 된 테이블들을 예로 들어보겠습니다. master나 manager 권한의 사람이 음식점 정보, 주문메뉴, 사용자, 리뷰, 결재내역 등등 각 테이블에 연관된 정보들을 포함해서 조회를 원하게 될 경우, db로 쿼리가 얼마나 보내지게 될까요? 주문 1개만해도 적어도 5개의 연관된 테이블 정보를 가져와야 하는데 각 테이블에 중복된 값들이 있게 된다면 더 증가될 것입니다.
Spring Boot를 이용해 실제로 주문 테이블의 모든 정보를 가져와 보겠습니다. (페이징 처리를 해서 10개 사이즈로 가져와 보겠습니다.)
먼저 fetch join을 사용하지 않고 그냥 가져왔을 때의 쿼리입니다.
Hibernate:
/* <criteria> */ select
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id
from
p_order o1_0
where
not(o1_0.deleted)
order by
o1_0.created_at desc
fetch
first ? rows only
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
select
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id
from
p_payments p1_0
left join
p_order o1_0
on o1_0.order_id=p1_0.order_id
where
p1_0.order_id=?
Hibernate:
/* <criteria> */ select
count(o1_0.order_id)
from
p_order o1_0
where
not(o1_0.deleted)
Hibernate:
select
s1_0.store_id,
s1_0.addruuid,
s1_0.address,
s1_0.detail_address,
s1_0.postcode,
s1_0.created_at,
s1_0.created_by,
s1_0.deleted,
s1_0.deleted_at,
s1_0.deleted_by,
s1_0.phone_number,
s1_0.store_name,
s1_0.updated_at,
s1_0.updated_by
from
p_store s1_0
where
s1_0.store_id = any (?)
Hibernate:
select
oml1_0.order_id,
oml1_0.order_menu_id,
oml1_0.created_at,
oml1_0.created_by,
oml1_0.deleted,
oml1_0.deleted_at,
oml1_0.deleted_by,
oml1_0.menu_id,
oml1_0.quantity,
oml1_0.updated_at,
oml1_0.updated_by
from
p_order_menu oml1_0
where
oml1_0.order_id = any (?)
Hibernate:
select
m1_0.menu_id,
m1_0.created_at,
m1_0.created_by,
m1_0.deleted,
m1_0.deleted_at,
m1_0.deleted_by,
m1_0.description,
m1_0.image_url,
m1_0.is_available,
m1_0.is_hidden,
m1_0.name,
m1_0.price,
m1_0.store_id,
m1_0.updated_at,
m1_0.updated_by
from
menu m1_0
where
m1_0.menu_id = any (?)
보이시나요? 쿼리가 날아가는 숫자가 상당합니다.. 이러면 제대로된 성능이 나오기 힘들겠죠. 하지만 fetch join을 사용하게 된다면?
Fetch Join 사용
Hibernate:
/* select
distinct order1
from
Order order1 left join
fetch
order1.orderMenuList as orderMenu
left join
fetch
order1.store as store
left join
fetch
order1.user as user
left join
fetch
order1.payments as payments */ select
distinct o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
oml1_0.order_id,
oml1_0.order_menu_id,
oml1_0.created_at,
oml1_0.created_by,
oml1_0.deleted,
oml1_0.deleted_at,
oml1_0.deleted_by,
oml1_0.menu_id,
oml1_0.quantity,
oml1_0.updated_at,
oml1_0.updated_by,
o1_0.order_status,
o1_0.order_type,
p1_0.payments_id,
p1_0.amount,
p1_0.created_at,
p1_0.created_by,
p1_0.deleted,
p1_0.deleted_at,
p1_0.deleted_by,
p1_0.updated_at,
p1_0.updated_by,
p1_0.user_id,
o1_0.special_request,
o1_0.store_id,
s1_0.store_id,
s1_0.addruuid,
s1_0.address,
s1_0.detail_address,
s1_0.postcode,
s1_0.created_at,
s1_0.created_by,
s1_0.deleted,
s1_0.deleted_at,
s1_0.deleted_by,
s1_0.phone_number,
s1_0.store_name,
s1_0.updated_at,
s1_0.updated_by,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id,
u1_0.user_id,
u1_0.addruuid,
u1_0.address,
u1_0.detail_address,
u1_0.postcode,
u1_0.created_at,
u1_0.created_by,
u1_0.deleted,
u1_0.deleted_at,
u1_0.deleted_by,
u1_0.email,
u1_0.password,
u1_0.role,
u1_0.updated_at,
u1_0.updated_by,
u1_0.user_name
from
p_order o1_0
left join
p_order_menu oml1_0
on o1_0.order_id=oml1_0.order_id
left join
p_store s1_0
on s1_0.store_id=o1_0.store_id
left join
p_users u1_0
on u1_0.user_id=o1_0.user_id
left join
p_payments p1_0
on o1_0.order_id=p1_0.order_id
Hibernate:
/* <criteria> */ select
o1_0.order_id,
o1_0.addruuid,
o1_0.address,
o1_0.detail_address,
o1_0.postcode,
o1_0.created_at,
o1_0.created_by,
o1_0.deleted,
o1_0.deleted_at,
o1_0.deleted_by,
o1_0.order_status,
o1_0.order_type,
o1_0.special_request,
o1_0.store_id,
o1_0.total_amount,
o1_0.updated_at,
o1_0.updated_by,
o1_0.user_id
from
p_order o1_0
where
not(o1_0.deleted)
order by
o1_0.created_at desc
fetch
first ? rows only
Hibernate:
/* <criteria> */ select
count(o1_0.order_id)
from
p_order o1_0
where
not(o1_0.deleted)
Hibernate:
select
m1_0.menu_id,
m1_0.created_at,
m1_0.created_by,
m1_0.deleted,
m1_0.deleted_at,
m1_0.deleted_by,
m1_0.description,
m1_0.image_url,
m1_0.is_available,
m1_0.is_hidden,
m1_0.name,
m1_0.price,
m1_0.store_id,
m1_0.updated_at,
m1_0.updated_by
from
menu m1_0
where
m1_0.menu_id = any (?)
확실히 fetch join을 사용하면서 DB로 날아가는 쿼리가 줄어든게 보이시죠? 이렇게 DB와 주고받는 쿼리를 줄이면서 성능을 높일 수가 있습니다. (fetch join은 Lazy를 무시하고 DB에서 값을 조회해 가져옵니다.)
하지만 무조건 전부 fetch join으로 가져오는게 좋은것은 아닙니다. fetch join으로 가져오는 내용이 메모리에서 감당하지 못할 정도로 수만건 이상이 될 경우 오히려 성능이 떨어질 수 있습니다. 이를 해결하는 방법으로는 BatchSize를 설정해서 한번에 가져오는 데이터 양을 조절할 수 있습니다. Spring의 경우 해당 Entity나 Entity의 필드, 또는 Global로 Batch size를 설정하면 되는데요, 저는 application.properties에 값을 설정해 두었습니다.
spring.jpa.properties.hibernate.jdbc.batch_size=50
위쪽은 조회시 가져오는 batch size 설정이고, 아래쪽은 한번에 수정/삭제 쿼리를 보내는 batch size입니다. 보통 1000 이하로 설정하면 된다고 하는데, 저는 공부하는 중이니 50정도로 설정해놓았습니다.
한계점
영한님 강의에서는 fetch join을 사용할 때 where로 특정 조건을 붙여서 가져오는 것을 지양하라고 하십니다. 그 이유는 데이터 정합성이 떨어질 수도 있고, DB 이상현상이 발생할 수도 있어서 입니다. 특정 table을 조회했는데 fetch join으로 조건을 걸어 5개만 가져왔고, 다른 곳에서 조회 했을 때는 100건을 가져왔을 때 영속성 컨텍스트의 입장에서는 이 상황을 따로 컨트롤 할 수 있는 방안을 제시하고 있지 않습니다.. 매우 위험하죠. 특정 데이터를 가져오고 싶으면 차라리 따로 쿼리를 날리는 것이 좋다고 합니다.
둘 이상의 컬렉션, 특히 일대다 관계가 중복되어서 사용되는 경우 (일대다 -> 일대다 -> 일대다)에는 다대다로 관계가 설정되면서 데이터가 엄청나게 뻥튀기 될 수 있습니다.
컬렉션을 페치조인할 경우 페이징 API를 사용할 수가 없는데 그 이유는 일대다의 관계에서 데이터가 뻥튀기 되면서 문제가 발생할 수 있습니다. 예를들어 데이터 조회 시 5개가 조회되었는데, 페이지 사이즈를 2로 설정해버리면 나머지 값들은 잘려버리기 때문에 사용하는 것은 위험하기 때문이죠. 이 경우 해결방법은 다대일의 경우로 조회를 하도록 만들어 버리면 된다고 합니다.
정리입니다.
제가 fetch join을 통해 가져온 데이터들을 보면, 필요없는 값들까지 모두 조회해서 가져옵니다. 좀더 성능 개선을 위해선 필요 데이터들만 조회해서 모두 가져오도록 DTO로 반환하는 것까지 적용하는 게 좋을듯 합니다. 이 부분은 차차 개선을 해보도록 하겠습니다.
사실 아직도 fetch join을 어떻게 사용할까.. 고민이 많이 되는 부분입니다. 조회 성능에서만 보면 될까? 어디까지 허용해서 데이터를 가져와야 할까? 필요한 데이터만 가져오도록 DTO 반환하는 방법은? 등등..
배울게 많지만 좀 즐거운것 같기도 하고.. 애매모호한 기분이네요. 그래도 fetch join이라는 것을 항상 머리속 키워드에 새겨두고 개발을 진행해 봐야겠습니다.
'개발 > Java' 카테고리의 다른 글
TaskScheduler를 이용한 주문 취소 구현해보기 (0) | 2024.11.27 |
---|---|
디버깅 (0) | 2024.11.25 |
Block vs Non-Block & Sync vs Async (0) | 2024.11.23 |
Thread Pool (0) | 2024.11.22 |
static method(정적 메서드) (1) | 2024.11.21 |