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

이재표·2023년 11월 19일
0

이번 장에서는 지연 로딩 때문에 발생하는 성능문제 원인들중 ToOne방식의 문제점들을 단계적으로 해결해 보자!

지연로딩 : 비즈니스 로직상 연관관계가 걸려있지만 당장 필요하지 않은 엔티티를 프록시로 조회하는 방법

첫번째 방식(V1)

첫번째 문제는 엔티티로 바로 반환하는 경우이다.

@GetMapping("/api/v1/sample-orders")
    public List<Order>orderV1(){
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }

반환하고자 하는 엔티티에 양방향 관계의 엔티티가 존재한다면 서로 계속 타고들어가 무한으로 객체가 반환하게되는 문제가 발생합니다. 따라서 양방향의 둘중 하나에 @JsonIgnore를 통해 반환을 끊는 부분을 작성해줘야합니다.

또 다른 문제는 Type definition error 이다.
fetch가 Lazy로 되어있기때문에 DB에서 해당 객체의 정보만 가져오고 연관되어있는 객체의 데이터는 가져오지 않습니다. 이때 연관 객체에 ByteBuddy라이브러리를 통해프록시 객체를 넣어줍니다. 하지만 이때 반환하는 과정에서 해당 ByteBuddy 객체를 반환할수 없어 에러가 나게됩니다.

물론 해당 객체를 반환하지 말라는 하이버네이트5 모듈을 사용하면 되지만 근데 그냥 엔티티를 바로 반환하지 맙시다!!

또한 엔티티가 변경되면 API스펙이 전부 변경되고, 필요없는 데이터까지 반환해야하기 때문에 성능상에 문제가 생길수 있기에 무조간 DTO를 이용하여 반환해야합니다!

두번째 방식(V2)

위와같이 엔티티를 그대로 반환하는것이 아닌 DTO를 이용하는 방식입니다.

@GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto>orderV2(){
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<SimpleOrderDto> result = orders.stream()
                .map(SimpleOrderDto::new)
                .collect(toList());
        return result;
    }

    @Data
    private class SimpleOrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        public SimpleOrderDto(Order order){
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address=order.getDelivery().getAddress();
        }
    }

DTO를 반환하면 엔티티가 변해도 DTO는 그대로이기 때문에 API스펙이 변할 걱정이 없습니다! 하지만 V1과V2가 공통적으로 가지고 있는 문제가 있다. 바로 지연로딩으로 인해 N+1문제가 발생하여 데이터베이스 쿼리가 너무 많이 호출되는 문제입니다.

N+1문제가 발생되는데, 그렇다고 EAGER을 쓰면 되지 않나?라고 생각할수 있지만, 옵션하나를 변경한것으로 예상과 다른 쿼리가 발생하여 성능상 문제가 발생할수 있습니다.

N+1문제 : 한개의 엔티티를 조회했을때 연관되어 있는 객체를 조회하기 위해 연관된 객체의 개수만큼 더 쿼리를 날리게 되는 문제

세번째 방식(V3)

페치조인을 통해 많은 쿼리가 나가는것을 해결해보자!
비즈니스 로직상 필요한 연관관계 객체들을 LAZY옵션을 무시하고, 프록시가 아닌 진짜 객체를 주입하여 한번에 가져오는 방법입니다.

//=> Controller 코드
@GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto>orderV3(){
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        return orders.stream().map(o -> new SimpleOrderDto(o)).collect(toList());
    }

//=> Repository 코드
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();
    }

JPQL코드를 확인하면 member와 delivery객체를 페치조인한 것을 볼수있다. (sql에는 패치조인이 없고, jpql를 위한 조인이다.)
코드를 실행하면 쿼리가 한번에 페치조인된 객체들을 주입하는 것을 볼수 있다. 가장 성능향상에 유용한 방법으로 적극적으로 활동하는것이 좋다.

네번째 방식(V4)

엔티티 조회후에 애플리케이션에서 DTO로 변환하는것이 아닌 jpa를 통해 엔티티를 DTO로 조회하는 방법을 봐보자

//=> Controller 코드
@GetMapping("/api/v4/simple-orders")
    public List<SimpleOrderQueryDto>findOrderDtos(){
        return orderRepository.findOrderDtos();
    }
   
//=> Repository 코드
public List<SimpleOrderQueryDto> findOrderDtos() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.SimpleOrderQueryDto(o.id,m.name,o.orderDate,o.status,d.address) from Order o" +
                        " join o.member m" +
                        " join o.delivery d", SimpleOrderQueryDto.class
        ).getResultList();
    }

쿼리를 살펴보면 DTO에서 원하는 값만 쿼리로 가져오기 때문에 V3와 똑같이 쿼리가 1번만 나가지만 네트워크 성능상 V4가 더 좋을것이다

그러면 V4가 더 좋은거 아닌가라 생각할수 있지만 각 방법에는 trade-off가 있다.

V3는 외부의 모습을 건드리지 않은 상태로 내부에 원하는것만 패치조인으로 가져와서 조금 성능이 안좋을수 있지만 재사용성이 좋다. 반면, V4는 실제 sql짜듯시 해서 가져온것인데 특정 DTO에 맞춰졌기 때문에 재사용성이 좋지 않다.

보통 특정 화면을 구성하는 화면에서 v4와 같이 사용하고 유지보수성을 높이기 위해 query를 짜는 레포지토리를 따로 만들어서 사용하고(orderSimpleQueryRepository), 보통 repository는 순수 엔티티를 조회하는 용도로 사용한다.

0개의 댓글