API 개발 고급

seokseungmin·2024년 10월 16일

Today I Learned

목록 보기
6/20

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

JPA를 사용해 지연 로딩(Lazy Loading)으로 인해 발생하는 성능 문제를 해결하는 다양한 방법을 설명합니다. 주문 + 배송 정보 + 회원 정보를 조회하는 API를 예시로, 성능 최적화를 단계별로 어떻게 접근할 수 있는지 살펴보겠습니다.

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

가장 먼저, 엔티티를 직접 외부로 노출하는 방식입니다.

@GetMapping("/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;
}

문제점:

  • 지연 로딩이 발생하여 N+1 문제가 발생합니다. 예를 들어, 주문 1개 조회 시 주문 1개 + 회원 정보 N번 + 배송 정보 N번의 추가 쿼리가 실행됩니다.
  • 엔티티를 직접 노출하면 양방향 연관관계에서 무한 루프 문제가 발생할 수 있습니다. 이를 방지하기 위해 @JsonIgnore를 적용해야 합니다.
  • Hibernate5Module을 사용하여 해결할 수 있지만, 실무에서는 이보다는 DTO 변환을 권장합니다.

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

V2에서는 엔티티 대신 DTO를 사용하여 응답을 반환합니다.

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());
    return orders.stream()
                 .map(o -> new SimpleOrderDto(o))
                 .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();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();
    }
}

장점:

  • 엔티티와 프레젠테이션 계층을 분리할 수 있습니다.
  • 엔티티의 변경에 따라 API 스펙이 변하지 않으며, 유지 보수가 쉬워집니다.

단점:

  • 지연 로딩으로 인해 N+1 문제가 여전히 발생합니다.

3. 간단한 주문 조회 V3: 페치 조인을 사용한 성능 최적화

V3에서는 페치 조인을 사용해 지연 로딩을 없애고, 한 번의 쿼리로 필요한 데이터를 모두 가져옵니다.

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

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번으로 모든 데이터를 가져올 수 있으므로 N+1 문제를 해결합니다.

단점:

  • 페치 조인을 남용하면 JPA에서 관리하는 연관 관계 관리가 복잡해질 수 있습니다.

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

V4에서는 JPA에서 DTO로 바로 조회하는 방법을 사용해, 성능 최적화를 극대화합니다.

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

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

장점:

  • 필요한 데이터만 선택해서 조회하기 때문에 네트워크 트래픽 감소쿼리 성능이 최적화됩니다.

단점:

  • 리포지토리 재사용성이 낮아지며, API 스펙에 맞춘 쿼리가 리포지토리 레벨로 내려가는 단점이 있습니다.

정리: 조회 방식 선택 가이드

조회 성능을 최적화할 때는 상황에 맞는 방법을 선택하는 것이 중요합니다:
1. 우선 엔티티를 DTO로 변환하는 방법을 사용하세요.
2. 필요에 따라 페치 조인으로 성능을 최적화합니다. 대부분의 성능 문제가 해결됩니다.
3. 그래도 성능 이슈가 해결되지 않으면, DTO로 바로 조회하는 방법을 고려하세요.
4. 최후의 방법으로 네이티브 SQL이나 스프링 JDBC Template을 사용할 수 있습니다.


profile

0개의 댓글