본문 바로가기
Spring

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

by 아토로 2022. 3. 6.

지연 로딩 @ManyToOne, @OneToOne 연관관계에 대한 성능 최적화 내용이다. @OneToMany 연관관계는 다음 포스트에서 정리할 예정이다.

예제 도메인

@Entity
@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<>();

    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;
}

 

@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

 

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "order_id ")
    private Order order;

    private int orderPrice;
    private int count;
}

 

@Entity
public class Delivery {

    @Id
    @GeneratedValue
    @Column(name = "delivery_id")
    private Long id;

    @OneToOne(mappedBy = "delivery", fetch = LAZY)
    private Order order;

    @Embedded
    private Address address;

    @Enumerated(EnumType.STRING)
    private DeliveryStatus status;
}

엔티티 직접 노출

엔티티를 API의 응답 값으로 반환하는 방법이다.

@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
    List<Order> orders = orderRepository.findAll();
    return orders;
}

Jackson 라이브러리 StackOverFlow 예외 발생

엔티티를 json으로 변환하는 과정에서 양방향 연관관계로 설정되어 있으면 서로를 계속 참조하게 되어 무한 루프에 빠지게 된다.

양방향 연관관계의 한쪽에 @JsonIgnore를 적용하여 해당 예외를 방지할 수 있다.

@JsonIgnore
@Enumerated(EnumType.STRING)
private DeliveryStatus status;

Jackson 라이브러리의 직렬화 실패

지연 로딩으로 반환된 프록시 객체가 아직 초기화되지 않은 상태에서 직렬화를 시도하므로 예외가 발생한다.

Hibernate5Module을 추가하면 초기화되지 않은 지연 객체에 대한 직렬화를 방지할 수 있다.

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

 

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

지연 로딩을 강제 초기화 값을 가져오도록 할 수 있다.

public List<Order> orderV1() {
    List<Order> orders = orderRepository.findAll();
    for (Order order : orders) {
        order.getMember().getName();
        order.getDelivery().getAddress();
    }
    return orders;
}

엔티티를 직접 노출하면 안되는 이유

  • 엔티티가 변경되면 API 스펙도 함께 변경된다.
  • 불필요한 데이터까지 반환하게 된다.
  • 엔티티에 의존관계 없이 독립적으로 API의 스펙이 결정되어야 한다.

엔티티를 DTO로 변환

엔티티를 API 스펙에 맞는 DTO로 변환하여 반환하는 방법이다.

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

 

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로 변환하는 과정에서 프록시 초기화가 일어나므로 지연 로딩에 의한 N + 1 문제가 발생한다. (Order 조회 1번, Member 지연 로딩 조회 N번, Delivery 지연 로딩 조회 N번)

엔티티를 DTO로 변환 - 페치 조인 최적화

지연 로딩에 의한 N + 1 문제를 방지하기 위해 페치 조인을 사용하는 방법이다.

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

페치 조인 사용하면 엔티티들을 쿼리 1번에 가져오므로 성능 향상을 기대할 수 있다.

JPA에서 DTO로 바로 조회

JPA에서 API 스펙에 맞는 DTO로 바로 조회하는 방법이다.

@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> orderV4() {
    return orderSimpleQueryRepository.findOrderDtos();
}

 

public List<OrderSimpleQueryDto> findOrderDtos() {
    return em.createQuery(
                    "select new me.study.jpashop2.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();
}

API 스펙에 밀접한 쿼리므로 재사용성이 낮고 메인 기능들과 연관성이 낮은 경우가 많다. 그러므로 쿼리용 Repository를 별도의 클래스로 분리하고, 패키지도 분리하는 것이 좋다.

필요한 필드만 DB에서 가져오기 때문에 네트워크 사용량을 줄일 수 있으나, Repository의 재사용성이 낮고 개발 복잡도가 올라가는 단점이 있다.

정리

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

2022.03.07 - [Spring] - JPA 지연 로딩과 조회 성능 최적화 (2)

댓글