본문 바로가기
기록/인프런

실전! 스프링 부트와 JPA 활용2 - 지연 로딩과 조회 성능 최적화

by 신발사야지 2023. 5. 8.

 
지금부터 설명하는 내용은 정말 중요합니다. 실무에서 JPA를 사용하려면 100% 이해해야 합니다.
안그러면 엄청난 시간을 날리고 강사를 원망하면서 인생을 허비하게 됩니다.
 
XtoOne(ManyToOne, OneToOne) 관계에서는 성능을 어떻게 최적화 하는가?
 
양방향 연관관계 주의점

  • 양방향 연관관계일 때 한 쪽은 @JsonIgnore 해줘야한다 → 무한루프 발생
  • Fetch.Lazy 이면 class org.hibernate.proxy.pojo.bytebuddy 에서 proxy 객체를 만들어서 대신 넣어놓는다, 문제는 뭐냐면 Jackson 이 bytebuddy 를 변환할 수 없어서 에러가 남
  • Hibernate5Module 을 설치하면 해결 가능함
  • 그런데 결과적으로 Entity 를 외부에 노출하는거 자체가 잘못된 방식이기 때문에 그냥 보여주는거지 사실 이렇게 하면 안된다
  • 지연로딩(LAZY) 를 피하기 위해 즉시 로딩(EAGER) 으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워진다. 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라!

DTO 를 사용해서 Entity 를 직접 노출하지 않아도 N + 1이 발생한다
— 멤버조회 쿼리

List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<SimpleOrderDto> result = orders.stream()
                .map(SimpleOrderDto::new)
                .collect(Collectors.toList());

select
        order0_.order_id as order_id1_6_,
        order0_.delivery_id as delivery4_6_,
        order0_.member_id as member_i5_6_,
        order0_.order_date as order_da2_6_,
        order0_.status as status3_6_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id limit ?

select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.user_name as user_nam5_4_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?

select
        delivery0_.delivery_id as delivery1_2_0_,
        delivery0_.city as city2_2_0_,
        delivery0_.street as street3_2_0_,
        delivery0_.zipcode as zipcode4_2_0_,
        delivery0_.status as status5_2_0_ 
    from
        delivery delivery0_ 
    where
        delivery0_.delivery_id=?

select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.user_name as user_nam5_4_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?

select
        delivery0_.delivery_id as delivery1_2_0_,
        delivery0_.city as city2_2_0_,
        delivery0_.street as street3_2_0_,
        delivery0_.zipcode as zipcode4_2_0_,
        delivery0_.status as status5_2_0_ 
    from
        delivery delivery0_ 
    where
        delivery0_.delivery_id=?

ORDER → SQL 1번 → ORDER 2개 조회 → 2개에 대한 멤버, 주소 쿼리가 2번씩 실행
멤버가 2개라서 1 + 2N 만큼 조회쿼리가 발생한다.
 
ORDER 가 100개 조회 되면 201 번 쿼리가 실행 됨
(물론 동일한 멤버가 주문한 경우, 멤버 조회 쿼리에서 영속성 컨텍스트에 해당 유저가 영속상태가 되어 캐시 비슷하게 동작하여 멤버 쿼리가 1번만 실행된다, 그런데 모두 동일한 유저만 주문할리는 없으니…)
 

N+1 문제를 해결하기 위해서는 fetch join 을 사용해주면 된다

/*
    order 를 조회하면서 member 와 delivery 도 같이 한 번에 가져옴
    join fetch 는 jpql 에만 있는 문법
     */
    public List<Order> findAllWithMemberDelivery() {
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m" +
                        " join fetch o.delivery d",
                Order.class
        ).getResultList();
    }

 

select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        member1_.city as city2_4_1_,
        member1_.street as street3_4_1_,
        member1_.zipcode as zipcode4_4_1_,
        member1_.user_name as user_nam5_4_1_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.status as status5_2_2_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id

fetch join 을 사용하면 쿼리 한 번만 날라간다!
(inner join 은 left join fetch 같은 식으로 바꾸면 outer join 으로 바꿀 수 있다)
여러분 fetch join 은 아주 적극적으로 사용하셔야 되요.
 

JPA 에서 DTO로 바로 조회

public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery("select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.userName, o.orderDate, o.status, d.address) " +
                " from Order  o" +
                " join o.member m" +
                " join o.delivery d", OrderSimpleQueryDto.class).getResultList();
    }
select
        order0_.order_id as col_0_0_,
        member1_.user_name as col_1_0_,
        order0_.order_date as col_2_0_,
        order0_.status as col_3_0_,
        delivery2_.city as col_4_0_,
        delivery2_.street as col_4_1_,
        delivery2_.zipcode as col_4_2_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id

원하는 컬럼만 조회해서 네트워크 사용량이 줄어든다. 대신에 범용성이 떨어진다
 
또한 V4 는 엔티티를 조회한게 아니기 때문에 비지니스 로직에서 값을 변경할 수 없다
 
그리고 V4 코드가 더 더럽다. + V4 는 냉정하게 말해서 물리적으로 계층이 나누어져있지만 논리적으로 계층이 다 깨져있는것, 레포지토리가 화면에 의존하고 있는 것, API 스팩이 바뀌면 레포지토리를 뜯어고쳐야 한다
 
그리고 대부분의 성능은 JOIN 이나 INDEX 문제이지, 필드가 조금 준다고 해서 성능이 엄청 향상되지 않는다. 물론 필드가 엄청 많거나 특정 컬럼에 큰 데이터가 들어있는 경우에는 사실 V4를 쓰는 것을 고려해야 한다. + 고객 트래픽이 엄청난 경우
 
 
킹영한 선생님의 경우에는 repository package 에 최적화 쿼리를 위한 새로운 pacakge 를 만든다.
repository.order.simplequery 같은 식으로,
repository 는 엔티티를 조회하는데 사용하고, V4 같은 쿼리는 따로 package를 만들어서 관리 → 유지보수성이 좋아진다
 
 

쿼리 방식 선택 권장 순서

  1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
  2. 필요하면 fetch join 으로 성능을 최적화 한다. → 대부분의 성능 이슈가 해결된다.
  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다. (QueryDsl 은?)

 
 
다음 시간에는 OneToMany(컬렉션 조회) 에 대해서 성능최적화 하는 방법에 대해서 알아보겠습니다.