[스프링과 JPA활용 2편] 요약 정리2

sonnng·2023년 11월 3일
0

Spring

목록 보기
24/41

API 개발 고급

(2) 지연 로딩과 조회 성능 최적화

주문, 배송정보, 회원을 조회하는 API를 만들어 볼 것이다. 지연로딩으로 발생하는 성능문제를 단계적으로 해결해본다.

참고::지금부터 설명하는 내용은 정말 중요하며, JPA를 실무에서 사용하려면 100%이해해야한다.



간단한 주문조회 V1: 엔티티를직접노출

/*
* xToOne의 성능 최적화를 해보자
* -Order
* -Order->Member(ManyToOne)
* -Order->Delivery (OneToOne)
* 이러한 연관관계 관련 API를 생성할 것이다.
* */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;
    @GetMapping("/api/v1/simple-orders")
    public List<Order> orderV1(){
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }
}

😱 문제점1

현재 Order에 대한 리스트만 출력하려고 했는데, Order 클래스에는 @XToOne 연관관계 매핑이 형성되어있는 상태다. 이 경우 계속해서 서로를 호출하며 에러가 발생하게 된다.

@Entity @Getter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

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

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    /*
    * 1) cascade 쓰지않았을때:
    * persist(orderItemA)
    * persist(orderItemB)
    * persist(orderItemC)
    * persist(order)
    *
    * 2) cascade 쓰고 있을때:
    * persist(order)만 해도 orderItems까지 persist된다.
    * */

    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;
    /*
    * cascade 쓴다면:
    * persist(order) 만 해도 delivery까지 persist된다.
    * */
    private LocalDateTime orderDate; //주문시간

    @Enumerated(EnumType.STRING)
    private OrderStatus status; //주문 상태 order, cancel

    public void setMember(Member member){
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem){
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void setDelivery(Delivery delivery){
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    public void setOrderDate(LocalDateTime orderDate) {
        this.orderDate = orderDate;
    }

    public void setStatus(OrderStatus status) {
        this.status = status;
    }

    //==생성메서드==//
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems){
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for(OrderItem orderItem:orderItems){
            order.addOrderItem(orderItem);
        }
        order.setOrderDate(LocalDateTime.now());
        order.setStatus(OrderStatus.ORDER);
        return order;
    }

    //==비즈니스로직==//
    /*주문취소*/
    public void cancel(){
        if(delivery.getStatus() == DeliveryStatus.COMP){
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가합니다.");
        }

        this.setStatus(OrderStatus.CANCEL);
        for(OrderItem orderItem: orderItems){
            orderItem.cancel();
        }
    }

    //==조회로직==//
    /*전체 주문 가격 조회*/
    public int getTotalPrice(){
        return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
    }
}

😂 문제점1에 대한 해결방법

따라서 member ▶️ order와 orderItem ▶️ order를 호출하는 부분에 @JsonIgnore 어노테이션을 붙여야 무한 루프 관계를 끊어낼 수 있고 JSON에 표현하는 부분에 해당하지 않게 된다.



😱 문제점2


이렇게 보이는 것처럼 Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]; 라는 ByteBuddyInterceptor 관련 프록시 에러를 보이는 것을 확인할 수 있다.

😂 문제점2에 대한 해결방법

jackson 라이브러리는 스프링 내부에 포함된 라이브러리로, 찾아보니 다음과 같은 역할을 해주고 있다.


그런데, jackson라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야할지 몰라서 위와같은 에러가 발생하는 것이다.

➡️Hibernate5Module을 스프링 빈으로 등록하면 해결된다.

스프링부트 3.0미만이면 build.gradle에 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'와 Application 클래스에 아래의 빈을 추가로 등록해주면 된다.

	@Bean
	Hibernate5Module hibernate5Module(){
		return new Hibernate5Module();
	}

그러면 postman으로 응답 데이터를 받아보면~~

[
    {
        "id": 15,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T01:23:13.872511",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 22,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T01:23:13.95703",
        "status": "ORDER",
        "totalPrice": 220000
    },
    {
        "id": 36,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:04:49.109243",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 43,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:04:49.234908",
        "status": "ORDER",
        "totalPrice": 220000
    },
    {
        "id": 50,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:08:24.148089",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 57,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:08:24.243985",
        "status": "ORDER",
        "totalPrice": 220000
    },
    {
        "id": 64,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:15:06.16109",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 71,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:15:06.241141",
        "status": "ORDER",
        "totalPrice": 220000
    },
    {
        "id": 78,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:21:27.535452",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 85,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:21:27.634098",
        "status": "ORDER",
        "totalPrice": 220000
    },
    {
        "id": 92,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:30:16.032354",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 99,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:30:16.107897",
        "status": "ORDER",
        "totalPrice": 220000
    },
    {
        "id": 106,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:37:43.061378",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 113,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2023-11-03T15:37:43.152075",
        "status": "ORDER",
        "totalPrice": 220000
    }
]

이렇게 fetch가 LAZY대상인 프록시 엔티티는 NULL로 표현하게 된다. 기본적으로 위의 라이브러리를 추가하게 되면 초기화된 프로젝시 객체만 노출하고, 그렇지 않은 프록시 객체는 노출하지 않기 때문에 그렇다고 한다.

  • LAZY 프록시 객체를 초기화하는 방법
	@GetMapping("/api/v1/simple-orders")
    public List<Order> orderV1(){
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName(); //LAZY강제 초기화
            order.getDelivery().getAddress(); // " "
        }
        return all;
    }

이렇게 .getName()이나 getAddress()로 강제 초기화를 하면 필요한 객체만 LAZY로딩으로 초기화하여 값을 null이 아닌 실제 값을 추출할 수 있다.

🫸우리가 기억해야 할 점은?

1. 엔티티를 노출하지 않는 것에 집중하도록 한다!
2. 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭! 한곳을 @JsonIgnore 처리해야한다.
안그러면양쪽을서로호출하면서 무한루프가걸린다.



간단한 주문조회 V2: 엔티티를 DTO로 변환

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> orderV2(){
        /*
        * N+1 문제
        *
        * 1번의 쿼리 결과로 2개가 추가로 실행되었다.
        * 1+회원N(2)+배송N(2) = 총 5개의 쿼리 실행(최악의 경우)
        *
        * */
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<SimpleOrderDto> result = orders.stream().map(SimpleOrderDto::new)
                .collect(toList());
        return result;
    }

    @Data
    static 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(); //LAZY 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress(); //LAZY 초기화
        }
    }

😱 간단한 조회 v2 문제점

엔티티를 DTO로 변환하는 일반적인 방법이지만, 쿼리가 총 N+1번 발생한다. 다시 말해 order 조회 1번+order.getMember().getName()의 LAZY 조회 N번+order.getDelivery().getAddress()의 LAZY조회 N번으로 최악의 경우에 해당하게 된다.

여기서는 member 2명, order 각각 하나씩 주문했기 때문에 여기서는 1+2(memeber.getName())+2(delivery.getAddress())=총 5개의 쿼리가 실행되어 N+1문제가 발현된다.

0개의 댓글