이전글 : ToMany 관계 컬렉션 성능 최적화
SQL 쓰듯이 데이터를 한줄밖에 못넣기 때문에, 일대다 관계인 컬렉션은 생성자 파라미터로 데이터를 넣을 수 없어서, 컬렉션을 제외하고 이전과 동일하게 DTO로 조회한다.
이후 반복루프를 돌면서 컬렉션도 동일하게 DTO로 조회 후 데이터를 채워준다.
Query: 루트 1번, 컬렉션 N 번 실행 (N + 1 문제)
ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리한다.
이런 방식을 선택한 이유는 다음과 같다.
ToOne 관계는 조인해도 데이터 row 수가 증가하지 않는다. ToMany(1:N) 관계는 조인하면 row 수가 증가한다.
row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.
Query: 루트 1번, 컬렉션 1번
ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem 을 한꺼번에 조회
orderId의 리스트를 생성하여 파라미터 인자로 넣고, 그 메서드에서는 orderId를 가져올때 위와 다르게 in절로 한번에 가져온다. → 루프를 돌면서 쿼리를 날리지않고, 쿼리 한번으로 컬렉션리스트를 메모리로 가져와서 매칭하여 값을 세팅해준다. 결과적으로 쿼리 2번으로 최적화 할 수 있다.
* MAP을 사용해서 매칭 성능 향상(O(1))
in절로 조회한 컬렉션 리스트를 그냥 써도 되지만, 좀 더 최적화와 코드 작성을 편하게 하기위해 stream을 써서 map으로 변환해준다.
order와 orderItem을 조인해서 한방 쿼리로 SQL 데이터를 한줄로 가져올 수 있게 한다.
루트 엔티티의 필드와 컬렉션 필드를 모두 가지고 있는 DTO를 조회해준다.
쿼리는 한번이지만 조인을 했기때문에 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가된다. → 페이징 처리가 불가능하다
또한, API 스펙을 이전과 같은 DTO로 반환해야한다면, 직접 코드로 루프를 돌려서 중복을 제외하고, 원하는 Dto 형식에 맞게 넣어주어야한다.
장점은 쿼리가 한번이지만, 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 보다 더 느릴수도 있다. 또한, 애플리케이션에서 추가 작업이 크고, DB 중복데이터 추가로 order를 기준으로하는 페이징은 불가능하다.
* groupBy할때 다른객체를 묶을때 어떤걸 묶을지 알려주어야 하는데, 기준을 Dto에 @EqualsAndHashCode(of = “orderId”) 와 같이 기준을 정해야한다.
분리된 orderItem을 @EqualsAndHashCode의 기준으로 묶는다.
* 엔티티 조회 방식은 페치 조인이나, hibernate.default_batch_fetch_size , @BatchSize 같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다. 반면에 DTO를 직접 조회하는 방식은 성능을 최적화 하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다
→ fetch join이나 BatchSize 정도로 충분히 최적화가 가능하다!!
이정도로 해결이 안된다면, 서비스가 트래픽이 많은 정도라 캐시를 사용해야 할 것이다.
* 엔티티는 직접 캐싱을 하면안된다! 영속성 컨텍스트에 의해 상태가 관리되기 때문에, 캐시에 잘못 올라가면 상당히 복잡해지기 때문에, 캐시한다면 무조건 DTO로 변환해서 DTO를 캐시해야한다. 엔티티를 캐시하는 전략으로 Hibernate 2차 캐시가 있지만, 실무에 적용하기 까다롭다.
개발자는 성능 최적화와 코드 복잡도 사이에서 고민 해봐야 한다.
항상 그런 것은 아니지만, 보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고간다.
엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다. 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 고민해봐야한다.
DTO 조회 방식의 선택지
DTO로 조회하는 방법도 각각 장단이 있다. V4, V5, V6에서 단순하게 쿼리가 1번 실행된다고 V6이 항상 좋은 방법인 것은 아니다.
V4는 코드가 단순하다. 특정 주문 한건만 조회한다면 이 방식을 사용해도 성능이 잘 나온다. 예를 들어서 조회한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.
V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 대신에 이것을 최적화한 V5 방식을 사용해야 한다. 예를 들어서 조회한 Order 데이터가 1000건인데, V4 방식을 그대로 사용하면, 쿼리가 총 1 + 1000번 실행된다. 여기서 1은 Order 를 조회한 쿼리고, 1000은 조회된 Order의 row 수다.
V5 방식으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성능 차이가 날 수 있다.
V6는 완전히 다른 접근방식이다. 쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페이징이 불가능하다. 실무에서는 이정도 데이터면 수백이나, 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다. 그리고 데이터가 많으면 중복 전송이 증가해서 V5와 비교해서 성능 차이도 미비하다.