[우아한테크코스] 10월 22일 TIL
[JPA] 최적화
기존 상황
-
n+1 발생 상황
/admin/api/reservations
호출 시 발생하는 쿼리문은 아래와 같습니다.Hibernate: select reservatio0_.id as id1_3_, reservatio0_.date as date2_3_, reservatio0_.description as descript3_3_, reservatio0_.end_time as end_time4_3_, reservatio0_.password as password5_3_, reservatio0_.space_id as space_id8_3_, reservatio0_.start_time as start_ti6_3_, reservatio0_.user_name as user_nam7_3_ from reservation reservatio0_ limit ? Hibernate: select space0_.id as id1_4_0_, space0_.area as area2_4_0_, space0_.color as color3_4_0_, space0_.description as descript4_4_0_, space0_.map_id as map_id13_4_0_, space0_.name as name5_4_0_, space0_.available_end_time as availabl6_4_0_, space0_.available_start_time as availabl7_4_0_, space0_.enabled_day_of_week as enabled_8_4_0_, space0_.reservation_enable as reservat9_4_0_, space0_.reservation_maximum_time_unit as reserva10_4_0_, space0_.reservation_minimum_time_unit as reserva11_4_0_, space0_.reservation_time_unit as reserva12_4_0_, map1_.id as id1_0_1_, map1_.map_drawing as map_draw2_0_1_, map1_.map_image_url as map_imag3_0_1_, map1_.member_id as member_i5_0_1_, map1_.name as name4_0_1_, member2_.id as id1_1_2_, member2_.email as email2_1_2_, member2_.oauth_provider as oauth_pr3_1_2_, member2_.organization as organiza4_1_2_, member2_.password as password5_1_2_ from space space0_ inner join map map1_ on space0_.map_id=map1_.id inner join member member2_ on map1_.member_id=member2_.id where space0_.id=? Hibernate: select space0_.id as id1_4_0_, space0_.area as area2_4_0_, space0_.color as color3_4_0_, space0_.description as descript4_4_0_, space0_.map_id as map_id13_4_0_, space0_.name as name5_4_0_, space0_.available_end_time as availabl6_4_0_, space0_.available_start_time as availabl7_4_0_, space0_.enabled_day_of_week as enabled_8_4_0_, space0_.reservation_enable as reservat9_4_0_, space0_.reservation_maximum_time_unit as reserva10_4_0_, space0_.reservation_minimum_time_unit as reserva11_4_0_, space0_.reservation_time_unit as reserva12_4_0_, map1_.id as id1_0_1_, map1_.map_drawing as map_draw2_0_1_, map1_.map_image_url as map_imag3_0_1_, map1_.member_id as member_i5_0_1_, map1_.name as name4_0_1_, member2_.id as id1_1_2_, member2_.email as email2_1_2_, member2_.oauth_provider as oauth_pr3_1_2_, member2_.organization as organiza4_1_2_, member2_.password as password5_1_2_ from space space0_ inner join map map1_ on space0_.map_id=map1_.id inner join member member2_ on map1_.member_id=member2_.id where space0_.id=? Hibernate: select spaces0_.map_id as map_id13_4_0_, spaces0_.id as id1_4_0_, spaces0_.id as id1_4_1_, spaces0_.area as area2_4_1_, spaces0_.color as color3_4_1_, spaces0_.description as descript4_4_1_, spaces0_.map_id as map_id13_4_1_, spaces0_.name as name5_4_1_, spaces0_.available_end_time as availabl6_4_1_, spaces0_.available_start_time as availabl7_4_1_, spaces0_.enabled_day_of_week as enabled_8_4_1_, spaces0_.reservation_enable as reservat9_4_1_, spaces0_.reservation_maximum_time_unit as reserva10_4_1_, spaces0_.reservation_minimum_time_unit as reserva11_4_1_, spaces0_.reservation_time_unit as reserva12_4_1_ from space spaces0_ where spaces0_.map_id=?
요약하자면 A: reservation findAll 을 위한 쿼리 1개, B: reservation 내 space를 조회하기 위한 쿼리 2개(space 1, 2에 대한), C: reservation 내 space 내 map을 조회하기 위한 쿼리 1개가 발생하고 있습니다. 이는 space가 늘어나면 B의 쿼리도 늘어나고, map이 늘어나는대로 C의 쿼리도 늘어나는 상황입니다.
이것이 바로 n+1 문제!
쿼리가 날아가는 것 하나하나는 db에 부하가 가는 것이고, 최소한으로 줄일 수 있다면 좋겠죠??
-
불필요한 쿼리 발생
기존의 findSpace 로직은 아래와 같습니다.
@Transactional(readOnly = true) public SpaceFindDetailResponse findSpace( final Long mapId, final Long spaceId, final LoginEmailDto loginEmailDto) { Map map = maps.findById(mapId) .orElseThrow(NoSuchMapException::new); Member manager = members.findByEmail(loginEmailDto.getEmail()) .orElseThrow(NoSuchMemberException::new); validateManagerOfMap(map, manager); Space space = map.findSpaceById(spaceId) //map.getSpace 하는 부분 .orElseThrow(NoSuchSpaceException::new); return SpaceFindDetailResponse.from(space); }
이 경우 이미 join을 통해 member, space를 가지고 옴에도 불구하고 findByEmail에 의해 쿼리가 또 발생하는 것입니다.
위와 같이 service 내에 불필요한 쿼리를 발생시키는 경우가 더러 있었습니다.
문제 발생 이유
- n+1 문제
생각해보면 reservation과 space는 다대일 매핑으로 기본 FetchType이 eager
입니다.
아래와 같이 글로벌 페치 전략 또한 주지 않았던 것을 확인할 수 있습니다.
@ManyToOne
@JoinColumn(name = "space_id", foreignKey = @ForeignKey(name = "fk_reservation_space"), nullable = false)
private Space space;
그렇다면 왜 reservation 조회와 동시에 space를 조회해오지 않은걸까요? 그 이유는 JPA가 JPQL을 분석해서 SQL 쿼리를 작성할 대는 페치전략에는 관심이 없고 eager든 lazy든 자신에게 주어진 JPQL에 충실하게 쿼리문을 작성합니다. findAll 호출 시 흐름이 아래와 같습니다.
select r from Reservation r
로부터select * from Reservation;
실행- db로부터 받은 응답에따라 Reservation 엔티티 생성
- 만들어낸 Reservation 엔티티를 보니 eager네?
- 영속성 컨텍스트로부터 space를 찾는다. 없으면
select * from space where id = ?
실행
따라서 쿼리가 n+1개 발생하는 것입니다.
-
불필요한 쿼리 발생 문제
양방향 매핑을 적용하면서 놓친 부분인 것 같습니다.
개선 방안
-
n+1 문제
- Fetch join을 사용해서 하나의 쿼리문을 통해 reservation과 space 모두 호출하도록 한다.
- @BatchSize를 지정해 sql in절로
select * from reservation where space_id in (?, ?, ?, ?, ?)
조회하도록 한다. - @Fetch(FetchMode.SUBSELECT)를 이용해 서브쿼리로 정보를 가져오도록 한다.
이 상황의 경우 모든 reservation을 조회하고, space까지 최대한 동시에 가져오는 것이 목적이었으므로 1번 방안을 택했습니다.
@Query(value = "select r from Reservation r inner join fetch r.space s " + "inner join fetch s.map m " + "inner join fetch m.member " + "order by r.id", countQuery = "select count(r) from Reservation r") Page<Reservation> findAllByFetch(Pageable pageable);
최대한 기존 로직을 안건드리고 싶어서 Page 객체를 사용하고자 countQuery를 주었습니다.
Pagination API 를 사용하는 AdminService에 사용되는 findAll 로직들에 사용하였고, 각 상황에 맞게 진짜 사용되는 경우에만 join하도록 했습니다. (member는 join문 없음)
-
inner join vs join
inner join 과 join은 동작이 같습니다.
하지만 inner join이 우리가 보기에 더 명시적이라고 생각해 inner join을 사용했습니다.
-
inner join vs inner join fetch
jpql에서 그냥 join 하면 그 엔티티만 가져오고 연관관계는 프록시인데 fetch join을 하면 연관관계까지 엔티티로 가져옵니다. 아래는 id로 map을 가져오고 map이 가진 space를 조회하는 것과 관련한 여러 상황에 대해 쿼리가 어떻게 날라가는지 테스트한 결과입니다.
-
findById
select map inner join member 이후 select space 쿼리 발생
-> member는 ManyToOne eager여서 join
-
그냥 join
select map 이후 select member, select space 쿼리 발생
-
fetch join
select map 이후 select member 쿼리 발생
-
개선 이후
-
n+1 발생 상황
Hibernate: select reservatio0_.id as id1_3_0_, space1_.id as id1_4_1_, map2_.id as id1_0_2_, member3_.id as id1_1_3_, reservatio0_.date as date2_3_0_, reservatio0_.description as descript3_3_0_, reservatio0_.end_time as end_time4_3_0_, reservatio0_.password as password5_3_0_, reservatio0_.space_id as space_id8_3_0_, reservatio0_.start_time as start_ti6_3_0_, reservatio0_.user_name as user_nam7_3_0_, space1_.area as area2_4_1_, space1_.color as color3_4_1_, space1_.description as descript4_4_1_, space1_.map_id as map_id13_4_1_, space1_.name as name5_4_1_, space1_.available_end_time as availabl6_4_1_, space1_.available_start_time as availabl7_4_1_, space1_.enabled_day_of_week as enabled_8_4_1_, space1_.reservation_enable as reservat9_4_1_, space1_.reservation_maximum_time_unit as reserva10_4_1_, space1_.reservation_minimum_time_unit as reserva11_4_1_, space1_.reservation_time_unit as reserva12_4_1_, map2_.map_drawing as map_draw2_0_2_, map2_.map_image_url as map_imag3_0_2_, map2_.member_id as member_i5_0_2_, map2_.name as name4_0_2_, member3_.email as email2_1_3_, member3_.oauth_provider as oauth_pr3_1_3_, member3_.organization as organiza4_1_3_, member3_.password as password5_1_3_ from reservation reservatio0_ inner join space space1_ on reservatio0_.space_id=space1_.id inner join map map2_ on space1_.map_id=map2_.id inner join member member3_ on map2_.member_id=member3_.id order by reservatio0_.id limit ?
reservation과 space, map, member 모두가 inner join 되어서 조회되는 것을 확인할 수 있었습니다.
-
불필요한 쿼리 발생
db는 한번만 쿼리를 보내고도 연관된 객체들을 사용할 수 있도록 fetch join을 이용하는 쿼리를 작성해 사용하도록 했습니다.
이렇게 함에 따라서 ManyToOne의 경우에도 조회 시 eager로 반드시 join을 해올 필요가 없어졌고, ManyToOne의 FetchType을 lazy로 변경해 불필요한 join문이 날아가지 않도록 했습니다. 예외로, Member↔Map의 경우에는 map을 가져와 member가 알맞는지 확인하는 로직이 아닌 경우가 없어 eager로 두었습니다.
이거는 service 로직의 변화를 보면 이해가 쉬울 듯 합니다.
참고로, map <-> space의 경우 space가 없는 경우에도 map이 조회되어야 하므로 left outer join을 사용했습니다.
@Query("select distinct m from Map m inner join fetch m.member left outer join fetch m.spaces where m.id = :id") Optional<Map> findByIdFetch(Long id);
주의할 점은, 2개 이상의 OneToMany에 Fetch join을 걸면
MultipleBagFetchException
이 발생한다는 것입니다. 우리의 경우에는 ManyToOne과 같은 단일 관계의 경우에만 다중 fetch를 걸거나, Member<->Map<->Space 처럼 OneToMany, ManyToOne 이렇게 다중 fetch를 걸었는데 member 상위에 OneToMany 연관관계가 더 있었더라면 fetch를 한번 더 거는 것은 안된다는 의미입니다. 관련 조졸두님