본문 바로가기
Dev/JPA

[JPA] JPA 활용 II - API 개발 고급 (지연로딩과 조회성능 최적화)

by dev_jsk 2021. 10. 4.
728x90
반응형

목표

  • 간단한 주문을 조회하는 기능을 만들면서 지연로딩 때문에 발생하는 성능 문제 해결
  • @ManyToOne, @OneToOne 관계 성능 최적화

V1. 엔티티 직접 노출

기본 조회

소스 구현

package jpabook.jpashop.api;

/**
 *
 * xToOne(ManyToOne, OneToOne) 관계 최적화
 * Order
 * Order -> Member
 * Order -> Delivery
 *
 */
@RestController
@RequiredArgsConstructor
public class OrderSimpleController {
    
    private final OrderRepository orderRepository;

    @GetMapping(value="/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }
    
}

동작 결과

무한 루프 발생

Order <-> Member, Order <-> Delivery, Order <-> OrderItem이 양방향 연관관계로 구성되어 있어 무한루프가 발생한다. 이러한 문제 해결은 양방향 연관관계 중 한쪽에 @JsonIgnore를 사용하여 예외처리를 한다.

양방향 연관관계 해결

소스 구현

// Member.java
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();

// Delivery.java
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;

// OrderItem.java
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
  • @JsonIgnore
    - 선언한 필드를 응답값에서 무시하는 어노테이션으로 데이터를 주고받을 때 해당 값은 무시된다.

동작 결과

@JsonIgnore 설정 후 결과

500 에러 발생 이유는 Order와 양방향 연관관계를 가진 Member 객체가 지연로딩으로 설정되어 있기 때문에 프록시 객체라서 JSON으로 어떻게 생성해야 하는지 모르기 때문에 발생한다. 이러한 문제는 Hibernate5Module을 스프링 빈으로 등록하여 해결한다.

Hibernate5Module 등록

Hibernate5Module을 스프링 빈으로 등록할 경우 지연로딩 항목에 대해 무시하여 NULL로 설정할 수 있다.

라이브러리 추가

// Hibernate5Module 추가
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

소스 구현

package jpabook.jpashop;

import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;

@SpringBootApplication
public class JpashopApplication {

    public static void main(String[] args) {
        SpringApplication.run(JpashopApplication.class, args);
    }

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

동작 결과

지연로딩으로 설정된 member, orderItems, delivery 값이 null로 반환된다.

강제 지연로딩 활성화

지연로딩은 DB에서 조회하기 전이기 때문에 결과가 NULL이다. 이때 Hibernate5Module의 강제 지연로딩 설정을 활성화하면 지연로딩으로 설정된 모든 항목을 다 조회한다.

@Bean
Hibernate5Module hibernate5Module() {
    Hibernate5Module hibernate5Module = new Hibernate5Module();
    // 강제 지연로딩 설정으로 지연로딩 항목을 다 조회
    hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
    return hibernate5Module;
}

강제 지연로딩 설정을 활성화 한 결과로 지연로딩 설정 항목이 전부 조회된다.

즉시로딩 사용시 문제점

  • 즉시로딩 설정한 필드가 필요 없어도 항상 조회된다.
  • N+1문제 발생, 즉시로딩이 설정되어 있어서 단건 쿼리가 발생한다.
  • API 스펙상 불필요한 데이터가 조회되기 때문에 성능 문제가 발생한다.
  • EntityManager.find()는 즉시로딩으로 성능 최적화가 가능하지만 JPQL은 SQL로 그대로 변환되어 나가기 때문에 성능 최적화를 하기 어렵다.

따라서 항상 지연로딩을 기본으로 하고 성능 최적화가 필요한 경우 페치 조인을 사용한다.

지연로딩 설정 후 직접 호출

지연로딩으로 설정된 필드를 강제로 호출하여 프록시 객체를 초기화하여 원하는 결과를 얻을 수 있다.

 

소스 구현

@GetMapping(value="/api/v1/simple-orders")
public List<Order> ordersV1() {
    List<Order> all = orderRepository.findAllByString(new OrderSearch());
    for (Order order : all) {
        // 호출을 통한 프록시 초기화
        order.getMember().getName();
        order.getDelivery().getAddress();
    }
    return all;
}

동작 결과

강제 호출한 Member와 Delivery정보가 표시고 호출하지 않은 OrderItme정보는 NULL로 표시된다.

V1 마무리

지연로딩으로 설정할 경우 위와 같은 문제들이 발생한다. 위와 같은 해결방법이 있지만 가장 중요한 것은 엔티티를 절대로 API에 사용하지 말아야 한다는 것이다. API에 사용시 정보 노출의 위험이 있기 때문에 DTO로 변환하여 조회기능을 구성하는 것이 가장 바람직하다.

V2. 엔티티를 DTO로 변환

소스 구현

@GetMapping(value = "/api/v2/simple-orders")
public List<SimpleOrderDTO> ordersV2() {
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());
    List<SimpleOrderDTO> result = orders.stream()
                                    .map(o -> new SimpleOrderDTO(o))
                                    .collect(Collectors.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) {
        this.orderId = order.getId();
        this.name = order.getMember().getName();
        this.orderDate = order.getOrderDate();
        this.orderStatus = order.getStatus();
        this.address = order.getDelivery().getAddress();
    }
}
  • DTO가 엔티티를 파라미터로 받는건 문제되지 않는다. 중요하지 않은 곳에서 중요한 곳을 의지하는 것이기 때문

동작 결과

DTO 내 구성된 필드만 조회되는 것을 확인할 수 있다.

엔티티를 DTO로 변환 시 문제점

  • 지연로딩 설정으로 인해 쿼리가 총 1 + N + N번 실행된다.
    - 주문(order) 조회 1번
    - 주문한 회원(member) 지연 로딩 조회 N번
    - 배송(delivery) 지연 로딩 조회 N번
    여기서 N번은 주문 조회 결과 수 이고 주문한 회원이 동일할 경우 회원 조회 쿼리는 주문 결과 수 보다 적게 실행되겠지만 반대로 다를 경우 주문 결과 수 만큼 실행된다.
  • N+1 문제가 발생하는 이유는 지연로딩 동작 방식 때문이다. 지연로딩은 요청이 있을 시 먼저 영속성 컨텍스트를 확인하고 찾는 정보가 없을 경우 DB에서 조회하기 때문이다.
  • 지연로딩의 N+1 문제를 해결하기 위해 즉시로딩으로 설정하게 되면 예측할 수 없는 쿼리가 실행되는 문제가 발생한다.

V2 마무리

API에 엔티티를 사용하지 않는 것은 좋지만 지연로딩으로 설정된 항목에 대해 N+1 문제가 발생한다. 이러한 문제점은 Fetch Join을 활용하여 해결이 가능하다.

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

소스 구현

// OrderSimpleController.java
@GetMapping(value = "/api/v3/simple-orders")
public List<SimpleOrderDTO> ordersV3() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery(new OrderSearch());
    List<SimpleOrderDTO> result = orders.stream()
                                    .map(o -> new SimpleOrderDTO(o))
                                    .collect(Collectors.toList());
    return result;
}

// OrderRepository.java
/**
 * fetch join 사용
 * @param orderSearch
 * @return
 */
public List<Order> findAllWithMemberDelivery(OrderSearch orderSearch) {
    List<Order> result = em.createQuery(
        "select o from Order o" + 
        " join fetch o.member m" + 
        " join fetch o.delivery d", Order.class
    ).getResultList();
    return result;
}

동작 결과

Fetch Join 쿼리 로그 (좌측) / 실행 결과 (우측)

페치조인 사용시 장점

  • V2 방식과 결과는 동일하지만 실행되는 쿼리가 다르다.
    - V2 : 1 + N + N개의 쿼리 실행
    - V3 : 1개의 쿼리 실행
    따라서 1개의 쿼리만 실행되기 때문에 성능이 더 좋다고 할 수 있다.
  • 지연로딩으로 설정된 항목 member, delivery의 정보가 이미 조회된 상태이므로 지연로딩이 발생하지 않는다.

V3 마무리

지연로딩의 N+1 문제를 해결할 수 있는 가장 큰 장점을 갖고 있어 실무에서도 많이 사용한다. 또한, 성능 문제의 대부분은 Fetch Join을 활용하여 해결되는 경우가 많기 때문에 Fetch Join에 대해 많은 학습이 필요하다.

V4. JPA에서 DTO로 바로 조회

소스 구현

// OrderSimpleController.java
@GetMapping(value = "/api/v4/simple-orders")
public List<OrderSimpleQueryDTO> ordersV4() {
    return orderSimpleQueryRepository.findOrderDTOS();
}

// OrderSimpleQueryRepository.java
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.java
@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 status, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = status;
        this.address = address;
    }
}
  • new 키워드
    - 단순값을 DTO로 바로 조회
    - 패키지명을 포함한 전체 클래스명으로 작성
    - 순서와 타입이 일치하는 생성자 필요

동작 결과

new 키워드 사용 조회 쿼리 로그 (좌측) / 실행 결과 (우측)

JPA에서 DTO로 바로 조회시 장점

  • 일반적인 SQL 사용처럼 원하는 값을 선택하여 조회가 가능하다.
  • SELECT절에서 원하는 값을 선택하여 조회가 가능하기 때문에 애플리케이션 네트워크 용량 최적화 효과가 있다.

V4 마무리

new 키워드를 사용하여 원하는 값만 조회하기 때문에 API 스펙에 딱 맞게 사용이 가능하다. 다만 다른 로직에서는 사용할 수 없다.

엔티티를 DTO로 변환과 JPA에서 DTO로 바로 조회의 차이점

  • 엔티티를 DTO로 변환
    외부의 모습을 건드리지 않은 상태로 내부에 원하는 것만 페치조인하여 조회했기 때문에 재사용성이 높고 성능 튜닝이 가능하다.
  • JPA에서 DTO로 바로 조회
    실제 SQL을 작성하여 사용하는 것처럼 필요한 값만 조회했기 때문에 재사용성이 떨어진다.

따라서 JPA에서 DTO로 바로 조회하는 방식을 사용하게 되면 API 스펙에 맞춘 코드가 레포지토리에 들어가기 때문에 레포지토리 재사용성이 떨어진다.

비교

  엔티티를 DTO로 변환 (페치조인 사용) JPA에서 DTO를 바로 반환
재사용성 높다 낮다
성능 성능 차이 미비

결과적으로 레포지토리는 가급적 순수한 엔티티를 조회하거나 성능 최적화 용도로만 사용해야 한다. 화면에 의존적이거나 복잡한 쿼리 로직을 구현해야 할 경우 별도의 쿼리 레포지토리를 구성하여 사용하는 것을 권장한다.

728x90
반응형

댓글