Backend/Spring Boot

[API 개발 고급] 지연 로딩과 조회 성능 최적화

Zayson 2022. 7. 7. 12:28

“김영한 강사님의 JPA 활용편 2 - API 개발과 성능 최적화"를 듣고 간단하게 정리하기”

 

  • 양방향 연관관계에서 직접 데이터를 반환하는 경우 서로가 서로를 참조하기 때문에 무한루프가 발생한다.
Order -> Member -> Order -> Member ... 무한루프발생!

 

  • 양방향 연관관계가 매핑된 엔티티 한 쪽에는 @JsonIgnore를 사용한다.
  • 지연로딩으로 설정된 값은 DB에서 가져오지 않고 가짜 Proxy 객체를 생성하고 실제 객체에 접근하고자 할때 그 때 프록시가 쿼리를 날려서 DB에서 값을 가져온다.
  • Hibernate5Module와 @JsonIgnore를 이용해 엔티티를 넘긴 경우 무한루프 없이 데이터를 조회해 오는 것이 가능하다.
  • 지연 로딩을 피하기 위해서 즉시 로딩 (EAGER)로 변경하지 않는다. 연관관계가 필요없는 경우에도 데이터를 조회하기 때문에 성능 튜닝이 어려워진다.
[
    {
        "id": 4,
        "member": null,     // 지연 로딩
        "orderItems": null,
        "delivery": null,
        "orderDate": "2022-07-06T10:44:43.457581",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 11,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2022-07-06T10:44:43.511754",
        "status": "ORDER",
        "totalPrice": 220000
    }
]

 

  • 하지만, 엔티티를 직접 프레젠테이션 계층에 넘기는 것은 하면 안된다!!! (V1의 문제점)
  • DTO로 변환해서 화면으로 응답한다. (V1의 해결책)
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV2() {
		// Order SQL 1번 실행 -> 결과 주문수 2개
    // N + 1문제라고 한다!! 첫번째 쿼리의 결과로 N번의 쿼리가 추가 실행된다.
    // N + 1 : 주문 1 + 회원 N + 배송 N
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());

		// Member, Delivery 각 두번 씩 조회
    return orders.stream()
            .map(order -> new SimpleOrderDto(order))
            .collect(Collectors.toList());
}

@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가 초기화 : MemberId를 가지고 영속성 컨텍스트를 조회한 후 없는 경우 DB에 쿼리를 날린다.
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();
    }
} 

 

  • DTO로 반환하는 경우 엔티티의 컬럼이 바뀌거나 하더라도 컴파일 단계에서 에러 체크가 가능하다.
  • V1, V2 모두 지연 로딩으로 인한 데이터베이스 쿼리가 너무 많이 호출되는 단점이 있다. Member, Order, Delivery 3개의 엔티티를 한번에 호출한다.
  • 영속성 컨텍스트가 주문 조회 SQL을 먼저 날린다. 쿼리는 한개만 날아간다.
  • 쿼리를 통해 주문이 2개가 조회되면, 각 주문 당 지연로딩에 따라 Member의 Name, Delivery의 Address 쿼리가 1번씩 추가 실행된다.
  • 즉, 주문 쿼리 1번에 회원, 배송 테이블의 쿼리가 N번 추가 조회되는 N + 1문제가 발생한다.
  • 지연 로딩은 영속성 컨텍스트를 조회하기 때문에 이미 조회된 경우 쿼리를 생략한다.
  • 따라서, 성능 향상을 위해서 fetch join을 이용한다. (V1, V2 : 지연 로딩으로 인한 N + 1의 해결책)

 

public List<Order> findAllWithMemberDelivery() {
    // Fetch Join을 이용 -> Order를 가져올 때 객체 그래프를 탐색해서 Member와 Delivery를 한번에 가져온다.
    return em.createQuery("select o from Order o" +
            " join fetch o.member m" +
            " join fetch o.delivery d", Order.class
    ).getResultList();
}
  • Fetch join을 이용해 주문(SQL 1번)을 가져올 때 객체 그래프를 탐색해 지연로딩되는 데이터를 한번에 가져온다. (V3)

모든 컬럼이 조회된다.

  • 기존 N+1번 돌던 쿼리가 단 1번의 쿼리로 모든 데이터를 조회할 수 있다.
  • V3의 약간의 단점은 먼저 엔티티로 조회한 후 엔티티를 DTO에 매핑하는 단계가 있다.
  • JPA를 이용해 데이터베이스에서 DTO로 바로 꺼냄으로써 성능을 더욱 최적화 시키는 것이 가능하다.
public List<OrderSimpleQueryDto> findOrderDtos() {
    return em.createQuery("select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
            " from Order o" +
            " join o.member m" +
            " join o.delivery d", OrderSimpleQueryDto.class)
            .getResultList();
}

@Data
public class OrderSimpleQueryDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}

 

  • JPQL을 이용해 new를 이용해 DTO의 데이터에 값을 매핑해 즉시 반환한다.
  • V3에서는 엔티티의 모든 컬럼을 조회하기 때문에 중복되는 컬럼도 함께 조회되었지만, DTO를 직접 매핑해준 경우 원하는 데이터만 DB에서 조회해 오기 때문에 네트웍 용량을 최적화할 수 있다.

원하는 데이터만 조회한다.

  • V4는 JPQL을 이용해서 성능을 최적화 시키는 것은 가능하지만, 해당 DTO를 이용하는 경우를 제외하면 재사용하는 것이 불가능하다는 단점이 있다.
  • 그에 비해 V3은 엔티티 자체를 컨트롤 하는 것이기 때문에 다른 API에서도 원하는 DTO로 변환하여 사용할 수 있기 때문에 재사용성이 좋다.

 

정리

  • Repository는 화면과 직접 연관되게 성능 최적화를 해야하는 (DTO로 직접 반환) 부분과 엔티티를 조회하는 Repository를 구분해주는 것을 권장한다.
  • 엔티티를 DTO로 변환하는 방법을 우선 선택한 후 Fetch Join을 이용해 성능 최적화한다.
  • DTO로 직접 조회하는 방법은 Fetch Join을 사용해도 성능 이슈가 있는 경우에 사용한다.
  • DTO로 직접 조회하는 방법도 성능 이슈가 있는 경우 JPA의 네이티브 SQL이나 스프링 JDBC Template을 이용해 SQL을 직접 사용한다.
반응형