
먼저 N+1 문제에 대해서 알아보기 전에 JPA에서 사용하는 즉시 로딩과 지연 로딩에 관해서 숙지해야한다.
N+1 문제란 연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(n)만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 되어 발생하는 문제이다.
N+1 문제는 @xToOne 관계와 @xToMany 관계, 그리고 지연 로딩, 즉시 로딩에 따라서 다르게 발생한다.
@OneToOne : 1:1
@ManyToOne : N:1
@OneToMany : 1:N (컬렉션)
@ManyToMany : N:M (절대 사용 X)
@OneToOne나 @ManyToOne 관계에서 즉시 로딩시 N+1 문제가 발생하지 않는다. 하지만 지연 로딩에는 N+1 문제가 발생한다. 아래의 코드를 보자. 아래의 코드는 전체 orders을 조회한 다음 orders 리스트 객체 내부에 반복문을 돌려 DTO 리스트로 변환하는 코드이다. 아래의 코드에서 SQL 조회문이 몇번이나 발생하는지 한번 세어보자. (Order에 연관된 엔티티는 모두 지연로딩을 적용)
@xToOne 예시 코드
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
List<Order> orders = orderRepository.findAll(); // Order 객체 2개가 조회됨 (가정)
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(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.findAll() ⇒ 1번
orders.stream().map(o -> new SimpleOrderDto(o)).collect(toList());
1 + 2 x N ⇒ 1 + 2 x 2 = 5번의 SQL 조회
위의 코드에서 첫번째 조회가 발생한 후 엔티티를 DTO로 변경함에 따라 객체 그래프 탐색이 발생하여 4번의 조회가 추가적으로 발생하였다. 그리하여 즉시 로딩시 1번의 조회로 해결될 문제가 지연 로딩으로 5번의 조회가 발생했다.
그렇다면 즉시 로딩을 사용해야하고 지연 로딩 보다 즉시 로딩이 더 우수한 것일까?
즉시 로딩은 사용하지 않는 불필요한 연관 엔티티까지 불러오기 때문에 즉시 로딩시 자동으로 발생하는 SQL 문의 JOIN은 데이터베이스의 성능 저하를 불러올 수도 있다. 그리고 컬렉션 조회, 즉 @xToMany 조회시에는 즉시 로딩이든 지연 로딩이든 N+1 문제가 발생하는 것은 똑같다.
그렇기 때문에 N+1 문제만 해결할 수 있다면 즉시 로딩보다 지연 로딩을 사용하는 것이 성능면에서 더 좋을 것이고 지연 로딩의 사용을 권장한다.
아래의 코드는 전체 orders을 조회한 다음 orders 리스트 객체 내부에 반복문을 돌려 DTO 리스트로 변환하는 코드이다. 그러나 이전 코드와 다른 점은 OrderDto에서 컬렉션인 orderItems을 지니고 있다는 점이다. (Order에 연관된 엔티티는 모두 지연로딩을 적용)
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAll(); // => 최초 1회 조회
List<OrderDto> result = orders.stream() // => N번 반복
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems; // 컬렉션
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); // 1번 조회
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // 1번 조회
// 중요 !
orderItems = order.getOrderItems().stream() // N번 조회
.map(orderItem -> new OrderItemDto(orderItem))
.collect(toList());
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName(); // 1번 조회
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
// 총 "1 + N * (1 + 1 + N * (1))" 번 조회 = N^2 * 2N + 1
위의 코드에서 중요한 부분은 아래와 같다. orderItems은 Order 엔티티 내부에서 @oneToMany 관계로 매핑되어 있다. 따라서 1:N의 관계이다. 그렇기 때문에 map 메소드를 통해서 orderItem의 수만큼 조회문을 보내야하고 따라서 N + 1의 문제가 발생한다. 이는 즉시 로딩을 사용하더라도 똑같이 일어난다.
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(toList());
N + 1 문제를 해결하는 방법은 2가지가 있다. 그것은 Fetch Join을 사용하는 방법과 Batch size를 사용하는 방법이 있다.
패치 조인은 JPQL에만 있는 JPQL의 문법이다. 패치 조인은 복잡하게 Join문을 작성할 필요없이 조회하고 싶은 엔티티를 나열하면 알아서 관련된 컬럼을 모두 조회해준다. 아래는 @xToOne 일 때의 패치 조인 예시이다.
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();
}
위와 같이 패치 조인하면 관련된 모든 컬럼이 담겨서 조회되기 때문에 따로 지연 로딩을 사용해서 값을 가져올 필요가 없다. 그렇기 때문에 SQL 조회문은 단 1번만 수행된다.
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery(); // 단 1번의 조회
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(toList());
return result;
}
그러나 패치 조인은 컬렉션(@xToMany)에서는 조금 다르다. @xToMany, 즉 1:N 관계에서는 패치 조인을 사용하면 컬럼 수가 N에 맞춰져야 하기 때문에 컬럼 수가 늘어난다. 그로 인해 중복이 발생할 수 있다. 따라서 컬렉션에서 패치 조인을 사용할 때는 distinct를 함께 사용해야한다.
그리고 컬렉션에서 패치 조인을 사용하면 페이징이 불가능하다. 그렇기 때문에 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버린다. 그러나 메모리에서 페이징을 하는 것은 매우 위험하다. 그렇기 때문에 컬렉션을 조회할 때 페이징을 해야한다면 패치 조인을 사용하지 않도록 하자.
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem(); // 단 1번의 조회
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
컬렉션 조회시 패치 조인을 사용하면 페이징이 불가능하다. 따라서 다른 방법을 사용해야한다. 그 방법은 Batch size 옵션을 지정하는 것이다. Batch size는 지연 로딩시 성능 최적화를 하는 방법이다. 사용 방법은 아래와 같다. 이처럼 Batch size 옵션을 지정해두면 지정된 사이즈 만큼 컬렉션이나, 프록시 객체를 한꺼번에 IN 쿼리로 조회한다.
hibernate.default_batch_fetch_size: 글로벌 설정 // application.properties
@BatchSize: 개별 최적화 // ex) @BatchSize(size = ??)
이전에 1 + N 문제가 발생한 이유는 컬렉션 조회시 N개의 데이터를 1개씩만 조건을 만들어 조회문을 던졌기 때문에 N만큼의 조회가 발생했다. 그러나 Batch size 옵션을 지정하면 IN 으로 N개의 데이터를 한꺼번에 조회하기 때문에 조회문이 1번만 발생한다. 따라서 1 + N이 아닌 1 + 1, 2번의 조회문이 발생하게된다. 따라서 Batch size 옵션을 사용하면 발생하는 조회 수를 줄일 수 있다.
@xToMany 예시 코드를 예시로 Batch size를 사용하여 N+1 문제를 해결하는 방법은 다음과 같다.
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
// xToOne 관계만 패치 조인을 사용했다. (orderItem을 조인에 추가하지 않음)
// setFirstResult(offset), setMaxResults(limit)를 사용해서 페이징
}
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
// orderItem만 지연 로딩을 사용하기 때문에 OrderDto 내부에서 알아서 조회됨.
// 이때Batch size 옵션을 통해서 N 만큼의 조회문을 한꺼번에 던짐
}