티스토리 뷰

컬렉션 관계인 일대다 관계를 조회하는 경우에 대해 알아본다.

 

아래와 같은 Order객체가 있을 때 모든 주문 목록과 각 주문에 포함된 주문 상품목록을 함께 가져온다고 생각해보자. Order 내 OrderItem은 리스트로 존재하므로 1 : N 관계를 가진다.

@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
	@JoinColumn(name = "delivery_id")
	private Delivery delivery;

	private LocalDateTime orderDate; // 주문시간

	@Enumerated(EnumType.STRING)
	private OrderStatus status; // 주문상태 [ORDER, CANCEL]
}

 


주문목록 가져오기 V1

  • 엔티티를 바로 반환하고 있다.
  • LAZY 로딩을 해제해주기 위해 값에 접근하면서 순회를 막기위해 JsonIgnore를 엔티티에 달아줘야 한다 (엔티티 수정 발생)
모든 주문 목록을 가져오는 ordersV1()
/*
Order -> OrderItem 내 Order에 JsonIgnore
Order -> Delivery 내 Order에 JsonIgnore
필요
 */
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
	List<Order> all = orderRepository.findAllByString(new OrderSearch());
	for (Order order : all) {
		order.getMember().getName(); // LAZY 해제, 즉시로딩
		order.getDelivery().getAddress(); // LAZY 해제, 즉시로딩

		List<OrderItem> orderItems = order.getOrderItems();
		orderItems.forEach(o -> o.getItem().getName()); // LAZY 해제, 즉시로딩
	}
	return all;
}

바로 모든 Order목록을 가져와서 반환하면 LAZY가 걸려있는 meber, orderItems, delivery에 관한 정보는 가져오지 않게 된다. 따라서 직접 값에 접근해줘야 LAZY가 풀리면서 프록시 객체가 아닌 실제 값이 들어가게 된다.

 

물론 이때 N+1 쿼리도 같이 발생한다.

 

현재 orders를 조회하는 api는 재귀를 막기 위해 Order의 속성인 Member, Delivery내 에 존재하는 Order에 대해 @JsonIgnore를 걸어줘야 하고, 값의 반환도 Entity 그대로 하고 있다.

 


주문목록 가져오기 V2

  • 엔티티를 바로 가져오지 않기위해 orderDto를 생성해서 해당 타입으로 반환한다.
  • Order 내 OrderItem객체들을 가져와야하는데 이들도 Entity이므로 바로 반환하면 안되고 orderItemDto로 변환해서 반환해야 한다.
  • 지연로딩이 Order -> Member, Delivery, OrderItem에 걸려있고, OrderItem -> Item에 걸려있으므로 너무 많은 쿼리가 발생한다.
    Orders 목록이 2개일 경우, 하나의 Order에서 Member, Delivery, OrderItem 을 조회하고, OrderItem 내 Item이 2개라면 2번 Item 이름을 조회하게 된다. 따라서 1회 (주문 목록 조회) + (Member, Delivery, OrderItem, item1, item2) * 2(개 목록) , 총 11회의 쿼리가 발생하게된다.
  • SQL 실행 수 order 1번
    • member , address N번(order 조회 수 N)
    • orderItem N번(order 조회 수 N)
    • item N*M번(orderItem 조회 수 M)
orderItemDto로 order 목록 가져오기 : ordersV2()
	@GetMapping("/api/v2/orders")
	public List<OrderDto> ordersV2() {
		List<Order> orders = orderRepository.findAllByString(new OrderSearch());
		List<OrderDto> all = orders.stream()
				.map(OrderDto::new)
				.collect(Collectors.toList());
		return all;
	}

	@Data
	static class OrderDto {
		private Long orderId;
		private String name;
		private LocalDateTime localDateTime;
		private OrderStatus orderStatus;
		private Address address;
		private List<OrderItemDto> orderItems;

		public OrderDto(Order order) {
			orderId = order.getId();
			name = order.getMember().getName();
			localDateTime = order.getOrderDate();
			orderStatus = order.getStatus();
			address = order.getDelivery().getAddress();
			orderItems = order.getOrderItems().stream()
					.map(OrderItemDto::new)
					.collect(Collectors.toList());
		}
	}

	@Data
	static class OrderItemDto {
		private Long orderItemId;
		private String itemName;
		private int price;
		private int count;

		public OrderItemDto(OrderItem o) {
			orderItemId = o.getId();
			itemName = o.getItem().getName();
			price = o.getOrderPrice();
			count = o.getCount();
		}
	}

Order, OrderItem에 대해서 DTO를 잘 적용해서 주문목록을 반환하지만 너무 많은 쿼리가 발생한다. one to one, many to one의 경우 fetch join을 사용해도 row수에 변화가 없지만 one to Many의 경우 fetch join을 사용하면 데이터 뻥튀기(row 증가)가 될 수 있다.

 

따라서 fetch join을 사용해서 해결하기 위해서는 조건들이 필요하다.

 


주문목록 가져오기 V3

N+1 쿼리 문제를 없애기 위해서는 fetch join을 사용하면되는데 어떻게 적용해야 되는지 보자.

 

fetch join을 적용해서 orderItemDTO 가져오기 : ordersV3()
	@GetMapping("/api/v3/orders")
	public List<OrderDto> orderV3() {
		List<Order> orders = orderRepository.findAllWithItem();
		List<OrderDto> all = orders.stream()
				.map(OrderDto::new)
				.collect(Collectors.toList());
		return all;
	}

 

fetch join 쿼리 적용해서 가져오기
	public List<Order> findAllWithItem() {
		return em.createQuery(
						"select 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();
    }

 

위와 같이 fetch join하면 쿼리는 딱 한번만 불려진다. 하지만 1 : N 관계를 갖는 Order - orderItems에서 문제가 발생한다. 주문 목록 N개가 있는데, 각 주문 마다 상품이 M개씩 있다면 하나의 테이블로 합치기 위해서 N * M 줄의 row를 만들어서 반환하게 된다.

 

즉, 주문하나에 여러개 존재하는 상품으로 인해 row도 중복으로 발생하게 되므로 api 응답으로 오는 주문목록도 N개가 아니라 N * M개가 반환되게 된다.

 

이를 방지하기 위해 jpql을 작성할 때 DISTINCT 를 추가적으로 달아줘야한다.

  • 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();
  • distinct를 달아주게 되면 동일한 row뿐만 아니라 기준이 되는 객체에 대해서도 중복을 제거해서 반환해준다.

 

따라서, OneToMany의 컬렉션 조회시 FETCH JOIN + DISTINCT를 함께 사용해야 N + 1문제도 해결하고 중복 객체에 대한 문제도 해결할 수 있다.

 

문제점

일대다 관계에서 FETCH JOIN + DISTINCT를 사용하면 다 해결되는 것처럼 보이지만 문제점이 존재한다

 

  1. 페이징 사용이 불가능하다
    • 페이징은 쿼리 뒤에 .setFirstResult(1).setMaxResults(10) 등을 추가해서 첫 시작 부분, 최대 결과 개수를 조절해주는 역활을 하는데 DISTINCT를 적용하면서 fetch join으로 인해 다(N)객체 기준으로 생성된 DB 테이블과 우리가 예상하는 테이블이 충돌이 나면서 페이징을 DB에서 제대로 적용을 하지 못하게된다.
    • 따라서 JPA는 모든 DB를 읽은 후 이를 메모리에 전부 올려서 메모리단에서 페이징을 처리하게 된다. 이는 조회되는 값이 조금만 많아도 메모리 문제를 일으킬 수 있으므로 절대 해서는 안된다.
    • 결론 : DISTINCT와 페이징은 함께 사용할 수 없다.
  2. 일대다 관계에 대한 FETCH JOIN은 하나만 가능하다
    • fetch join을 하면 N(다)쪽 수에 맞춰서 1(일)을 전부 붙이게 되는데 여기에 추가로 M(다)가 적용되면 N * M 테이블이 생성되게 된다. 이러면 row수도 엄청나게 증가될 뿐더러 jpa가 무엇을 기준으로 db에서 값을 가져와야 되는지 모호해지면서 결과도 틀려지게 된다.
    • 즉, 조회된 데이터의 부정합(모순)이 발생할 수 있다.

주문목록 가져오기 V3.1

그렇다면 컬렉션 엔티티를 조회할 때 FETCH JOIN과 페이징을 함께 적용하기 위해서 어떻게 해야할까?

  1. x To One 관계를 모두 FETCH JOIN한다.
    • 어짜피 to One관계는 Column값만 증가시킬뿐 Row는 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  2. 컬렉션은 지연로딩으로 조회한다.
  3. 지연 로딩 성능 최적화를 위해서 hibernate.default_batch_fetch_size, @BatchSize를 사용한다.
    • hibernate.default_batch_fetch_size 설정이란?
      • 글로벌 설정 (application.yaml에서 설정), @BatchSize는 개별설정으로 컬렉션(XtoMany) Entity에 달아주면 된다.
      • 이 옵션을 사용하면 프록시 객체, 컬렉션 객체를 설정한 크기만큼 IN 쿼리로 조회해서 한번에 가져온다.
      • 아래처럼 모든 상품을 한번에 가져온다.
컬렉션 조회를 위한 FETCH JOIN + batch size 설정 : orderV3_page()
	@GetMapping("api/v3.1/orders")
	public List<OrderDto> orderV3_page(
			@RequestParam(value = "offset", defaultValue = "0") int offset,
			@RequestParam(value = "limit", defaultValue = "100") int limit) {
		// toOne 관계 객체를 먼저 FETCH JOIN으로 가져온다.
		List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
		// 이후 toMany(컬렉션)객체를 가져온다.
		List<OrderDto> all = orders.stream()
				.map(OrderDto::new)
				.collect(Collectors.toList());
		return all;
	}

 

페이징을 적용한 쿼리
	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();
	}

 

위와 같이 toOne관계에는 fetch join을 사용해서 N + 1문제를 해결하고, 컬렉션 객체는 default_batch_fetch_size를 사용해서 최적화하면 1 + N을 1 + 1로 만들 수 있다.

 

또한 전송되는 데이터량이 INNER JOIN이므로 필요한 값만 테이블에 생성되므로 약간의 쿼리가 더 발생하더라도 데이터 전송량이 줄어준다.

 

결론

FETCH JOIN을 사용해서 X to One 객체의 N + 1 쿼리 문제를 해결할 수 있다. 반면에 OneToMany인 컬렉션에 대해서는 FETCH JOIN을 하면 Many 객체를 기준으로 TABLE이 새로 생성되어 원하는 결과값을 얻을 수가 없었다. 이를 해결하기 위해 DISTINCT를 select 시 추가하는 방법을 사용했다.

 

하지만 DISTINCT를 사용하면 중복된 기준 객체에 대한 중복을 걸러주지만 PAGING(페이징)을 사용할수 없다는 단점이 존재했다. 이를 다시 해결하기 위해서 XtoOne 객체만 우선 FETCH JOIIN으로 조회해 준 후 나머지 컬렉션 객체는 LAZY 조회를 했다. 이 때 발생하는 N + 1문제를 해결하기 위해서 hibernate.default_batch_fetch_size를 설정해 주었다.

 

즉, PAGING을 사용하고 싶다면 BatchSize설정 후 toOne 관계에는 FETCH JOIN을 나머지는 따로 LAZY 조회를 하고 그렇지 않다면 전부 FETCH JOIN을 해서 조회하면된다. (단, fetch join은 컬렉션에 대해서는 하나까지만 가능하다)

 

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday