N+1 문제

hgh1472·2024년 9월 9일

스프링

목록 보기
5/8

프로그래머스 과제 중 N+1 문제를 발견했다.

상황은 3개의 Entity가 존재한다. Product, Order, OrderItem 으로 존재하고, Product는 등록된 제품이다. 사용자는 주문을 할 수 있고, 여러 제품을 주문할 수 있다. 제품 당 주문내역은 OrderItem으로 관리되고, Order는 여러 개의 OrderItem을 가질 수 있다.

Product


@Entity
@Builder
@ToString
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Product {

	@Id
	// @Column(columnDefinition = "BINARY(16)")
	private UUID productId;

	private String productName;

	private String category;

	private Long price;

	private String description;

	private LocalDateTime createdAt;

	private LocalDateTime updatedAt;

	@PrePersist
	public void prePersist() {
		this.productId = UUID.randomUUID();
		this.createdAt = LocalDateTime.now();
	}

	@PreUpdate
	public void preUpdate() {
		this.updatedAt = LocalDateTime.now();
	}

	public static Product from(NewProductDTO newProductDTO) {
		return Product.builder()
			.productName(newProductDTO.getProductName())
			.category(newProductDTO.getCategory())
			.price(newProductDTO.getPrice())
			.description(newProductDTO.getDescription())
			.build();
	}

	public void updateProduct(ProductDTO productDTO) {
		this.productName = productDTO.getProductName();
		this.category = productDTO.getCategory();
		this.price = productDTO.getPrice();
		this.description = productDTO.getDescription();
	}
}

Order

/**
 * MySQL에서 order는 예약어 => order로 저장하면 오류 발생
 */

@Entity
@Builder
@ToString
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(name = "orders")
public class Order {

	@Id
	private UUID orderId;

	private String email;
	private String address;
	private String postCode;
	private String orderStatus;
	private LocalDateTime createdAt;
	private LocalDateTime updatedAt;

	@OneToMany(mappedBy = "order")
	private List<OrderItem> orderItems = new ArrayList<>();

	@PrePersist
	public void prePersist() {
		this.orderId = UUID.randomUUID();
		this.createdAt = LocalDateTime.now();
		LocalDateTime today2PM = LocalDateTime.of(createdAt.toLocalDate(), LocalTime.of(14, 0));

		if (createdAt.isBefore(today2PM)) {
			this.orderStatus = "당일배송";
		} else {
			this.orderStatus = "출고준비중";
		}
	}

	@PreUpdate
	public void preUpdate() {
		this.updatedAt = LocalDateTime.now();
	}

	public static Order from(OrderRequestDTO requestDTO) {
		return Order.builder()
			.email(requestDTO.getEmail())
			.address(requestDTO.getAddress())
			.postCode(requestDTO.getPostCode())
			.build();
	}
}

OrderItem

@Entity
@Getter
@ToString
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class OrderItem {

	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long seq;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "order_id")
	private Order order;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "product_id")
	private Product product;

	private String category;

	private Long price;

	private Integer quantity;

	private LocalDateTime createdAt;

	private LocalDateTime updatedAt;

	@PrePersist
	public void prePersist() {
		this.createdAt = LocalDateTime.now();
	}

	@PreUpdate
	public void preUpdate() {
		this.updatedAt = LocalDateTime.now();
	}

	public static OrderItem of(Product product, Integer quantity, Order order) {
		return OrderItem.builder()
			.order(order)
			.product(product)
			.category(product.getCategory())
			.price(product.getPrice() * quantity)
			.quantity(quantity)
			.build();
	}
}

사용자의 주문 내역 조회

이 때 사용자는 자신의 email을 통해 주문내역을 검색한다. 이 때 주문내역은 주문한 제품명 포함해야 한다. 그렇다면, Order ↔ OrderItem ↔ Product 전부 찾아야 한다. 만약 JPA에서 제공하는 findByEmail 메서드 그대로 사용하면 어떻게 될까? 조회 로직은 다음과 같다.

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {

	private final OrderRepository orderRepository;

	public List<OrderDTO> getOrders(String email) {
		List<Order> byEmail = orderRepository.findByEmail(email, Sort.by(Sort.Direction.ASC, "createdAt"));
		log.info("=== byEmail ===");
		 return byEmail.stream()
			.map(OrderDTO::from)
			.toList();
	}
	
	/*
	public static OrderDTO from(Order order) {
		return OrderDTO.builder()
			.email(order.getEmail())
			.address(order.getAddress())
			.postCode(order.getPostCode())
			.orderStatus(order.getOrderStatus())
			.orderItems(order.getOrderItems().stream().map(OrderItemDTO::from).toList())
			.build();
	}
	
	public static OrderItemDTO from(OrderItem orderItem) {
		return OrderItemDTO.builder()
			.productName(orderItem.getProduct().getProductName())
			.category(orderItem.getCategory())
			.price(orderItem.getPrice())
			.quantity(orderItem.getQuantity())
			.build();
	}
	*/
}

생각만해도 쿼리문이 여러 개 나갈 것으로 예상된다. 현재 DB에는 1개의 주문이 있고, 이 주문에 2개 제품을 주문한 것으로 되어있다.

Hibernate: 
    select
        o1_0.order_id,
        o1_0.address,
        o1_0.created_at,
        o1_0.email,
        o1_0.order_status,
        o1_0.post_code,
        o1_0.updated_at 
    from
        orders o1_0 
    where
        o1_0.email=? 
    order by
        o1_0.created_at
2024-09-08T23:52:49.716+09:00  INFO 3928 --- [coffee] [nio-8080-exec-2] p.coffee.order.service.OrderService      : === byEmail ===
Hibernate: 
    select
        oi1_0.order_id,
        oi1_0.seq,
        oi1_0.category,
        oi1_0.created_at,
        oi1_0.price,
        oi1_0.product_id,
        oi1_0.quantity,
        oi1_0.updated_at 
    from
        order_item oi1_0 
    where
        oi1_0.order_id=?
Hibernate: 
    select
        p1_0.product_id,
        p1_0.category,
        p1_0.created_at,
        p1_0.description,
        p1_0.price,
        p1_0.product_name,
        p1_0.updated_at 
    from
        product p1_0 
    where
        p1_0.product_id=?
Hibernate: 
    select
        p1_0.product_id,
        p1_0.category,
        p1_0.created_at,
        p1_0.description,
        p1_0.price,
        p1_0.product_name,
        p1_0.updated_at 
    from
        product p1_0 
    where
        p1_0.product_id=?
	

총 4번의 쿼리가 나갔다. 각 쿼리의 이유를 분석해보자.

  • 첫번째 쿼리
    • findByEmail
  • 두번째 쿼리
    • OrderDTO의 from 메서드 중 orderItems(order.getOrderItems().stream().map(OrderItemDTO::from).toList())
    • 주문과 관련된 OrderItem을 불러오기 위한 쿼리
  • 세번째, 네번째 쿼리
    • OrderItemDTO의 from 메서드 중 productName(orderItem.getProduct().getProductName())
    • 각 OrderItem 마다 Product에 접근 : 2개의 OrderItem ⇒ 2개의 Product 접근 쿼리

두번째 쿼리 ↔ 세, 네번째 쿼리는 N+1문제로 볼 수 있다. order.getOrderItems 를 통해 Order와 연관된 OrderItem을 한꺼번에 불러온다. 즉, Order의 프록시 객체로 존재하는 List을 바로 각각 접근하지 않고 한꺼번에 불러와서 한개씩 접근하지 않고 한번에 불러왔다.

하지만 OrderItem의 Product도 프록시 객체로 존재하게 된다. 따라서 결국 OrderItem 마다 Product에 접근하게 되므로 OrderItem 개수만큼 쿼리가 나가게 된다. OrderItem의 Product에 접근하게 되면서 N+1문제가 발생하게 된 것이다.

이를 해결하기 위해 Fetch Join을 사용했다.

@Query("SELECT o FROM Order o " +
		"JOIN FETCH o.orderItems oi " +
		"JOIN FETCH oi.product " +
		"WHERE o.email = :email " +
		"ORDER BY o.createdAt ASC")
List<Order> findByEmail(String email, Sort sort);

Jpa 인터페이스를 위와 같이 작성하였다. 실제 실행 쿼리를 살펴보자.

Hibernate: 
    select
        o1_0.order_id,
        o1_0.address,
        o1_0.created_at,
        o1_0.email,
        oi1_0.order_id,
        oi1_0.seq,
        oi1_0.category,
        oi1_0.created_at,
        oi1_0.price,
        p1_0.product_id,
        p1_0.category,
        p1_0.created_at,
        p1_0.description,
        p1_0.price,
        p1_0.product_name,
        p1_0.updated_at,
        oi1_0.quantity,
        oi1_0.updated_at,
        o1_0.order_status,
        o1_0.post_code,
        o1_0.updated_at 
    from
        orders o1_0 
    join
        order_item oi1_0 
            on o1_0.order_id=oi1_0.order_id 
    join
        product p1_0 
            on p1_0.product_id=oi1_0.product_id 
    where
        o1_0.email=? 
    order by
        o1_0.created_at,
        o1_0.created_at
2024-09-09T00:27:33.296+09:00  INFO 18924 --- [coffee] [nio-8080-exec-1] p.coffee.order.service.OrderService      : === byEmail ===

4개의 쿼리가 1번의 쿼리에서 한번에 조회함으로써 줄어들었다.

하지만 Fetch Join의 경우 Pagination 에서 문제가 발생한다. 실제로 주문내역이 여러 개라면 Paging 처리를 하게될텐데 이 때 문제가 생기는 것 같다.

0개의 댓글