본문 바로가기

Develop/SPRING FRAMEWORK

API 개발 - 지연 로딩과 조회 로직 성능 최적화

이번 포스팅에서는 API 개발 시 조회로직을 구현할 때 발생하는 성능 이슈를 최적화할 수 있는 방법을 살펴본다.

JPA를 사용하여 Entity 간의 연관관계를 매핑할 땐 기본적으로 지연 로딩을 활용한다. (fetch = FetchType.LAZY)

이번에는 그중에서도 @OneToOne, @ManyToOne 관계가 설정된 Entity를 조회할 때 발생하는 이슈를 해결해보겠다.

 

1. 엔티티를 직접 노출

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

orderRepository.findAllByString 메소드를 통해 검색 조건을 만족하는 엔티티를 통째로 가져왔다. Postman을 통해 결과를 살펴보면 무한루프에 빠지게 되는 걸 확인할 수 있다. 왜냐하면 Order 엔티티는 현재 Member, Delivery 엔티티와 양방향 연관관계가 매핑되어 있기 때문에 양쪽을 계속 왔다 갔다 하면서 무한루프가 발생한다. 이를 해결하기 위해선 한쪽 엔티티에 @JsonIgnore 옵션을 줄 수가 있는데 이건 최악이다.. 특정 API를 위해 엔티티를 건들이는 건 피해야 한다. 우리가 개발해야 할 API는 앞으로 많기 때문!

 

jackson 라이브러리 에러

따라서 Member, Delivery 엔티티에 @JsonIgnore 옵션을 걸어준다면 위와 같은 에러를 만날 수 있다.

Order 엔티티의 Member, Delivery는 지연 로딩이 걸려있다. 지연 로딩이란 해당 값에 null이 아니라 가짜 프록시 객체를 생성해서 넣어준다. 이후 필요할 때 해당 객체를 실제 DB에서 꺼내오겠다는 의미이다. 하지만 jackson 라이브러리 (객체를 json으로 변환하는 역할)가 프록시 객체를 어떻게 처리해야 하는지 모르기 때문에 에러가 발생한다.

 

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

따라서 해당 하이버네이트 모듈을 스프링 빈으로 등록해서 초기화 되지 않은 프록시 객체는 노출하지 않도록 설정할 수 있다.

결과적으로 Order 엔티티의 Member, Delivery는 null 값으로 표현된다. 이때 해당 값들을 강제로 지연 로딩해서 가져오고 싶다면 FORCE_LAZY_LOADING 옵션의 값을 true로 설정하면 된다.

하지만 고작 API 하나 때문에 하이버네이트 모듈을 건들이는 건 좋은 방법이 아니다! 또한 이러한 지연 로딩을 피하기 위해서 즉시 로딩으로 설정하면 안된다. 필요하지 않은 연관관계 마저 항상 조회하기 때문에 성능 튜닝이 매우 어려워진다.

 

@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
  List<Order> all = orderRepository.findAllByString(new OrderSearch());
  for (Order order : all) {
    order.getMember().getName();
    order.getDelivery().getAddress();
  }
  return all;
}

반복문을 통해 Order 엔티티의 Member, Delivery 프록시 객체를 초기화 해주었다. 이제 우리는 원하는 결과를 얻을 수 있다!

 

하지만 이렇게 엔티티를 직접 노출하는 방법은 좋지 않다. 이유는 다음과 같다.

  • 기본적으로 엔티티의 모든 값이 노출된다.
  • API 응답 스펙을 맞추기 위한 로직이 추가된다. (@JsonIgnore, 뷰 로직 등)
  • 실무에서는 다양한 API가 만들어지는데, 한 엔티티에 각각의 API를 위한 프레젠테이션 응답 로직을 담기는 어렵다.
  • 엔티티가 변경되면 API 스펙이 변한다!

2. 엔티티를 DTO로 변환

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV2() {

  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) {
    orderId = order.getId();
    name = order.getMember().getName();
    orderDate = order.getOrderDate();
    orderStatus = order.getStatus();
    address = order.getDelivery().getAddress();
  }
}

orderRepository.findAllByString 결과로 받아온 엔티티를 DTO로 변환하여 반환한다.

가장 일반적인 방법이지만 N+1 problem이 발생한다. 예를 들어 조회하고자 하는 Order 엔티티의 개수가 2개일 때, 최악의 경우 1번(Order 조회) + 2번(각 Order의 Member 조회) + 2번(각 Order의 Delivery 조회) = 5번의 SQL 쿼리가 나간다.

 

왜 최악의 경우일까? 지연 로딩은 영속성 컨텍스트에 해당하는 엔티티가 존재하는지 조회하기 때문에, 이미 영속성 컨텍스트 내에 존재하는 엔티티의 경우 실제 DB에서 가져오는 SQL 쿼리를 생략할 수 있다.

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

@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> orderV3() {

  List<Order> orders = orderRepository.findAllWithMemberDelivery();
  List<SimpleOrderDto> result = orders.stream()
          .map(o -> new SimpleOrderDto(o))
          .collect(Collectors.toList());
  return result;
}
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();
}

 

Order 엔티티를 조회하는 메소드를 작성할 때 페치 조인을 활용한다면 SQL 쿼리 한번에 연관된 엔티티를 모두 가져올 수 있다.

4. JPA에서 DTO로 바로 조회

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

3번 방법을 통해 대부분의 성능 이슈를 해결할 수 있다. 하지만 필요하지 않은 컬럼까지 모두 가져온 뒤 DTO로 전환한다는 단점이 있다. 이러한 부분까지 성능 최적화가 필요하다면 위 방법을 활용할 수 있다. 실제 DB에서 조회할 때부터 원하는 데이터만 선택하여 DTO로 즉시 변환하여 조회한다.

하지만 이러한 최적화가 성능에 미치는 영향은 미비함에도 불구하고 특정 API 스펙에 맞춘 코드가 Repository 내에 들어간다는 단점이 있기 때문에 개발자의 고민이 필요하다.

 

따라서 만약 이러한 최적화 코드가 필요하다면 OrderSimpleQueryRepository와 같은 별도의 Repository을 만들어서 관리하는 게 좋다. 기존 Repository는 Entity을 조회할 때 사용하여야 한다. 그래야 코드의 재사용성도 높다.