Home API 성능 최적화 - 지연 로딩 조회 성능 최적화
Post
Cancel

API 성능 최적화 - 지연 로딩 조회 성능 최적화

등록과 수정은 보통 거의 성능 문제가 발생하지 않는다. 단순하게 데이터 한 건을 삽입하고 삭제하는 그런 과정일 뿐이다.

주로 문제는 조회에서 발생한다. 조회를 하는 경우가 가장 많고 조회하는 데이터가 제일 많기 때문이다.

이 포스트를 시작으로 다양한 경우의 성능 최적화에 대해 알아본다.

  • 지연 로딩과 조회 성능 최적화
  • 컬렉션 조회 최적화
  • 페이징
  • OSIV

조회 성능 최적화

주문 + 배송정보 + 회원을 조회한다고 가정하자.

  • 주문(Order)를 조회
  • 주문(Order) -> 회원(Member) 조회
  • 주문(Order) -> 배송정보(Delivery) 조회

이 부분은 참고로 ManyToOne, OneToOne의 관계이다.

만약 Order와 OrderItem의 관계였다면 Order에서는 OrderItem 컬렉션을 가지고 있기 때문에 또 다른 경우가 된다. 이 부분은 다음 포스트에서 다룬다.

V1

1
2
3
4
5
6
7
8
9
10
11
12
@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;  
	}  
}

간단하게 Repository에서 전체를 조회하는 방법으로 컬렉션을 가져와본다.

다양한 문제가 발생한다.

우선 기본적으로 엔티티 자체를 노출하는 문제. 그리고 아래의 문제들이 있다.

무한 루프 문제

Order에는 Member가 있고, Member에는 Orders가 존재한다. 양방향 연관관계에 있는 것이다.

그런데 위와 같이 전체를 조회하게 되면 JSON으로 변환할 때, Member에서는 Orders를, Order에서는 Member를 계속 JSON으로 변환하면서 무한루프에 빠지게 되는 문제가 발생한다.

이러한 양방향 연관관계 무한루프에 대한 부분은 @JsonIgnore로 해결할 수 있긴 하다.

이 외에도 문제가 있다.

프록시 객체 문제

Order클래스를 보면

1
2
@ManyToOne(fetch = LAZY)
private Member member;

지연 로딩으로 설정되어 있다.

즉 Order를 조회할 때 Member의 값을 직접 호출하는 부분이 없기 때문에 Member는 프록시 객체이고, 실제 객체와 매칭되는 초기화가 되지 않은 상태이다.

문제는 JSON으로 변환해주는 jackson 라이브러리가 기본적으로 프록시 객체를 json으로 변환하는 방법을 모른다는 점이다.

=> 예외 발생

이 부분은 Hibernate5Module을 스프링 빈으로 등록하면 해결된다.

1
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5- jakarta'
1
2
3
@Bean Hibernate5JakartaModule hibernate5Module() { 
	return new Hibernate5JakartaModule(); 
}

지연 로딩인 부분은 무시하는 세팅이라고 보면 된다. 물론 지연 로딩인 부분도 가져오도록 세팅하는 방법도 존재한다. (이 방법은 문제가 많기 때문에 따로 설명 하지 않는다.)

세팅 후 JSON 응답 결과를 보면 member, delivery 등은 다 null이고 order와 관련된 데이터만 값이 정상적으로 입력되어 있는 것을 볼 수 있다.

지연 로딩이기 때문이다. DB에서 직접 조회한 것이 아니기 때문이다.

V2 - 엔티티를 DTO로 변환하여 조회

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@GetMapping("/api/v2/simple-orders")  
public List<SimpleOrderDto> ordersV2() {  
	return orderRepository.findAllByString(new OrderSearch()).stream()  
			.map(SimpleOrderDto::new)  
			.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();  
	}  
}

이렇게 DTO를 사용하면 API 스펙에 맞추어 원하는 데이터를 알맞게 이용할 수 있다.

하지만 이 또한 문제가 있다.

  • N + 1 문제
    • 코드를 보면 Order를 조회하면서 Member, Delivery를 같이 조회한다.
    • order.getMember().getName() 같은 부분에서 지연 로딩 프록시 객체가 초기화 된다.
      • DB쿼리가 발생한다.
      • Delivery도 마찬가지.
    • Order를 조회했을때 만약 주문이 2개가 존재한다면 각각의 Order에서 Member, Delivery 테이블을 조회하기 위해 쿼리가 발생하게 되는 것이다.
    • 1 (Order 2개) + 2 (Order1의 Member, Delivery) + 2 (Order2 …)
      • 하나의 Order를 OrderDto로 만들 때마다 쿼리 2개씩 발생.
    • 조회된 Order가 더 많으면 발생되는 쿼리의 수는 무수히 많아질 것이다.

이러한 문제를 개선해보도록 하자.

V3 - DTO로 변환 + 페치 조인 사용

1
2
3
4
5
6
7
8
9
//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();  
}
1
2
3
4
5
6
@GetMapping("/api/v3/simple-orders")  
public List<SimpleOrderDto> ordersV3() {  
	return orderRepository.findAllWithMemberDelivery().stream()  
			.map(SimpleOrderDto::new)  
			.collect(Collectors.toList());  
}

페치조인을 하면 V2와 결과는 같지만 쿼리가 1번만 발생한다.

페치조인에 대한 자세한 내용은 페치조인 포스트를 확인하면 된다.

V4 - JPA에서 DTO로 바로 조회하는 방법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data  
public class SimpleOrderQueryDto {  
  
	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;  
	}
}
1
2
3
4
5
6
7
8
//OrderRepository
public List<OrderSimpleQueryDto> findOrdersDto() {  
	return em.createQuery(  
			"select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address" +  
			" join o.member m" +  
			" join o.delivery d", OrderSimpleQueryDto.class  
	).getResultList();  
}
1
2
3
4
@GetMapping("/api/v4/simple-orders")  
public List<OrderSimpleQueryDto> ordersV4() {  
	return orderRepository.findOrdersDto();  
}

이 방법은 일반적인 SQL을 사용하는 것 처럼 원하는 값을 선택해서 조회하는 방법이다.

new 명령어를 통해 JPQL의 결과를 DTO로 즉시 변환한다.

  • select 절에서 원하는 데이터를 직접 선택하므로 DB -> 애플리케이션으로 데이터를 넘길 때 네트워크 용량이 최적화된다.

하지만 단점도 있다.

  • 리포지토리의 재사용성이 떨어진다.
    • API 스펙에 맞춘 코드가 리포지토리에 들어가게 된다.

정리

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 방법 두 가지 중 하나를 택하면 된다. 각각 장단점이 있다.

  • 쿼리 방식 선택 권장 순서
    • 우선 엔티티를 DTO로 변환하는 방법을 택한다.
    • 필요하면 페치조인을 통해 성능을 최적화한다.
    • 위 방법으로 안될 때 DTO로 직접 조회하는 방법을 사용한다.
    • 최후의 방법으로는 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해 SQL을 직접 사용해야 한다.
This post is licensed under CC BY 4.0 by the author.