본문 바로가기
🔗 JPA

jpa 지연로딩 사용 주의점 (jpa 지연 로딩과 조회 성능 최적화)

by 비타민찌 2022. 10. 13.
728x90

주문 API를 만들며 만난 jpa 지연로딩 문제와 주의점.

XToOne 성능 최적화에 대한 이야기이다.

 

우선 주문 API는 다음과 같은 구조다. (전부 XToOne 관계)

order - member (ManyToOne)
order - delivery (OneToOne)
order - orderitem : XToMany

 

    @GetMapping("/orders")
    public List<Order> orders() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName(); 
            order.getDelivery().getStatus();
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getProduct().getTitle());
        }
        return all;
    }

 

주의할 점 1

# 엔티티 노출 X

엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 것 꼭 한 쪽을 @JsonIgnore 처리해야한다.

아니면 서로 호출해서 무한루프가 걸림. 물론 DTO로 변환해야 하지만..

 

그래서 @JsonIgnore 처리를 하면?

"trace": "org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type,
class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->com.minji.vanillashop.domain.order.entity.Order["orderItems"]-

 

여전히 에러가 난다. 무슨 에러일까?

Order entity를 보면 다음과 같이 되어 있는데..

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

 

여기서 지연로딩을 살펴보자.

지연 로딩은 필요한 값을 디비에서 끌고 오는게 아니다. 프록시 기술로 뭔가 아래처럼

 private Member member = new ByteBuddyInterceptor();

가짜로 넣어두고, 필요한 값을 꺼내려고 맴버 객체에 손을 대면 그 때 sql을 날려 값을 채워주는 식으로 동작한다. (이걸 프록시를 초기화 한다고 한다.) 이 과정에서 json이 member 객체를 꺼내가려고 하는 순간! 이게 순수 객체가 아니라 바이트 버디 인 것이다... 그래서 이런 에러가 발생한다. 이를 해결해주는 라이브러리로 해결할 수도 있지만, 사실 Entity를 노출하지 않으면 일어나지도 않을 문제다.

 

 

주의할 점 2

# api 스펙에 불필요한 정보 노출

api 스펙은 필요한 것만. 간단하게! 하자.

사용하지 않는 연관 정보까지 가지고 오니까 성능 저하도 생기고,

이렇게 안하면 실무 운영이 헬이 된다..

 

 

주의할 점 3

# 그렇다고 해서 즉시로딩(Eager)을 사용하지 말자.

Eager 로 해서 성능 최적화가 가능할때는 em.find로 식별자 하나로 할 때 정도다.

findAllByString 같이 JPQL이 날아갈땐 관련된 애들 쿼리를 다 날리기 때문에 성능 튜닝이 매우 힘들다.

지연로딩이 기본이고, 성능 최적화를 위해서 페치 조인을 사용하자.

 

코드를 아래처럼 수정했다.

    @GetMapping("/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(toList());

        return result;
    }
    
    @Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate; //주문시간
        private OrderStatus orderStatus;
        private List<OrderItemDto> orderItems;


        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            orderItems = order.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(toList());
        }
    }

그런데 지연로딩을 사용하다보면 로딩은 쿼리가 너무 많이 나가는 것을 볼 수 있다.

 

 

주의할 점 4

# LAZY 로딩 n+1 문제

사실 1+N이다. 만약 위에서 주문이 10개면, 최악의 경우 21개의 쿼리가 나간다. ( 1 + member N + delivery N) 쿼리가 많이 나가면 네트워크 작업이 많으니 성능 저하 문제가 생기는데, 이 문제를 아래처럼 fetch join으로 해결할 수 있다.

 

# Fetch join

[OrderRepository]

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();
    }

 

[OrderController]

@GetMapping("/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(toList());

        return result;
    }

 

그래서 정리하자면..

# 쿼리 방식 선택 권장 순서

1. 엔티티를 DTO로 변환 (유지 보수하기 좋음)

2. fetch join으로 성능 최적화. (여기까지 하면 보통 )

3. 2까지 했는데 안되면 DTO 직접 조회하는 방식 (포스팅에 정리안함)

사실 성능은 select절이 아니라 join, where 쪽에서 많이 일어나는데, 그래서 2와 3은 특수한 상황이 아니라면 큰 성능 차이가 없다. 특수한 상황이란 고객 트래픽이 실시간으로 마구 쏟아지는 api가 (예를들어 admin 페이지?)

4. 네이티브 SQL이나.. SQL을 직접 사용 (포스팅에 정리안함)

 

 

 

 

 

728x90

댓글