[스프링/Spring] Spring JPA의 지연 로딩과 N+1 문제
kindof
·2021. 11. 13. 17:26
1. N+1 문제란?
N+1 문제는 JPA를 이용해서 개발을 하다보면 처음에 무조건 만나게 되는 문제라고 생각합니다.
N+1 문제란 무엇인지 설명하기 위해 아래 엔티티와 코드를 보도록 하겠습니다. 회원(Member)과 주문(Orders), 배송(Delivery) 테이블이 있고 회원과 주문은 일대다, 주문과 배송은 일대일 관계를 가지고 있습니다.
* N+1 문제 설명에 집중하기 위해 생성자나 연관 관계 메서드는 생략하고, 테이블도 간단하게 설계했습니다.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@Embedded
private Address address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
@Id
@GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member; //주문 회원
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery; //배송정보
private LocalDateTime orderDate; //주문시간 @Enumerated(EnumType.STRING)
private OrderStatus status; //주문상태 [ORDER, CANCEL]
}
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status; //ENUM [READY(준비), COMP(배송)]
}
그리고 아래와 같이 주문(Order)를 조회하는 API를 만들어보겠습니다. 해당 API는 주문 번호와 주문한 회원의 이름, 주문 시간, 주문 상태와 주소지를 갖는 DTO를 리턴합니다.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/orders")
public List<SimpleOrderDto> findAllOrders(){
List<Order> orders = orderRepository.findAll();
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();
}
}
}
테스트를 위해 DB에는 userA, userB를 만들고 userA, B가 각각 두 개의 상품을 주문했다고 하겠습니다. 그러면 현재 생성된 총 주문(Order)의 수는 두 개입니다. 이 상황에서 주문을 조회하는 쿼리를 날려보겠습니다.
그림이 약간 희미하지만 지금 주문을 조회하기 위해서 위에서 5번의 쿼리(Order → Member → Delivery → Member → Delivery)가 실행된 것을 볼 수 있습니다. 이는 기본적으로 Order → Member와 Order → Delivery가 지연 로딩으로 설정되어 있기 때문인데요.
Order를 1번 조회하면 Order → Member에서 지연 로딩 조회를 N번 하게 되고 또 다시 Order → Delivery 지연 로딩을 N번 하게 되는 것입니다. 한편, 지연 로딩을 한다는 것은 곧 Order 엔티티와 관계를 맺는 엔티티들을 Order 조회 시 가져오지 않고, 필요한 타이밍에 쿼리를 날려 가져온다는 뜻입니다.
* JPA 지연 로딩과 프록시 객체
조금만 자세히 JPA에서의 지연 로딩과 프록시 객체에 대해 살펴보려고 합니다. 위에서 말했듯이 지연 로딩을 이용하면 Order와 관계를 맺는 Member, Delivery를 Order 조회 시점에 DB에서 조회하지 않고, 실제 엔티티의 정보가 필요한 타이밍에 쿼리를 날려 가져옵니다.
그리고 이 때 사용되는 개념이 바로 프록시인데요. 프록시 객체는 실제 클래스를 상속하여 만들어지는 객체로, 실제 클래스와 겉 모양이 같은 객체를 말합니다.
그리고 아래 그림처럼 프록시 객체는 실제 객체의 참조(target)를 보관하고 있고, 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메서드를 호출하게 됩니다.
지금 설명하는 예제 상황에서는 Order를 조회하면 최초에 지연 로딩으로 Member, Delivery에 대한 프록시 객체가 만들어집니다. 그런데 이 프록시 객체에는 Member, Delivery의 실제값은 존재하지 않고 참조(target)만 가지고 있으므로, 실제 데이터가 필요할 때는 get() 메서드를 호출하여 DB 조회를 하는 것입니다.
그리고 우리는 DTO를 생성할 때 프록시 객체가 가진 메서드인 getName(), getAddress()를 호출하여 Member, Delivery 테이블에 조회 쿼리를 날리고 있는 것입니다.
지금까지 이야기 한 내용을 정리하면, N+1 문제는 결국 목표로 하는 대상의 한 번 조회를 위해 자식 엔티티들을 N번씩 조회해야 하는 문제를 말합니다.
여기서는 주문이 두 개 존재하기 때문에 N = 2가 되고, 필요한 자식 엔티티는 Member, Delivery이기 때문에 1 + N + N = 1 + 2 + 2 = 5번의 쿼리가 발생한 것이죠. 이는 만약 세 개의 자식 엔티티들과 연관을 맺고 부모의 조회 결과가 100,000만 건이라면 1 + 100,000 + 100,000 + 100,000 + 100,000 번의 쿼리가 날라간다는 것이기 때문에, 엄청난 성능 저하를 가져올 수밖에 없습니다.
* 다만, 지연 로딩은 기본적으로 영속성 컨텍스트에서 조회하므로 이전에 쿼리에서 조회한 결과가 있다면 그 녀석은 새로운 조회 쿼리를 날리지 않기 때문에 N번 이하로 실행될 수는 있습니다.
자, 그렇다면 이제 이 문제를 해결하기 위해서는 어떻게 해야하는지 알아보겠습니다.
2. Fetch Join
Fetch Join을 사용하기 위해 OrderRepository에서 아래와 같은 조회 쿼리를 만들었습니다.
Fetch Join을 사용하면 어떤 결과가 나올까요?
아래처럼 기존의 컨트롤러에서 레포지토리의 쿼리만 변경해주도록 하겠습니다.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/orders")
public List<SimpleOrderDto> findAllOrders(){
List<Order> orders = orderRepository.findAllWithMemberDelivery(); // 이 부분만 변경
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
결과는 보시다시피 단 한 번의 조회 쿼리만 호출된 것을 볼 수 있습니다. Fetch Join을 사용하게 되면 Order를 조회할 때 연관 관계를갖는 Member, Delivery 엔티티들을 같이 묶어서 하나의 테이블로 리턴해줍니다. 이 과정에서 Order → Member, Order → Delivery는 이미 조회가 되었기 때문에 지연 로딩이 발생하지 않고, 이로 인해 재차 Member, Delivery에 조회 쿼리를 날릴 필요가 없어지게 되며 하나의 테이블 안에서 필요한 내용을 다 가져다가 쓸 수 있게 되는 것이죠.
참고로, 여기서는 o.member m, o.delivery d처럼 Member와 Delivery 필드를 모두 가져왔지만 o.member.name, o.delivery.address와 같이 써주면 필요한 필드만 가져올 수도 있습니다.
이렇게 이번 포스팅에서는 JPA에서 N+1 문제가 무엇이며 왜 발생하는지 직접 눈으로 확인을 해보고, 이를 해결하기 위한 Fetch Join 쿼리에 대해 알아보았습니다.
감사합니다.
'Spring & Springboot' 카테고리의 다른 글
[Spring/스프링] JPA에서 엔티티를 수정할 때 - 쿼리와 변경 감지 (0) | 2022.01.13 |
---|---|
@OneToMany 관계에서 Fetch Join의 사용과 페이징 처리 문제 (0) | 2021.11.21 |
[스프링/Spring] 스프링 MVC? DispatcherServlet 이해하기 (0) | 2021.11.07 |
[스프링/Spring] FrontController 패턴과 스프링 MVC (0) | 2021.11.05 |
[스프링/Spring] 서블릿(Servlet)과 서블릿 컨테이너 (0) | 2021.11.03 |