[Spring] 스프링 부트와 JPA 활용2(API 개발과 서능 최적화) - API 개발 고급 : 지연 로딩과 조회 성능 최적화

밀크야살빼자·2023년 5월 29일
0
post-thumbnail

스프링 부트와 JPA 활용2 - API 개발과 서능 최적화 [김영한 강사님]

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

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1(){
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }
}
public class Member {
    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

public class Order{
@ManyToOne(fetch = LAZY)
    @JoinColumn(name="member_id")
    private Member member;
    }
    
public class Delivery{
    @JsonIgnore
    @OneToOne(mappedBy = "delivery", fetch = LAZY)
    private Order order;
    }

Order가 Member에 있고, 해당 Member에 Order가 있어 무한 루프가 발생한다.
객체를 JSON으로 만드는 JACKSON 입장에서는 무한루프를 돌며 객체를 뽑아낸다.
양방향이면 반대편에서 @JsonIgnore 해줘야 한다.

이렇게 해주면 500에러와 함께 "org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]; 발생
이는 지연로딩으로 인해 DB에서 가져오지 않아 나타나는 에러이다.

   @ManyToOne(fetch = LAZY)
    @JoinColumn(name="member_id")
    private Member member;
    
    ->
    //하이버네이트가 Member를 상속한 프록시객체를 만들어서 넣어둔다.(ByteBuddy 라이브러리에 있음)
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name="member_id")
    //프록시 객체에 가짜를 넣어두고 Member 객체에 접근할 떄 그 때 db에 sql을 날림(=프록시 초기화)
    private Member member = new ProxyMember();
@Bean
	Hibernate5Module hibernate5Module() {
		return new Hibernate5Module();
	}

기본적으로 초기화 된 프록시 객체만 노출, 초기화 되지 않은 프록시 객체는 노출 안함

  • 엔티티를 직접 노출하는 것은 좋지 않음
  • order -> memberorder -> address는 지연로딩 => 실제 엔티티 대신에 프록시 존재
  • Jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모름 => 예외 발생
  • Hibernate5Module을 스프링 빈으로 등록하면 해결
@Bean
Hibernate5Module hibernate5Module() {
    Hibernate5Module hibernate5Module = new Hibernate5Module();
    //강제 지연 로딩 설정
    hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
    return hibernate5Module;
}

이 옵션을 키면 order -> member, member -> orders 양방향 연관관계를 계속 로딩 => @JsonIgnore옵션을 한곳에 주어야 한다.

❗주의❗
엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭 한곳을 @JsonIgnore 처리해야 한다. 안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.

❗주의❗
지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다. 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용

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

/**
* V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
* - 단점: 지연로딩으로 쿼리 N번 호출
*/
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAll();
    List<SimpleOrderDto> result = orders.stream()
        .map(o -> new SimpleOrderDto(o))
        .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();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();
    }
}
/*
엔티티를 DTO로 변환하는 일반적인 방법이다.
쿼리가 총 1 + N + N번 실행된다. (N+1 문제)
order 조회 1번(order 조회 결과 수가 N이 된다.)
order -> member 지연 로딩 조회 N 번
order -> delivery 지연 로딩 조회 N 번
예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다. (최악의 경우)
지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
*/


/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
*/
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    List<SimpleOrderDto> result = orders.stream()
        .map(o -> new SimpleOrderDto(o))
        .collect(toList());
    return result;
}
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();
}
  • 안에 있는 필드도 Entity를 그대로 노출하면 안된다.
    • orderDto에는 OrderItem이 아니라 OrderItemDto 형태로 있어야 함.
    • Address 같은 단순 값 타입은 변경될 일이 없으므로 바로 써도 상관 없음.
  • 엔티티를 DTO로 변환하는 일반적인 방법
  • 지연 로딩 쿼리 횟수
    • order

      • 1번
    • member, address, orderItem

      • order 결과 개수만큼
    • item

      • orderItem 결과 개수 만큼
    • 다만, 같은 Entity가 영속성 컨텍스트에 있다면 지연 로딩이더라도 SQL을 실행하지 않음

  • 쿼리가 총 1 + N + N번 실행(N+1 문제)
    • order 조회 1번(order 조회 결과 수가 N이 됨)

    • order -> member 지연 로딩 조회 N번

    • order -> delivery 지연 로딩 조회 N번

    • 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행(최악의 경우)

      • 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략

간단한 주문 조회 V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화

  • 모든 필드에 대한 쿼리 날림
  • 쿼리가 많이 나가는 문제를 fetch join으로 해결
/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
*/
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    List<SimpleOrderDto> result = orders.stream()
        .map(o -> new SimpleOrderDto(o))
        .collect(toList());
    return result;
}
OrderRepository - 추가 코드
    public List<Order> findAllWithMemberDelivery() {
    // 쿼리 한번으로 order, memeber, delivery를 조인한 후 select절에서 한번에 가졍오기
    return em.createQuery(
        "select o from Order o" +
        " join fetch o.member m" +
        " join fetch o.delivery d", Order.class)
        .getResultList();
}
  • order 결과값 2개, orderItem 4개를 조인하면 order가 4개로 된다.
  • order와 orderItem을 조인하면 중복된 결과가 나옴
  • order는 2개지만 order_item에는 각 order_id에 해당하는 데이터가 2개씩 총4개가 있기 때문 -> order_item 개수만큼 데이터가 생긴다.
  • 개수만큼 생긴 데이터 레퍼런스 똑같음 -> JPA에서는 PK가 같으면 같은 참조값을 가진다.
  • 컬렉션의 데이터 생성을 막기 위해 distinct로 중복을 거름

DB의 distinct

  • 한 줄이 완전히 똑같아야 제거
    • 몇몇 상황에서는 중복데이터의 모든 컬럼 데이터가 똑같지 않아 제거되지 않음
    • ex) order는 겹치지만 order_item 값은 겹치지 않아서 제거되지 않음

JPA의 distinct

  • SQL에 distinct를 추가해서 실제 distinct 쿼리가 나감

  • 애플리케이션 상에서 다시 한 번 중복을 거름

  • 페이징이 불가능하다는 단점이 있음

  • fetch join : 진짜 객체 값을 채워서 가져오는 것(JPA에만 있음)

  • 엔티티를 fetch join을 사용해서 쿼리 1번에 조회

  • 페치 조인으로 order -> member , order -> delivery 는 이미 조회된 상태 이므로 지연 로딩이 발생하지 않음

간단한 주문 조회 V4 : JPA에서 DTO로 바로 조회

  • 지정한 필드만 쿼리 날림
  • 데이터를 적게 퍼올리는 만큼 네트워크 용량도 줄어듦
private final OrderSimpleQueryRepository orderSimpleQueryRepository; //의존관계 주입
/**
  * V4. JPA에서 DTO로 바로 조회
  * - 쿼리 1번 호출
  * - select 절에서 원하는 데이터만 선택해서 조회
  */
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
    return orderSimpleQueryRepository.findOrderDtos();
}

OrderSimpleQueryRepository

package jpabook.jpashop.repository.order.simplequery;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
    
    private final EntityManager em;
    
    public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
            "select new jpabook.jpashop.repository.order.simplequery.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();
    }
}

OrderSimpleQueryDto 리포지토리 Dto 직접 조회

package jpabook.jpashop.repository.order.simplequery;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;

import lombok.Data;
import java.time.LocalDateTime;

@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;
    }
}
  • 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회
  • new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB애플리케이션 네트워크 용량 최적화(생각보다 마비)
    • SELECT절에 필드 몇 개 추가된다고 성능이 저하되지 않음
    • 성능 이슈의 대부분은 JOIN 절에서 발생
  • 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다.

쿼리 방식 선택 권장 순서
1. 우선 엔티티를 DTO로 변환하는 방법을 선택
2. 필요하면 페치조인으로 성능을 최적화, 대부분의 성능 이슈가 해결됨
3. 그래도 안되면 DTO로 직접 조회하는 방법 사용
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용

profile
기록기록기록기록기록

0개의 댓글