[우아한테크코스] 10월 22일 TIL

5 minute read

[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 호출 시 흐름이 아래와 같습니다.

  1. select r from Reservation r 로부터 select * from Reservation; 실행
  2. db로부터 받은 응답에따라 Reservation 엔티티 생성
  3. 만들어낸 Reservation 엔티티를 보니 eager네?
  4. 영속성 컨텍스트로부터 space를 찾는다. 없으면 select * from space where id = ? 실행

따라서 쿼리가 n+1개 발생하는 것입니다.

  • 불필요한 쿼리 발생 문제

    양방향 매핑을 적용하면서 놓친 부분인 것 같습니다.

개선 방안

  • n+1 문제

    1. Fetch join을 사용해서 하나의 쿼리문을 통해 reservation과 space 모두 호출하도록 한다.
    2. @BatchSize를 지정해 sql in절로 select * from reservation where space_id in (?, ?, ?, ?, ?) 조회하도록 한다.
    3. @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를 조회하는 것과 관련한 여러 상황에 대해 쿼리가 어떻게 날라가는지 테스트한 결과입니다.

      1. findById

        select map inner join member 이후 select space 쿼리 발생

        -> member는 ManyToOne eager여서 join

      2. 그냥 join

        select map 이후 select member, select space 쿼리 발생

      3. 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를 한번 더 거는 것은 안된다는 의미입니다. 관련 조졸두님