“김영한 강사님의 JPA 활용편 2 - API 개발과 성능 최적화"를 듣고 간단하게 정리하기”
OneToOne, ManyToOne과 같은 경우는 Fetch Join을 해주는 것으로 성능을 최적화 하는 것이 가능하다.
ToMany(OneToMany)의 경우에는 Join을 하는 경우 N개의 컬럼으로 늘어나기 때문에 최적화에 어려움이 있다.
Ex. 주문과 주문상품 관계로 주문 조회를 최적화
주문과 배송, 회원(ToOne관계), 주문상품(ToMany)을 조회한다.
V1 : 엔티티를 직접 노출 (안좋은 방법)
- 주문상품과 주문한 상품의 상세 정보를 가져오기 위해서 Lazy 로딩이 설정되어 있는 프록시의 강제 초기화가 필요하다.
- 아래의 방법은 엔티티를 직접 노출하기 때문에 DTO로 변환해서 데이터를 화면에 전달해야한다.
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
// 지연로딩 데이터 가져오기
for (Order order : orders) {
order.getMember().getName(); // 지연로딩 초기화
order.getDelivery().getAddress(); // 지연로딩 초기화
List<OrderItem> orderItems = order.getOrderItems(); // 상품명을 가져오기 위해서 주문상품을 강제 초기화
orderItems.stream().forEach(orderItem -> orderItem.getItem().getName()); // 상품명을 가져오기 위해서 지연로딩 강제 초기화
}
return orders;
}
V2 : 엔티티를 DTO로 변환
- 엔티티를 DTO로 변환해서 화면에 전달해야한다.
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> collect = orders.stream()
.map(order -> new OrderDto(order))
.collect(Collectors.toList());
return collect;
}
@Getter
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItem> 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();
}
}
- 모든 데이터가 DTO에 매핑되면서 데이터를 화면에서 전달받을것이라 예상했지만 OrderItems 부분은 데이터가 Null인것을 확인할 수 있다.
- OrderItem은 엔티티이기 때문에 DTO에서 프록시를 강제초기화 해주지 않았기 때문에 데이터를 조회하지 못했다.
- 아래 코드처럼 프록시를 강제 초기화를 해야 데이터를 조회할 수 있다.
order.getOrderItems().stream().forEach(orderItem -> orderItem.getItem().getName());
- 하지만, DTO로 반환하더라도 DTO안에 엔티티가 존재하면 안된다. 즉 엔티티의 의존을 완전히 끊어야한다.엔티티 정보가 모두 노출되기 때문이다.
- OrderItem에 대한 별도의 DTO를 한 개 더 생성해주어야 한다.
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
...
// OrderItem 엔티티를 DTO로 랩핑하는 것이 아닌 별도의 DTO로 변환해서 응답한다.
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(Collectors.toList());
}
@Getter
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName(); // 지연 로딩 프록시 초기화
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
- 원하는 데이터만 전달하는 것이 가능하다.
- 또한 컬렉션으로 가져오는 엔티티도 클라이언트에 노출되지 않는다. (Value Obejct의 경우 예외)
- 하지만, 여러 개의 DB를 조회하기 때문에 지연로딩이 될 때마다 쿼리가 날아가게 되어 네트웍 퍼포먼스 이슈가 발생할 확률이 크다.
- 주문 조회 1번 → 회원 2명, 배송정보 2건, → 회원 1명당 주문 상품 2개 = 1 + 2 + 2 + 4 = 9번의 쿼리가 발생한다!!
V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화
- 많은 쿼리가 호출되는 것을 해결하기 위해 페치 조인을 이용한다.
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();
}
- 하지만, 주문이 2건이고 상품이 4개이기 때문에 조인을 하게되면 상품이 N이기 때문에 상품의 개수만큼 결과 row를 출력하게된다. (주문과 주문상품이 1:N 관계이기 때문이다.)
- 늘어난 데이터는 래퍼런스까지 동일한 중복 데이터이다
- 페치 조인을 사용할 때 의도는 주문은 2건 그대로 나오고 상품을 각각 출력하는 것이었다.
- 이를 해결하기 위해 distinct 키워드를 사용한다.
- JPA에서의 distinct는 DB의 distinct와 동일하게 작동하고, 추가적으로 엔티티가 중복인 ID값을 가진다면 애플리케이션에서 중복을 제거해준다.
- 또한, 페치 조인을 이용함으로써 기존 쿼리가 9번 나가던 것을 단 1번의 쿼리로 조회하는 것이 가능하다.
- 컬렉션 페치 조인을 이용해 최적화하는 경우의 가장 큰 단점은 페이징의 불가능이다.
- 1:N관계에서 페치 조인과 페이징 처리를 함께 사용하는 경우 쿼리의 결과를 모두 조회해온 후 메모리 상에서 페이징 처리를 해버린다. 만약 데이터가 무수히 많은 경우 OutOfMemory가 발생하면서 큰 장애가 발생할 수 있다.
- 1:N 페치 조인에서는 페이징을 사용하면 안된다.
- 컬렉션 페치 조인은 1개만 사용할 수 있다. 1:N에 대한 페치조인을 두 개 이상 사용하는 경우 1 → N → M개로 갑자기 Row가 증가되면서 데이터가 부정합하게 조회될 수도 있다.
V3.1 : 엔티티를 DTO로 변환 - 페이징과 한계 돌파 (권장)
- 컬렉션을 페치조인하면 페이징이 불가능하다.
- 1:N 조인이 발생하면 1을 기준으로 페이징을 처리하고 싶지만 N을 기준으로 데이터 row가 생성된다.
- Hibernate가 메모리로 DB를 읽어 페이징 처리를 하는데 장애로 이어질 수 있다. 따라서, 절대 사용하면 안된다.
- 대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이렇게 해결할 수 있다.
- ToOne 관계를 모두 페치 조인을한다. 1:1 관계이므로 조인을 하더라도 row의 증가가 발생하지 않는다.
- ToOne 관계는 쿼리 한번에 가져오는 것이 가능하다. 따라서, 페이징 처리도 가능하다.
- 예제에서 Order와 Delivery, Member는 1:1 관계이기 때문에 페치 조인을 통해 모두 가져온다.
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
// Fetch Join을 이용 -> Order를 가져올 때 객체 그래프를 탐색해서 Member와 Delivery를 한번에 가져온다.
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();
}
- OrderItem은 1:N관계이기 때문에 페이징 처리를 할 수 없다. 따라서 페치 조인을 함께 하지 않고 페치조인을 통해 가져온 결과에서 지연 로딩으로 조회한다. 즉, 컬렉션은 지연로딩한다.
- 컬렉션을 지연로딩해서 가져오면 N + 1문제가 발생한다. Order → OrderItem → Item =1 : N : M으로 쿼리가 발생한다.
- 이때 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size를 application.yml에 설정한다.
- hibernate.default_batch_fetch_size : 컬렉션이나 프록시 객체를 설정한 size만큼 in절에 담아 조회한다.
- hibernate.default_batch_fetch_size는 적당한 사이즈를 지정해야한다.
- MaxSize는 1000개로 제한된다. 대체로는 100 ~ 1000 사이를 지정하는것이 권장된다.
- 1000으로 지정하는 경우 순간적으로 DB부하가 올 수 있다.
- WAS나 DB가 얼마나 순간 부하를 받는지 확인하면서 그에 맞게 설정해간다.
- 쿼리가 Order 1번, OrderItem 1번, Item 1번씩 쿼리가 발생한다.
- 1:N:M 에서 1:1:1로 성능을 최적화 할 수 있다. 쿼리호출 수가 1+N → 1:1로 최적화된다.
- 전체 페치 조인을 한 경우와 다르게 쿼리가 조금 더 호출 되지만 페이징을 할 수 있다는 장점이 있고 조회한 데이터가 정규화 된 것처럼 중복이 없는 상태로 가져오는 것이 가능하다.
- 엔티티 별로 상세하게 batchSize를 지정하고 싶은 경우 @BatchSize(size = 사이즈)로 지정한다.
- toMany 관계의 경우 엔티티의 컬렉션 위에 지정해준다
@BatchSize(size=100)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
- toOne 관계인 경우 클래스 레벨에 적용한다.
@BatchSize(size=100)
@Entity
public class Delivery {
...
}
V4 : JPA에서 DTO를 직접 조회
- 핵심 비즈니스를 이용할 때 (엔티티)를 찾을 때와 특정 화면에서만 사용되는 경우를 구분한다.
- 화면이나 API에 의존있는 쿼리와 중요 핵심 비즈니스 로직에 대해 관심사를 분리할 수 있다.
@Data
public class OrderQueryDto {
private Long orderId;
private String name;
private LocalDate orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemQueryDto> orderItems;
public OrderQueryDto(Long orderId, String name, LocalDate orderDate, OrderStatus orderStatus, Address address, List<OrderItemQueryDto> orderItems) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.orderItems = orderItems;
}
}
// 1:1 관계인 엔티티를 한번에 조회해서 DTO에 넣어준다.
public List<OrderQueryDto> findOrderQueryDtos() {
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();
}
- 생성한 DTO를 new 키워드를 이용해 데이터를 넣어준다.
- OrderItems는 컬렉션 타입(N개)이기 때문에 JPQL의 new키워드를 이용해 데이터를 바로 넣는 것이 불가능하다. row가 증가되기 때문이다.
- 따라서 1:N 쪽에서 N 쪽을 또다른 DTO로 반환받는 JPQL을 작성한다.
@Data
public class OrderItemQueryDto {
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;
}
}
// OrderItems N의 관계 데이터를 한번 더 조회
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" + // 주문상품(OrderItem), 상품(Item)은 toOne 관계이므로 조인을 해도 데이터가 늘어나지 않는다.
" join oi.item i" +
" where oi.order.id = :orderId", OrderItemQueryDto.class)
.setParameter("orderId", orderId)
.getResultList();
}
- 1:1 관계의 데이터와 1:N관계의 데이터를 모두 DTO를 이용해 조회했다. 애초의 목적은 하나의 DTO에 모든 값을 담아 반환하는 것이기 때문에 조회한 1:N DTO 데이터를 기존 DTO에 넣어준다.
public List<OrderQueryDto> findOrderQueryDtos() {
List<OrderQueryDto> result = findOrders();
// 컬렉션 타임은 아직 못넣었기 때문에 한개씩 넣어준다.
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
- 루트 쿼리 (Order Id 를 가져오는 쿼리) 1번을 통해 2개의 OrderId를 가져온다 → 쿼리 1번 실행
- 가져온 orderId에 대해 OrderItem을 조회하는 쿼리가 Id당 1번씩 실행된다 → 쿼리 2번 실행
- 하지만, 루트 쿼리에 1번에 컬렉션 조회 쿼리 2번이 실행되었기 때문에 N+1 문제가 발생한다.
V5 : JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화 (DTO 변환 사용 시 권장)
- V4에서는 1:1 관계와 1:N 관계를 각각 조회하고 하나의 DTO에 데이터를 Set하는 방식으로 해결했다.
- 이는 N+1 문제를 발생시킨다. 따라서 최적화가 필요하다.
- 1:1 관계 데이터를 조회하는 것은 동일하다.
- 1:N 관계 데이터를 기존 Loop을 돌리던 것을 한 번만 호출하고 루트 쿼리에서 나온 데이터 2개를 (OrderId 2개, 식별자)를 리스트로 담아 파라미터로 넘긴다.
- 파라미터로 넘어온 OrderId 리스트를 IN절에 매핑해서 쿼리를 1번 호출하고 불러온 데이터를 Map을 이용해 메모리에 적재한다. Map을 이용하면 O(1)이기 때문에 매칭 성능을 향상시킬 수 있다.
- 이미 데이터가 메모리에 적재되었기 때문에 DTO에 데이터를 매핑할 때 OrderId를 Loop돌려 OrderItem 데이터를 넣어준다.
public List<OrderQueryDto> findAllByDtoOptimization() {
List<OrderQueryDto> result = findOrders();
// 조회한 주문ID를 모두 뽑아온다. (2개 조회)
List<Long> orderIds = result.stream().map(o -> o.getOrderId())
.collect(Collectors.toList());
// 루프를 돌지 않고 데이터를 한번에 가져온다. (쿼리를 1번 날림)
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" + // 주문상품(OrderItem), 상품(Item)은 toOne 관계이므로 조인을 해도 데이터가 늘어나지 않는다.
" join oi.item i" +
" where oi.order.id in :orderIds", OrderItemQueryDto.class)
.setParameter("orderIds", orderIds)
.getResultList();
// orderItems를 Map으로 변경해준다. (메모리에 세팅)
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
// 결과를 매핑
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
V6 : JPA에서 DTO로 직접 조회, 플랫 데이터 최적화
- 쿼리를 1번만 호출해서 데이터를 가져오기 위해 새로운 DTO로 받는다.
- 따라서, 조회하고자 하는 테이블을 한번에 조인해서 쿼리를 날린다.
@Data
public class OrderFlatDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
// OrderItem
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;
}
}
public List<OrderFlatDto> findAllByDtoFlat() {
return em.createQuery("select" +
" new jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
" from Order o" +
" join o.member m" +
" join o.delivery d" +
" join o.orderItems oi" +
" join oi.item i", OrderFlatDto.class)
.getResultList();
}
- N을 기준으로 데이터 row가 늘어난 상태로 추출되기 때문에 데이터에는 중복이 있다.
- API 스펙에 맞게 데이터를 화면에 리턴하기 위해서 추가적인 작업이 필요하다.
// 중복된 데이터를 제거해서 API 스펙에 맞게 데이터를 매핑한다.
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());
- 쿼리 1번에 데이터를 가져오는 것도 가능하지만, 조인이 많아지기 때문에 데이터의 중복이 추가되므로 상황에 따라 성능이 더 안좋을 수도 있다.
- 중복 데이터를 모두 가져오기 때문에 API 스펙에 맞게 애플리케이션에서 추가적인 작업을 진행해야한다. 당장 매핑하는 람다만 봐도 복잡해보인다!!
- N을 기준으로 페이징을 처리하는 것은 가능할 수 있지만, 1을 기준으로 페이징하는 것은 할 수가 없다. N을 기준으로 데이터가 추출되었기 때문이다.
정리
- 엔티티를 그대로 반환하는 것은 엔티티 스펙이 변해버리고 API 스펙도 함께 변해버리기 때문에 사용하면 안된다.
- 엔티티를 조회한 후 DTO로 변환해서 반환한다.
- 각각의 엔티티를 지연 로딩을 통해 조회하게 되면 많은 쿼리를 호출하게 되기 때문에 페치조인을 사용한다.
- 컬렉션 타입은 페치조인시 페이징이 불가능하다. 따라서, ToOne 관계는 페치 조인으로 쿼리 수를 최적화를 하고, 컬렉션은 페치 조인을 사용하지 않고 지연 로딩을 그대로 유지한다. 대신 hibernate.default_batch_fetch_size, @BatchSize를 이용해 페이징을 처리한다
- JPA에서 DTO를 직접 조회하는 경우 일대다 관계인 컬렉션은 IN절을 이용해서 메모리에 미리 적재하고 이를 매핑해서 최적화한다.
- 대부분은 페치 조인 선에서 성능 최적화가 이뤄진다. JPA가 직접 최적화를 해주기 때문에 코드를 단순하게 유지하는 것이 가능하다.
권장 개발 순서
- 엔티티를 조회한 후 DTO로 변환한다.
- 페치 조인을 이용해 쿼리 수를 최적화한다.
- 컬렉션은 hibernate.default_batch_fetch_size, @BatchSize를 최적화한다.
- 엔티티 조회 방식으로 성능 이슈가 있는 경우 DTO 조회 방식을 이용한다.
- DTO 조회 방식으로도 성능 이슈가 있는 경우 Native SQL, JdbcTemplate릉 이용한다.
반응형
'Backend > Spring Boot' 카테고리의 다른 글
Repository 단위 테스트 (0) | 2022.07.15 |
---|---|
[API 개발 고급] 지연 로딩과 조회 성능 최적화 (0) | 2022.07.07 |
API 개발 기본 간단 정리 (0) | 2022.07.07 |
도메인 개발 팁 간단 정리 (0) | 2022.07.05 |
RestTemplate (0) | 2022.07.01 |