[Spring] 조회 API 성능 최적화하기 1 - 지연로딩(Lazy Loading)과 페치 조인(Fetch Join) : ToOne 매핑
조회 API 성능 최적화하기 1 - 지연로딩(Lazy Loading)과 페치 조인(Fetch Join)
Order 테이블을 기준으로 ToOne으로 연관관계 매핑되어있는 테이블을 조회할 때 발생하는 N+1문제와 그에 대한 대책에 대해서 다뤄본다.
- Order 1개만 조회할 때는 그에 따른 Member와 Delivery도 1개이기 때문에 큰 상관이 없다. 최대 3개의 쿼리가 발생한다. fetch join을 사용하면 1번으로 줄일 수 있다.
- 그런데 Order.findAll()을 하게 되면 어떻게 될까? 1번의 쿼리로 N개의 Order가 호출되면 그에 부수적으로 N개의 Member와 N개의 Delivery를 조회하게 된다. 즉 2N+1쿼리가 발생하여 일명 N+1 문제가 발생하게 된다. 이 또한 fetch join과 DTO 생성으로 해결할 수 있다.
간단한 주문 조회 V1 : 엔티티를 직접노출
제일 단순한 방법으로 엔티티로 직접 Order엔티티를 가져와서 데이터 조회하는 방법이 있다.
- 참고로 V1방법은 성능 최적화를 이루기 위한 과정으로 나아가기 위해 보여주는 것이기 때문에 이러한 문제가 발생한다는 것만 인지해주면 된다.
OrderController.class
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1(){
List<Order> all = orderRepository.findAll(new OrderSearch());
return all;
}
문제 1 (무한 루프 발생)
그러면 다음과 같이 무한 루프가 발생한다. 이유는 Order와 다른 엔티티들과 양방향 매핑이 이뤄져있기 때문이다.
- order → member , member → order ... 무한 루프
해결방법 1
엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳에 둘 중 한 곳에 @JsonIgnore을 처리해주면 된다.
Delivery.class
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
Member.class
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
문제 2 (Type Definition Error)
하지만 그래도 Type Definition Error 에러가 발생한다. Type Definition Error는 지연로딩으로 인한 DB에서 데이터를 가져오지 않아서 생기는 에러이다. 지연로딩은 사용하지 않으면 Hibernate에서 가짜 프록시 객체(ByteBuddyInterceptor)를 생성해서 넣어놓기만 하는데, JSON이 루프를 돌리면서 Member가 이상한 객체(ByteBuddyInterceptor)로 되어 있어서 에러 호출을 하게 된 것이다.
해결 방법 2
이에 대한 방법으로는 Hibernate5Module을 사용하여 Lazy 프록시 객체를 강제 초기화하여 해결해주면 된다.
build.gradle
implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-hibernate5'
JpashopApplication.class (@SpringBootApplication)
// hibernate5Module을 이용해 Lazy 프록시 객체 강제 초기화
@Bean
Hibernate5Module hibernate5Module(){
Hibernate5Module hibernate5Module = new Hibernate5Module();
// 강제 Lazy Loading
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
그러면 다음과 같이 정상적으로 JSON 데이터가 조회된다.
모든 데이터를 조회?
조회는 했지만 데이터가 깔끔하지가 않다. API 스팩을 만들 때 다음과 같이 데이터를 조회하도록 만들면 안된다. 이렇게 외부에 모든 데이터를 노출해버리면 운영과 유지보수 등 다양한 문제가 발생한다. 가급적 꼭 필요한 데이터만 노출하는 것이 좋다.
그냥 즉시로딩(EAGER)을 하면 안될까?
절대 지연 로딩(LAZY)를 피하기 위해 즉시 로딩(EAGER)을 설정하지 말자. 즉시 로딩 때문에 연관관계가 필요없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다.
- 따라서, 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해야 한다.(V3)
정리 (엔티티 직접 조회 API)
문제점
- 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
- 기본적으로 엔티티의 모든 값이 노출된다.
- 응답 스펙을 맞추기 위해 로직이 추가된다. (@JsonIgnore, 별도의 뷰 로직 등등)
- 실무에서는 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 프레젠테이션 응답 로직을 담기는 어렵다.
- 엔티티가 변경되면 API 스펙이 변한다.
- 추가로 컬렉션을 직접 반환하면 항후 API 스펙을 변경하기 어렵다.(별도의 Result 클래스 생성으로 해결)
결론
- API 응답 스펙에 맞추어 별도의 DTO를 반환한다.
- 모든 엔티티가 노출되는 조회 V1는 정말 안 좋은 버전이다.
- @JsonIgnore 이건 정말 최악의 방법이다. API가 해당 조회 API가 하나가 아니기 때문에 특정 API에 종속되어 사용되면 안된다.
- 실무에서는 member 엔티티의 데이터가 필요한 API가 계속 증가하게 된다. 어떤 API는 name 필드가 필요하지만, 어떤 API는 name 필드가 필요없을 수 있다.
- 결론적으로 엔티티 대신에 API 스펙에 맞는 별도의 DTO를 노출해야 한다.
간단한 주문 조회 V2 : 엔티티를 DTO로 변환
위의 문제점을 반영하여 엔티티를 DTO로 변환해서 Order엔티티에서 원하는 데이터만 따로 조회해보자.
- (orderId, name, orderDate, orderStatus, address)
OrderController.class
@GetMapping("/api/v2/simple-orders")
public List<OrderSimpleDto> orderV2(){
List<Order> orders = orderRepository.findAll(new OrderSearch());
List<OrderSimpleDto> result = orders.stream()
.map(o -> new OrderSimpleDto(o))
.collect(Collectors.toList());
return result;
}
@Data
static class OrderSimpleDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public OrderSimpleDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); // LAZY 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // LAZY 초기화
}
}
DTO로 변환하면 좋은점
- 엔티티가 변해도 API 스펙이 변경되지 않는다.
- 추가로 Result 클래스로 컬렉션을 감싸서 향후 필요한 필드를 추가할 수 있다.
다음과 같이 DTO로 변환하면 깔끔하게 데이터가 조회되는 것을 확인할 수 있다.
문제 발생 (N+1 문제)
다만 쿼리를 보면 총 5번이 호출되는 것을 확인 할 수 있다. 조회하는 엔티티는 3개인데 왜 5개나 발생했을까? N+1문제가 발생한 것이다. 쿼리가 총 1 + N + N 번 실행되는 것이다.
- Order 조회 1번 (주문 수 2개)
- Order → Member 조회 2번
- Order → Delievery 조회 2번
만약 100만건의 주문 수를 조회한다면 최악의 경우, 주문 쿼리(1개)에 추가로 200만(Member, Delivery 각 1번씩)을 조회하여 엄청난 성능 저하를 가져오게 된다.
- 지연 로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략하므로 무조건 200만은 아니다. 그래서 만약 주문은 10개를 1명이 주문했다면 멤버 조회 쿼리는 1번만 나가게 된다.
- 이러한 성능 문제를 해결하기 위해서 Fetch Join 최적화 작업이 필요하다.
간단한 주문 조회 V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화
조회하는 엔티티를 페치 조인(Fetch Join)을 사용하면 쿼리 1번에 모든 데이터를 조회할 수 있다.
OrderSimpleApiController.class
// (initDB기준 1개 쿼리로 최적화)
@GetMapping("/api/v3/simple-orders")
public List<OrderSimpleDto> ordersV3(){
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<OrderSimpleDto> result = orders.stream()
.map(OrderSimpleDto::new)
.collect(Collectors.toList());
return result;
}
OrderRepository.class
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();
}
페치조인 결과
다음과 같이 페치 조인을 하게되면 조회 결과는 V2와 똑같지만 다음과 같이 쿼리가 1번만 나가는 것을 확인할 수 있다.
- 페치 조인으로 order → member, order→delivery는 이미 조회 된 상태이므로 지연로딩이 일어나지 않는다.
- 페치 조인은 실무에서 많이 사용하는데 단순하지 않기 때문에 꼭 공부를 해야한다.
간단한 주문 조회 V4 : JPA에서 DTO로 바로 조회
V3에서는 엔티티를 조회한 다음 엔티티를 DTO로 중간에 변환해서 조회하는 방법을 사용했다면 V4에서는 JPA에서 바로 DTO로 조회해보자.
OrderSimpleApiController.class
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4(){
return orderSimpleQueryRepository.findOrderDtos();
}
OrderSimpleQueryRepository.class
@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();
}
}
OrderSimpleQueryDto.class
@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 orderStatus, Address address) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
조회 결과는 V3과 일치한다.
그런데 쿼리 실행 결과를 보면 SELECT 절이 확연히 줄어든 것을 확인할 수 있다. 정확하게 원하는 데이터만 선택하여 쿼리를 조회한 것이다.
V4 장점
V4는 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회하고 new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환한다.
- V3 페치조인 경우에는 기본적으로 데이터를 더 많이 퍼오기 때문에 네트워크 사용량이 V4보다 비교적 크다.
- V4에서는 컴팩트하게 정확하게 원하는 데이터만 선택하여 쿼리를 조회한 것이기 때문에 V3보다 높은 성능을 보인다.
V4 단점
언뜻 보기엔 성능을 높여주기 때문에 좋아보이긴 하지만 트레이드 오프가 있다. 컴팩트하다는 것이 객체지향 프로그래밍에서 단점으로 작용할 수 있다. 유연성이 떨어지기 때문이다.
- V4는 특정 API 스팩에만 맞춰 딱 맞게 제작한 것이기 때문에 해당 DTO에만 한정되어있다는 단점이 있다. 따라서 해당 코드는 재사용이 거의 불가능하다고 보면 된다.
- 반면 V3는 원하는 엔티티에 페치조인만 적용하였기 때문에 공용으로 사용이 가능하다.
트레이드 오프란 무엇을 잃으면서 무엇을 얻는 것이다. 보통 득이 더 많아야 트레이드 오프를 행하기 마련이다. 하지만 V4는 전체적인 애플리케이션 관점에서 이뤄지는 성능 향상은 미비하다.
- V4에서 SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화를 이루지만 네트웍 성능이 매년 좋아지고 있기 때문에 그렇게 큰 차이가 없다.
- 대부분의 성능 저하 현상은 SELECT 절이 아닌 FROM 절에서 많이 발생한다. (JOIN, INDEX)
조회 API 쿼리 방식 선택 권장 순서
엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.
- 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
- 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접사용한다.
[Spring] 조회 API 성능 최적화하기 1 - 지연로딩(Lazy Loading)과 페치 조인(Fetch Join)
[Spring] 조회 API 성능 최적화하기 2 - 컬렉션 조회 최적화 (1:N)
※ 참고
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화