티스토리 뷰

One to One, Many to One 관계를 갖는 객체를 조회할 때 어떻게 조회해서 반환하도록 api를 구현해야하는지 알아본다.

 

Order (주문 객체)

@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]
}

회원이 품목을 골라서 주문을 하게 되는데 이렇게 발생한 모든 주문 목록을 가져와 보자.


쿼리 N + 1 문제

한번의 조회에서 N번의 추가 조회가 발생하는 문제를 말한다.

 

1회 조회에 얻는 N개의 결과에 연동된 LAZY LOADING(지연로딩) 된 객체에 대해 N번의 추가 조회가 발생하는 것으로, 만약 조회 결과 객체 내에 LAZY 객체가 2개가 있다면 (1번의 조회에 N개의 결과를 얻게될 경우), 총 쿼리 수는 1 + N + N 번 발생하게 된다.

 

만약 100번 조회시 (2N + 1) * 100번의 쿼리가 발생되므로 성능상에도 큰 문제가 생길 수 있다.

 

지연로딩

x To One 관계에 대해서는 기본 값이 EAGER로 즉시 로딩이 설정되어 있다. 하지만 이렇게 되면 하나의 주문을 조회해도 해당 주문에 관련된 모든 회원, 배달을 확인하는 쿼리가 발생한다. 이를 막기 위해 LAZY로 변경 시 하위 엔티티에 대해서는 조회하지 않게 된다.

 

즉시로딩

EAGER의 경우 하위 엔티티에 대해 조회가 발생하면 그 즉시 찾아서 같이 반환하는 것이다. 예를들어 앨범 엔티티에 여러 곡을 List로 같은 속성값이 있다고하자. 이 앨범을 조회하면 그 즉시 하위 곡을 모두 찾아서 채운 후 앨범을 반환하는 것이다.

이때 N개의 앨범이 있을 때 앨범 목록을 조회하면 각각의 앨범마다 하위 노래 목록을 찾는 쿼리가 발생하게 되어 1(앨범조회) + N(각 앨범 별 노래 목록 조회)가 발생하게 된다.

 

🐢 예시 코드

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        List<Order> orders = orderDaoImpl.findAllByString(new OrderSearch());
        List<SimpleOrderDto> result = orders.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());
        return result;
    }

    @Data
    private 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();
        }
    }

모든 주문을 가져오는 orderV2()가 정의되어 있고, 이때 api를 받기 위해 SimpleOrderDto를 사용하고 있다.

 

이때 각 주문 정보를 가지고 Dto객체를 생성하는데 order.getMember().getName()과 order.getDelivery().getAddress()에 의해서 LAZY가 걸려있는 Member객체와 Delivery객체를 강제로 LAZY를 풀고 조회해서 가져오게 된다.

 

Order는 Member하나가 (주문을)여러개를 가질 수 있는 ManyToOne의 관계이고, Order하나에 Delivery하나가 달리는 OneToOne의 관계이다.

즉 X - To One의 관계를 갖는것이다. 따라서 Order하나를 조회해도 어느 회원하나, 어느 배달 하나와 연동이 되어 있다는 것이므로, 각 주문 객체를 조회할 때 해당 주문을 한 회원을 찾는 쿼리, 해당 주문과 연관된 배달을 찾는 쿼리가 발생한다. 

 

결과적으로 하나의 주문 객체를 조회하면서 N개의 주문 결과를 얻으면 주문별로 한번씩 회원과 배달을 찾기위해 총 1번(목록조회) + N번(Member) + N번(Delivery)의 조회 쿼리가 발생하게 되는 것이다.

 

이러한 N + 1 문제를 해결하기 위해 FETCH JOIN 방법을 사용해야 한다.


FETCH JOIN : N + 1 문제 해결

fetch join을 이용해서 연관관계가 있는 객체를 조회할 때 하나의 테이블로 묶어서 한번에 조회하는 방법이다.

 

🐢 예시 api 코드

	@GetMapping("/api/v3/simple-orders")
	public List<SimpleOrderDto> ordersV3() {
		List<Order> orders = orderDaoImpl.findAllWithMemberDelivery();
		return orders.stream()
				.map(SimpleOrderDto::new)
				.collect(Collectors.toList());
	}

Repository에 새로 조회용 메서드를 만들어서 반환받은 값을 DTO로 변환해서 반환하는 api 구조이다.

 

🐢 구현 코드 - findAllWithMemberDelivery()

	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();
	}

내부에서 쿼리를 이용해서 주문과 관련된 모든 회원과 배송을 join해서 하나의 row쿼리를 생성하고 이를 반환한다. 이렇게 반환된 것을 JPA가 알아서 쪼개주고, 이를 DTO객체에 맞춰 넣어서 반환해준다.

 

실제 모든 객체를 JOIN해서 만든 결과를 반환하므로 LAZY에 대해 따로 신경쓰지 않아도 된다.

 


FETCH JOIN : 최적화

바로 위 apiV3에서는 OrderRepository에서 원하는 조건을 fetch join으로 합친 후 Order Entity로 가져왔다. 이러한 방법 대신 DTO로 바로 가져오는 방법을 알아보자.

 

🐢 DTO로 바로 가져와서 반환하는 api 구현

@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
	return orderSimpleQueryRepository.findAllDtos();
}

 

🐢 API 처리를 위한 DTO 객체를 만들어주는 findAllDtos()메소드

public List<OrderSimpleQueryDto> findAllDtos() {
	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();
}

쿼리에서 바로 원하는 정보로 이뤄진 DTO 객체를 생성해서 반환한다.

이렇게 SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트워크 용량 최적화가 가능하다 (획기적으로 줄지는 않는다)

 


결론

조회할 때 절대 Entity그대로 조회해서 반한하면 안되고 맞춤형 DTO 객체를 생성해서 반환해야 한다.

 

이때  두가지 방법이 있는데

방법 1) Entity를 가지고 query문을 통해 필요한 객체를 fetch join해서 반환한 후 해당 쿼리를 DTO에 맞춰서 반환하는 것

방법 2) 반환형을 DTO 리스트로 갖도록 조회 때 바로 DTO 객체를 생성해서 반환하는 것

 

두가지가 존재한다. 이 엔티티를 DTO로 변환하는 것과 DTO로 바로 생성해서 조회하는 방법은 상황에 따라서 더 나은 방법을 골라서 쓰면된다.

 

엔티티를 조회하고 DTO로 변환하는 방법의 장점은 엔티티 레포지토리의 재사용성이 좋고 코드가 간결하고, 단점은 Entity의 모든 정보를 받아오므로 select 수가 더 많다는 것이다.

 

DTO를 바로 생성해서 조회하는 방법의 장점은 필요한 값만 가져오므로 select수가 정확히 필요한 만큼만 호출된다, 단점은 해당 API에 맞춤형 메서드이므로 확장성을 가지기 어렵고 반환값이 DTO이므로 2차적인 사용이 불가하는 점이다.

 

일반적으로 쿼리를 최적화하기 위한 단계

1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다. (Entity조회 -> DTO 변환, N + 1문제 내포)

2. + fetch join으로 성능을 최적화 한다. (LAZY 객체에 대해 FETCH JOIN으로 N + 1 문제 해결)

3. ++ DTO로 직접 조회하는 방법을 사용한다. (SELECT 수를 줄일 수 있다, 요구사항에 비해 너무 많은 속성값을 가져온다면 의미 있다.)

4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.

4번 방법은 아직 모르는 방법이므로... 추후 학습시 작성하자.

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