[실전! 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] API 개발 고급(3): 컬렉션 조회 최적화(1)

2024. 8. 27. 16:28·Back-End/JPA
목차
  1. 컬렉션 조회 최적화(1)

[작성일: 2023. 10. 03]

 

컬렉션 조회 최적화(1)

주문 조회 V1: 엔티티 직접 노출

주문내역에서 추가로 주문한 상품 정보를 추가로 조회하려면 Order 기준으로 컬렉션인 OrderItem과 Item이 필요하다.

지금은 OneToOne, ManyToOne 관계만 있다.

이번에는 컬렉션인 일대다 관계(OneToMany)를 조회하고 최적화 하는 방법을 알아보자.

 

@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName();
order.getDelivery().getAddress();
List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream().forEach(
o -> o.getItem().getName());
}
return all;
}
}

 

 

orderItem, Item 관계를 직접 초기화하면 Hibernate5JakartaModule 설정에 의해 엔티티를 JSON으로 생성한다.

양방향 연관관계면 무한 루프에 걸리지 않게 한 곳에 @JsonIgnore를 추가해야 한다. 

하지만 엔티티를 직접 노출하는 것은 좋은 방법이 아니다.

 

 

 

 

 

주문 조회 V2: 엔티티를 DTO로 변환

@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
// ... 코드 생략
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o))
.collect(Collectors.toList());
return collect;
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(Collectors.toList());
}
}
@Data
static class OrderItemDto {
private String itemNames;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemNames = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
}

 

 

지연 로딩으로 인해 너무 많은 SQL이 실행되고 있다.

  • order 1번
  • member, address N번(order 조회 수만큼)
  • orderItem N번(order 조회 수만큼)
  • item N번(orderItem 조회 수만큼)

 

참고: 지연 로딩연 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고, 없으면 SQL을 실행한다.
따라서 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않는다.

 

 

 

 

 

주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
// ... 코드생략
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o))
.collect(Collectors.toList());
return collect;
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(Collectors.toList());
}
}
@Data
static class OrderItemDto {
private String itemNames;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemNames = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
}

 

@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
// ... 코드생략
public List<Order> findAllWithItem() {
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();
}
}

 

페치 조인으로 SQL이 한 번만 실행된다.

distinct를 사용한 이유는 일대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은 order 엔티티의 조회 수도 증가하게 된다.

JPA의 distinct는 SQL에 distinct를 추가하고, 같은 엔티티가 조회되면 애플리케이션에서 중복을 걸러준다.

 

참고: 
컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고 메모리에서 페이징 해버린다.(매우 위험!)

컬렉션 페치 조인은 1개만 사용할 수 있다. 데이터가 부정합하게 조회될 수 있으므로 컬렉션 둘 이상에 페치 조인을 사용하면 안 된다.

 

 

 

 

 

주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파

컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.

일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적인데 데이터는 다(N)을 기준으로 row가 생성된다.

Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버리는 것이다.

이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도하며, 최악의 경우 장애가 발생한다.

 

 

그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?

  1. 먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치 조인한다. ToOne 관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  2. 컬렉션은 지연 로딩(LAZY)으로 조회한다.
  3. 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.
    • hibernate.default_batch_fetch_size: 글로벌 설정
    • @BatchSize: 개별 최적화
    • 이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

 

@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();
List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o))
.collect(Collectors.toList());
return collect;
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(Collectors.toList());
}
}
@Data
static class OrderItemDto {
private String itemNames;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemNames = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
}

 

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

 

 

jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
# show_sql: true
format_sql: true
default_batch_fetch_size: 100

 

application.yml에 default_batch_fetch_size: 100을 추가한다. (in 쿼리의 개수)

개별로 설정하려면 @BatchSize를 적용하면 된다. 컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용하면 된다.

 

  • 쿼리 호출 수가 1+N에서 1+1로 최적화 된다.
  • 조인보다 DB 데이터 전송략이 최적화 된다.
    • Order와 OrderItem을 조인하면 Order가 OrderItem만큼 중복해서 조회된다.
    • 이 방법은 각각 조회하므로 전송해야 할 중복 데이터가 없다.
  • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만 DB 데이터 전송량이 감소한다.
  • 컬렉션 패치 조인은 페이징이 불가능하지만 이 방법은 페이징이 가능하다.
  • ToOne 관계는 페치 조인을 해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치 조인으로 쿼리 수를 줄이고, 나머지는 hibernate.default_batch_fetch_size로 최적화 하면 된다.

 

참고: 
default_batch_fetch_size의 크기는 적당한 사이즈를 골라야 하는데 100 ~ 1000 사이를 선택하길 권장한다.
이 전략을 SQL IN 절을 사용하는데 데이터베이스에 따라 IN절 파라미터를 1000으로 제한하기도 한다.
1000으로 잡으면 한 번에 1000개를 DB에서 애플리케이션에 불러오므로 순간 DB에 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량은 같다.
1000으로 설정하는 것은 성능 상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.

 

참고: 스프링부트 3.1부터는 하이버네이트 6.2를 사용하는데 where in 대신에 array_contains를 사용한다.

 

where in 사용 문법은 where item.item_id in(?,?,?,?)

array_contains 사용 문법은 where array_contains(?, item.item_id)

 

where in에서 array_contains를 사용하도록 변경해도 결과는 완전 동일하지만 변경하는 이유는 성능 최적화 때문이다.

array_contains에서 default_batch_fetch_size에 맞추어 배열에 null값을 추가하는데 특정 데이터베이스에 따라 배열의 데이터 숫자가 같아야 최적화 되기 때문에 그런 것으로 추정된다고 한다.

 

 

 

 

 

 

 

 

 

 


🐣 출처: 인프런 김영한님 강의

 

이 글은 인프런의 김영한님 JPA 강의를 보고 작성한 글입니다.

강의를 들으면서 정리한 글이므로 틀린 내용이나 오타가 있을 수 있습니다.

 

저작자표시 비영리 변경금지 (새창열림)
  1. 컬렉션 조회 최적화(1)
'Back-End/JPA' 카테고리의 다른 글
  • [실전! 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] API 개발 고급(4): OSIV와 성능 최적화
  • [실전! 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] API 개발 고급(3): 컬렉션 조회 최적화(2)
  • [실전! 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] API 개발 고급(2): 지연 로딩과 조회 성능 최적화
  • [실전! 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] API 개발 고급(1): 조회용 샘플 데이터 입력
뚜비
뚜비
1년차 백엔드&iOS 개발자의 감자 탈출 블로그 🥔🥔
  • 뚜비
    뚜비의 개발로그
    뚜비
  • 전체
    오늘
    어제
  • 글쓰기     관리
    • Devlog
      • Back-End
        • Java
        • Spring
        • JPA
        • HTTP
        • Security
        • Back-End
        • Front-End
      • 알고리즘
      • iOS
        • Swift
      • Database
      • Tips
        • Git & GitHub
        • A to Z
      • 프로젝트
      • 생각정리
  • 태그

    성능최적화
    변수
    다형성
    MVC
    Spring Security
    HTTP
    JPA
    Database
    spring
    스프링
    Java
    알고리즘
    자바스크립트
    Security
    Swift
    생성자
    javascript
    DB
    자바
    김영한
    게시판만들기
    sql
    객체
    최주호
    데이터베이스
    의존성주입
    프로그래머스
    jsp
    html
    백준
  • hELLO· Designed By정상우.v4.10.0
뚜비
[실전! 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] API 개발 고급(3): 컬렉션 조회 최적화(1)
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.