지연 로딩과 조회 성능 최적화

  • xToOne(OnetoOne, ManyToOne) 관계일 때 최적화 하기

 

엔티티를 그대로 반환

Order Entity

@Entity
@Getter @Setter
@Table(name = "orders")
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}

    //==연관관계 메서드==
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void setDelivery(Delivery delivery) {
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    protected Order() {}

    //==생성 메서드==//
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for (OrderItem orderItem : orderItems) {
            order.addOrderItem(orderItem);
        }
        order.setStatus(OrderStatus.ORDER);
        order.setOrderDate(LocalDateTime.now());

        return order;
    }

    //==비즈니스 로직==//
    public void cancel() {
        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가합니다.");
        }

        this.setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }

    //==조회 로직==//
    public int getTotalPrice() {
        return orderItems.stream()
                .mapToInt(OrderItem::getTotalPrice)
                .sum();
    }
}

 

 

Member Entity

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @NotEmpty
    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}
  • Order와 Member 엔티티를 보면, Order에 Member가 있고, Member에 Order가 있다. 즉, Order를 그대로 JSON으로 직렬화 하면 Order <-> Member(서로를 계속 호출) 양방향 연관관계의 무한 루프에 빠질 수 있다.
  • 그래서, 양방향 연관관계일 때는 한 쪽에 JsonIgnore를 걸어주어서 양방향 호출을 끊어주어야 한다.
  • DTO로 변환해서 반환하면 원칙적으로 JsonIgnore는 필요 없다.
    - 엔티티를 JSON으로 직렬화하지 않기 때문
    - DTO에는 엔티티 대신 필요한 값만 담기 때문
    - DTO에는 양방향 관계를 그대로 유지하지 않기 때문
    하지만 ->
  • 엔티티를 DTO로 변환해서 반환하더라도 웬만하면 JsonIgnore 걸어주는 게 좋다. (언제 실수가 터질지 모르기 때문) 또한 DTO 안에서도 엔티티를 그대로 노출하거나 양방향 구조를 그대로 두면 무한루프가 빠질 수 있기 때문. (DTO 안에 엔티티를 두는 것도 엔티티를 노출하는 것이기에, 이렇게 설계하면 안된다.)

 

@Entity
@Getter
@Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @NotEmpty
    private String name;

    @Embedded
    private Address address;

    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}
  • Order <-> Member 양방향 연관관계로 인해, Member에서 Order에 JsonIgnore를 걸어줬다.
  • 근데 이렇게 해주면 Type Definition Error가 난다. 왜냐하면, Order에서 Member는 지연로딩으로 설정되어 있어서 실제 Member를 가져오는 것이 아닌 프록시 객체로 넣어두기 때문이다.
  • 그래서 Jackson 라이브러리는 이 프록시 객체를 어떻게 JSON으로 바꿔야 할지 몰라서 에러를 낸다.
  • 프록시 객체로 넣어두고 나서 실제 Member를 조회 및 수정하는 작업을 요청하면 그때서야 실제 Member를 가져와서 할당한다.
  • 그래서 아래처럼 HibernateJakarta5Module (Spring 부트 3.0 이상일 때)을 스프링 빈으로 등록해서 이 프록시 객체를 무시하거나 강제 초기화하도록 지원한다.
  • 또한 프록시는 Member의 ‘가짜 객체’인데, 실제로 getName()처럼 필드를 접근하는 시점에 DB 쿼리를 날려서 데이터 채운다.
  • 단, 그 시점에 영속성 컨텍스트(세션)가 살아 있어야 한다. 이미 트랜잭션/세션이 끝났다면 → LazyInitializationException가 터진다.
  • Jackson이 직렬화 과정에서 member.getName() 등을 호출하면서 이 과정이 트리거되기도 한다.
@SpringBootApplication
public class JpashopApplication {

	public static void main(String[] args) {
		SpringApplication.run(JpashopApplication.class, args);
	}

	@Bean
	Hibernate5JakartaModule hibernate5Module() {
		return new Hibernate5JakartaModule();
	}
}

 

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }
}
  • 그러면 이제 양방향 연관관계 문제를 해결하면서 잘 조회가 된다.
  • 하지만 이렇게 해선 안된다. 엔티티를 그대로 외부로 노출해서 반환하기 때문이다.
  • 또한 필요한 속성만 뽑아서 전달하는 게 아니라, 그냥 모든 Order 엔티티의 속성을 그대로 모두 반환하기 때문에 성능 상의 문제도 있다.
  • 즉, 필요한 속성만을 뽑아서 전달해주는 DTO를 변환해서 반환해주어야 한다.

 

영속성 컨텍스트 범위

구분 트랜잭션 범위 전략 (OSIV: false) OSIV 전략 (OSIV: true, 스프링 부트 기본)
생성 시점 트랜잭션 시작 시 클라이언트 요청 들어올 때
소멸 시점 트랜잭션 커밋/롤백 시 클라이언트 응답 완료 시
특징 트랜잭션 끝나면 지연 로딩 불가 컨트롤러에서도 지연 로딩 가능
장점 DB 커넥션을 짧게 씀 (리소스 효율 좋음) 지연 로딩 코드를 자유롭게 작성 가능
단점 지연 로딩 코드를 서비스 계층에 몰아넣어야 함 DB 커넥션을 너무 오래 붙들고 있음

 

 

영속 상태와 준영속 상태

상태 객체 타입 동작 결과
영속 상태 (Tx 안)  진짜 객체 (이미 로딩됨) getName() 호출 쿼리 안 나감 (메모리에서 바로 조회)
영속 상태 (Tx 안) 프록시 객체 (지연 로딩) getName() 호출 쿼리 나감 (DB 연결해서 데이터 가져옴) -> 여기서 N+1 발생
준영속 상태 (Tx 밖) 프록시 객체 (지연 로딩) getName() 호출 쿼리 불가 (에러 발생) (LazyInitializationException)

 

 

엔티티를 DTO로 변환해서 반환

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        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) {
            this.orderId = order.getId();
            this.name = order.getMember().getName(); // LAZY 초기화
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress(); // LAZY 초기화
        }
    }
}
  • 이전과 달리, 필요한 속성만을 API 요청 스펙에 맞춰서 DTO를 정의한 후 엔티티를 DTO로 변환해서 반환해준다.
  • 하지만 V1과 V2 모두 N+1 문제가 발생한다.
  • select o from Order o만 날린다고 하면:
    1. orders 전체 조회
    2.  DTO 변환하면서 order.getMember().getName() 호출
    → Order 개수만큼 Member 지연 로딩 조회 쿼리 N번
    3. order.getDelivery().getAddress() 호출
    → Delivery 지연 로딩 조회 쿼리 N번

    총 N+1+N 쿼리 나가는 최악의 상황(모든 주문의 회원이 다르고, 배송지도 다른)이 발생한다.
  • 따라서 fetch join을 이용해서 최적화 하는 방안이 필요하다.

 

Fetch Join을 이용한 최적화

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithMemberDelivery();

        return orders.stream()
                .map(SimpleOrderDto::new)
                .collect(Collectors.toList());
    }

    @Data
    static class SimpleOrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDto(Order order) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
        }
    }
}

 

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    public void save(Order order) {
        em.persist(order);
    }

    public Order findOne(Long id) {
        return em.find(Order.class, id);
    }

    ...

    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();
    }
}
  • Order, Member, Delivery를 한 번의 SQL로 가져온다. 즉, 연관 엔티티가 이미 영속성 컨텍스트에 로딩되어 있어서 쿼리가 1번만 나간다.
  • 이후, order.getMember(), order.getDelivery()를 호출해도 이미 로딩되어 있어서 추가 select 쿼리가 나가지 않는다.
  • 그래서 모두 지연 로딩으로 설정해두고, N + 1 문제가 발생할 수 있는 조회를 fetch join을 사용하면 대부분의 성능 최적화 문제는 해결될 수 있다. (xToOne 관계일 때)
  • 하지만 1:N 관계에서, fetch join을 사용하면 데이터 뻥튀기가 발생할 수 있고 페이징을 할 수 없다는 한계가 있다.

일반 Join:

select o from Order o join o.member m
  • 조인은 하지만, SELECT 절에서 Order 데이터만 가져온다. Member 데이터는 가져오지 않는다.
  • 따라서 조회된 Order의 getMember()를 호출하면 여전히 쿼리가 나간다(N+1 발생).

 

Fetch Join:

select o from Order o join fetch o.member m
  • Member 데이터까지 한 번에 퍼올려서 영속성 컨텍스트에 다 채워 넣는다.

 

 

JPA에서 바로 DTO로 조회해서 반환

@Data
public class OrderSimpleQueryDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}

 

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

	...

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

 

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    ...

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

    public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.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();
    }
}
  • JPA에서 바로 DTO로 조회해서 반환하기에 성능면에서 더 뛰어나다.
  • 하지만 엔티티가 아닌 화면에 맞게 설계된 DTO로 조회하기에 변경 감지가 불가능하다. 따라서 재사용성이 낮다. 
  • 그리고 본래 레포지토리는 엔티티를 조회하는 용도로 사용되는 계층이다.
  • 하지만 레포지토리 계층에서 API 스펙에 맞는 조회를 하는, 레포지토리에 화면(API 스펙)에 맞춘 코드가 들어가지는 것이다.
  • 그래서 trade-off 관계에 있어서 상황에 따라 맞게 설계해야 한다.
  • 그리고 쓴다면 해당 DTO를 조회하는 별도 레포지토리를 따로 만들어서 구현하는 것이 좋다.

 

별도 분리해서 Repository를 구현

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {

    private final EntityManager em;

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

 

쿼리 방식 선택 권장 순서

1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
2. 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다. (여기서 95% 해결)
3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다

 

 

 

컬렉션 조회 최적화

  • xToOne 최적화를 알아봤으니, OneToMay 조회를 최적화해보자.

 

엔티티를 그대로 노출해서 반환

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());

        // LAZY 초기화 (프록시 초기화)
        for (Order order : all) {
            order.getMember().getName();
            order.getDelivery().getAddress();

            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName());
        }

        return all;
    }
}
  • 먼저 엔티티를 그대로 노출하는 경우를 알아 보자. 이렇게 하면 당연히 안된다. (프록시 초기화 복습용)

 

컬렉션을 DTO로 변환해서 반환

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    // 이렇게 배열로 반환하는 것도 좋지 않다. (why?)
    // List<OrderDto>를 담는 별도 DTO를 만들어서 반환하는 것이 베스트이다. (why?)
    @GetMapping("api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return result;
    }

    @Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address; // 이런 value object는 DTO로 안바꿔도 괜찮다.
        private List<OrderItemDto> orderItems;

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

    @Data
    static class OrderItemDto {

        private String itemName;
        private int orderPrice;
        private int count;

        public OrderItemDto(OrderItem orderItem) {
            this.itemName = orderItem.getItem().getName();
            this.orderPrice = orderItem.getOrderPrice();
            this.count = orderItem.getCount();
        }
    }
}
  • 쿼리가 아주 많이 나간다.
1. Orders 조회 (select * from orders where ...)
2. DTO 변환 과정에서 member 접근 -> N번 (order.getMember().getName()) : Order 수 만큼 Member 조회
3. delivery 접근 -> N번 (order.getDelivery().getAddress()) : N번
4. orderItems 접근 -> N번 (order.getOrderItems()) : Order 수 만큼 OrderItem 조회
5. orderItem().getItem() -> 각 orderItem마다 Item 로딩 : OrderItem만큼 Item 로딩 (Lazy)

즉, 1 + N + N + N + 총 주문 아이템 수
  • 따라서 컬렉션 Lazy 초기화를 하면 성능이 매우 낮아진다.

 

[
  { orderDto... },
  { orderDto... }
]
  • 그리고 현재 위처럼 루트가 없는 배열 구조로 반환하는데, 이러면 후에 다른 정보를 넣고 싶을 때 확장이 불가능하다.
  • 또한 프론트엔드에서도 공통 처리, 에러 처리, wrapper 구조가 통일되지 않아서 관리가 힘들다.
  • 따라서 무조건 응답을 객체로 감싸서 반환하는 것이 좋다.

 

페치 조인으로 최적화

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    ...

    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItems();
        List<OrderDto> result = orders.stream()
                .map(OrderDto::new)
                .collect(Collectors.toList());

        return result;
    }

    @Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address; // 이런 value object는 DTO로 안바꿔도 괜찮다.
        private List<OrderItemDto> orderItems;

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

 

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    ...

    public List<Order> findAllWithItems() {
        return em.createQuery(
                "select distinct o from Order o" +
                " join fetch o.member" +
                " join fetch o.delivery d" +
                " join fetch o.orderItems oi" +
                " join fetch oi.item i", Order.class)
            .getResultList();
    }
}

 

JPA distinct

  • 1대다 조인이 있으므로 row가 증가한다. 따라서 distinct로 중복을 제거해준다. (Order가 1개지만 OrderItem이 3개라면, row가 3개로 뻥튀기된다.)
  • 즉, 1:N 관계를 조인하면 부모 엔티티의 개수 * 자식 개수 만큼 row가 증가한다.
  • jpa의 distinct는 2가지 기능을 해준다.
    1.  SQL 레벨에서 distinct 쿼리를 날려준다. 하지만, 행의 모든 속성 값이 대부분 다 다를거기 때문에 중복 제거가 되지 않는 경우가 많다.
    2. JPA 메모리 차원에서 중복을 제거해준다. 엔티티가 같은 PK를 가지면 엔티티 인스턴스를 하나만 유지한다.
  • 덕분에 페치 조인을 사용하면 쿼리는 한 번만 실행된다.
  • 하지만, 1:N 조인으로 인해 중복된 row가 DB에서 그대로 조회되어 네트워크를 통해 애플리케이션으로 전송된다.
  • JPA는 영속성 컨텍스트 차원에서 같은 PK를 가진 엔티티를 하나로 합치지만, 데이터 전송량과 처리해야 할 row 수 자체는 줄어들지 않는다.

 

페이징 사용 불가의 단점

  • 하지만 페이징을 사용할 수 없다는 단점이 있다. 페이징을 쓰면 SQL 쿼리에 limit나 offset 등이 붙어야 정상 페이징이 된다.
  • 그런데 1:N 페치 조인에서는 이미 SQL 결과에서 row가 뻥튀기 되고 이 때문에 페이징을 SQL에서 정확하게 계산할 수가 없다.
  • 그래서 Hibernate는 DB에서 페이징을 하지 않고 모든 결과를 메모리로 가져와서 자바 컬렉션에서 페이징을 하는데, 이렇게 하면 OutOfMemory가 날 수 있는 아주 위험한 사태가 발생할 수 있다.
  • 즉, 1:N 컬렉션 페치 조인은 페이징과 절대 함께 쓰지 말 것.
  • 물론, 페이징을 사용하지 않을 것이라면 페치 조인 사용하면 된다.

 

컬렉션 페치 조인은 1개만 사용 가능

  • 컬렉션 페치 조인은 1개만 사용 가능하다. 만약 Order가 Items(1:N)도 가지고, Images(1:M)도 가진다고 가정하고, 이걸 둘 다 fetch join 걸어버리면?
    • SQL: Order JOIN Items JOIN Images
    • 결과: (주문 1개) * (아이템 10개) * (이미지 5개) = 50줄(Row)의 데이터가 생성된다. (Cartesian Product, 카르테시안 곱)
    • 즉, 곱하기의 곱하기(N * M)가 되어버린다.
  • JPA(하이버네이트) 입장에서 이렇게 뻥튀기된 데이터 속에서, 어떤 줄이 어떤 아이템이고 어떤 이미지인지 매핑하는 기준이 모호해진다.
  • 이를 억지로 맞추려다 보면 데이터가 누락되거나 꼬이는 현상이 발생하여 부정확한 데이터가 된다.

 

 

페치 조인 vs 조인

구분 join fetch join
목적 조건, 조인 대상 사용 연관 엔티티/컬렉션을 함께 로딩
연관 엔티티 로딩 보장? X (LAZY면 나중에 또 쿼리 나갈 수 있음) O (한 번에 로딩)
N+1 해결? 상황에 따라 여전히 발생 가능 적절히 쓰면 N+1 크게 줄일 수 있음

 

 

 

엔티티를 DTO로 반환 - 페이징과 한계 돌파

hibernate.default_batch_fetch_size 옵션을 이용한 지연 로딩 최적화

  • application.yml 에 hibernate.default_batch_fetch_size 옵션을 설정함으로써, 지연 로딩(LAZY)될 프록시나 컬렉션들을 여러 개 모아서 한 번의 IN 쿼리로 가져올 수 있다.
  • 원래라면 각 주문마다 order.getOrderItems()가 호출되어서 N + 1 문제가 발생될 것을, 초기화 안된 Order.orderItems 컬렉션을 전부 한 번에 IN 쿼리로 가져온다.
  • 개별적으로 하고 싶으면 엔티티에 컬렉션인 속성에 @BatchSize. 컬렉션이 아니라 One인 속성은 엔티티 자체에다가 @BatchSize.
  • 보통 전역 설정인 hibernate.default_batch_fetch_size 사용을 선호
  • 이 방식은 페치 조인 방식보다 쿼리 호출 수는 약간 증가하지만, DB -> 애플리케이션 데이터 전송량이 감소한다.
  • 무엇보다, 이 방식은 컬렉션 페치 조인으로는 불가능했던 페이징을 가능하게 한다.

 

결론

  • toOne 관계는 계속해서 페치 조인해도 상관 없다.(데이터가 뻥튀기 되지 않고, 페이징에 영향을 주지 않으므로)
  • 컬렉션은 지연 로딩으로 조회하고, hibernate.default_batch_fetch_size로 최적화 한다. (최대값=1000 / 100 ~ 1000 권장) 값이 무엇이든 간에 결국 전체 데이터를 로딩해야 하므로 메모리 사용량은 같음.
  • 결국 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.
@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    ...

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

 

 

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @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(OrderDto::new)
                .collect(Collectors.toList());

        return result;
    }

    @Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address; // 이런 value object는 DTO로 안바꿔도 괜찮다.
        private List<OrderItemDto> orderItems;

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

    @Data
    static class OrderItemDto {

        private String itemName;
        private int orderPrice;
        private int count;

        public OrderItemDto(OrderItem orderItem) {
            this.itemName = orderItem.getItem().getName();
            this.orderPrice = orderItem.getOrderPrice();
            this.count = orderItem.getCount();
        }
    }
}

 

 

 

JPA에서 컬렉션이 있을 때 DTO 직접 조회

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

   ...

    @GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4() {
        return orderQueryRepository.findOrderQueryDtos();
    }
}

 

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    public List<OrderQueryDto> findOrderQueryDtos() {
        List<OrderQueryDto> result = findOrders();
        result.forEach(o -> { // N번 실행
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });

        return result;
    }

    private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                " from OrderItem oi" +
                " join oi.item i" +
                " where oi.order.id = :orderId", OrderItemQueryDto.class)
            .setParameter("orderId", orderId)
            .getResultList();
    }

    // sql에서는 컬렉션을 넣어서 생성자 호출을 못한다
    private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
                " from Order o" +
                " join o.member m" +
                " join o.delivery d", OrderQueryDto.class)
            .getResultList();
    }
}

 

@Data
public class OrderQueryDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemQueryDto> orderItems;

    public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}



@Data
public class OrderItemQueryDto {

    @JsonIgnore
    private Long orderId;
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}
  • JPQL의 new 생성자 표현식은 컬렉션을 직접 생성자에 넣을 수 없다.
    • 그래서 orderItems를 함께 넣는 한 방짜리 DTO 생성이 안 되는 것.
    • 또, join으로 row가 늘어나서 주문 기준으로 중복 row가 생성돼버림.
  • 쿼리는 Order(루트) + Member + Delivery 1번(루트와 toOne 관계에 있는), 컬렉션 N번 실행
  • toOne 관계들을 먼저 조회하고, toMany 관계는 각각 별도로 처리. toMany 관계는 조회하면 row 수가 증가하기 때문

 

 

createQuery가 필요한 상황 예시

상황 이유
fetch join 사용 findAll로는 제어 불가능
DTO projection Repository 기본 메서드 불가
여러 테이블 join 복잡한 JPQL 필요
조건이 복잡 메서드명으로 표현 불가
통계/집계 count, group by 등

 

 

 

 

JPA 컬렉션 DTO 조회 최적화 - 1

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

	...

    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5() {
        return orderQueryRepository.findAllByDto_optimization();
    }
}

 

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

	...

    // sql에서는 컬렉션을 넣어서 생성자 호출을 못한다
    private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
                " from Order o" +
                " join o.member m" +
                " join o.delivery d", OrderQueryDto.class)
            .getResultList();
    }

    public List<OrderQueryDto> findAllByDto_optimization() {
        List<OrderQueryDto> result = findOrders();

        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

        return result;
    }

    private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
        // id를 한 번에 가져옴
        List<OrderItemQueryDto> orderItems = em.createQuery(
                        "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                                " from OrderItem oi" +
                                " join oi.item i" +
                                " where oi.order.id in :orderIds", OrderItemQueryDto.class)
                .setParameter("orderIds", orderIds)
                .getResultList();

        // 맵으로 바꿔서 편하게 매핑
        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
        return orderItemMap;
    }

    private static List<Long> toOrderIds(List<OrderQueryDto> result) {
        List<Long> orderIds = result.stream()
                .map(OrderQueryDto::getOrderId)
                .collect(Collectors.toList());
        return orderIds;
    }
}
  • toMany 관계에 있는 OrderItem에 대해, 쿼리를 N번 날려서 가져오지 않고 한 번의 쿼리로 가져온다.
  • 그리고 이를 맵으로 바꾸어서 OrderQueryDto에 매핑해주어서 효율적으로 한다. 즉, 조립을 메모리에서 하는 것.
  • 그래서 1 + 1의 쿼리로 최적화 되었다.

 

 

컬렉션 DTO 조회 최적화 - 2

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

    ...
    
    @GetMapping("/api/v6/orders")
    public List<OrderQueryDto> ordersV6() {
        List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();

        return flats.stream()
                .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(),
                        o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
                        mapping(o -> new OrderItemQueryDto(o.getOrderId(),
                                o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
                        )).entrySet().stream()
                .map(e -> new OrderQueryDto(e.getKey().getOrderId(),
                        e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(),
                        e.getKey().getAddress(), e.getValue()))
                .collect(toList());
    }
}

 

@Data
public class OrderFlatDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    private String itemName;
    private int orderPrice;
    private int count;

    public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

 

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    ...

    public List<OrderFlatDto> findAllByDto_flat() {
        return em.createQuery(
                     "select new" +
                        " jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, i.price, oi.count)" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d" +
                        " join o.orderItems oi" +
                        " join oi.item i", OrderFlatDto.class)
            .getResultList();
    }
}
  • 쿼리는 한 번만 나가지만, 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5보다 느릴 수 있다.
  • 그리고 애플리케이션에서 추가 작업이 크다.
  • 이 경우, Order 기준으로는 페이징이 불가능하다.

 

관계 페이징 여부 전략 이유
xToOne (Member -> Team)  상관없음 Fetch Join 데이터 뻥튀기 없음. 쿼리 1방이 제일 빠름.
xToMany (Team -> Members) 페이징 함 Join X (Batch Size) Fetch Join 하면 메모리 터짐. Batch Size가 유일한 답.
xToMany (Team -> Members) 페이징 안 함 Fetch Join (선택) 상세 화면(Detail)처럼 데이터 1개만 조회할 땐 써도 됨.
관계 무관 조건/정렬만 필요 일반 Join 데이터는 안 필요하고 WHERE, ORDER BY에만 쓸 때.