이 글은 김영한 님의 인프런 강의 '실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화'를 수강하고 정리 한 글 입니다.
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의
스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., - 강의 소개 | 인프런
www.inflearn.com
1. API 개발 고급 - 컬렉션 조회 최적화
1-1. 주문 조회 V1: 엔티티 직접 노출
com.wwan13.api.OrderApiController
@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.stream().forEach(o -> o.getItem().getName()); //Lazy 강제 초기화
}
return all;
}
- orderItem, Item 관계를 직접 초기화 하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성한다.
- 양방향 연관관계면 무한루프에 걸리지 않게 한 곳에 @JsonIgnore를 추가해야 한다.
- 엔티티를 직접 노출하므로 좋은 방법은 아니다.
1-2 주문조회 V2 : 엔티티를 DTO로 변환
com.wwan13.api.OrderApiController
@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(toList());
return result;
}
@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(toList());
}
}
@Data
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();
}
}
- 지연로딩으로 너무 많은 SQL 실행
- SQL 실행 수
- order 1번 (order 조회 수 만큼)
- member, address N번 (order 조회 수 만큼)
- orderItem N번 (order 조회 수 만큼)
- item N번 (orderItem 조회 수 만큼)
1-3 주문조회 V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화
com.wwan13.api.OrderApiController
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
com.wwan13.repository.OrderRepository
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이 1번만 실행됨
- 페이징 불가능하다
distinct를 사용한 이유는 1대 다 조인이 있어 데이터베이스 row가 증가한다. 그 결과 같은 order 엔티티의 조회 수도 같이 증가하게 된다. JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다. 이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.
컬렉션 페치 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상에 페치 조인을 사용하면 데이터가 부정합하게 조회될 수 있다.
1-3-1 주문조회 V3.1 : 엔티티를 DTO로 변환 - 페이징과 한계 돌파
- 컬렉션을 페치 조인 하면 페이징이 불가능 하다.
- 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
- 일대다 에서 1을 기준으로 페이징을 하고 싶지만, 데이터는 N을 기분으로 row가 생성되다.
- Order를 기준으로 페이징 하고 싶지만, N인 OrderItem을 조인하면 OrderItem이 기준이 된다
해결방안
- 먼저 ToOne 관계를 모두 페치조인 한다. ToOne 관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
- 컬렉션은 지연로딩으로 해결한다.
- 지연로딩 성능 최적화를 위해 hibernate.default_batch_size, @BatchSize를 적용한다.
- hibernate.default_batch_size : 글로벌 설정
- @BatchSize : 개별 최적화 (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)
- 컬렉션이나 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
com.wwan13.repository.OrderRepository
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();
}
com.wwan13.api.OrderApiController
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "1000") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
- orderRepository.findAllWithMemberDelivery(offset, limit) : ToOne 관계 페치조인
- new OrderDto(o) : 생성자에서 메서드 호출로 프록시 초기화
com.wwan13.resource.Application.yml
spring: jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
장점
- 쿼리 호출 수가 1+N 에서 1+1로 최적화 된다.
- 조인보다 DB 데이터 전송량이 최적화 된다. (Order와 OrderItem을 조인하면 Order 가 OrderItem만큼 중복해서 조회되지만, 이 방법을 사용하면 각각 조회하므로 전송해야할 중복 데이터가 없다.
- 페치 조인 방식과 비교해서 쿼리 호출 수가 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
결론
ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치조인으로 쿼리 수를 줄이고 해결하고, 나머지는 hibernate.default_batch_fetch_size 로 최적화 하자.
1-4 주문조회 V4 : JPA에서 DTO 직접 조회
com.wwan13.api.OrderApiController
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueryDtos();
}
com.wwan13.repository.OrderQueryRepository
public List<OrderQueryDto> findOrderQueryDtos() {
//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();
//루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
public List<OrderQueryDto> findOrders() {
return em.createQuery("select new " +
"wwan13.jpashop.repository.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();
}
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery("select new " +
"wwan13.jpashop.repository.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();
}
- Query : 루트 1번, 컬렉션 N번 발생
- ToOne 관계들을 먼저 조회, ToMany 관계는 각각 별도록 처리
- ToOne관계는 조인해도 데이터 row 수가 변하지 않음
- ToMany 관계는 조인하면 row 수가 증가
- row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.
1-5 주문조회 V5: JPA 에서 DTO 직접 조회 - 컬렉션 조회 최적화
com.wwan13.api.OrderApiController
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findAllByDto_optimization();
}
com.wwan13.repository.OrderQueryRepository
public List<OrderQueryDto> findAllByDto_optimization() {
//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();
//orderItem 컬렉션을 MAP 한방에 조회
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
//루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
private List<Long> toOrderIds(List<OrderQueryDto> result) {
return result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
}
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long>
orderIds) {
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();
return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}
- Query: 루트 1번, 컬렉션 1번
- ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem 을 한꺼번에 조회
- MAP을 사용해서 매칭 성능 향상(O(1))
1-6 주문조회 V6 : JPA에서 DTO로 직접 조회, 플랫 데이터 최적화
-생략-
2. API 개발 고급 정리
권장 순서
- 엔티티조회방식으로우선접근
- 페치조인으로 쿼리 수를 최적화
- 컬렉션 최적화
- 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화
- 페이징 필요X 페치 조인 사용
- 엔티티 조회 방식으로 해결이안되면 DTO조회 방식 사용
- DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate
DTO 조회 방식의 선택지
- V4는 코드가 단순하다. 특정 주문 한 건만 조회하면 이 방식을 사용해도 성능이 잘 나온다.
- V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 대신 이를 최적화한 V5를 사용해야 한다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성능 차이가 날 수 있다.
- V6는 완전히 다른 접근방식이다. 쿼리 한번으로 최적화되어 상당히 좋지만 페이징이 불가능하고 데이터가 많으면 중복 전송이 증가해 V5과의 성능 차이도 미비하다.
3. API 개발 고급 - 실무 필수 최적화 OSIV
OSIV에 대한 내용은 아래 글에서 따로 다루었습니다.
[jpa] OSIV (Open Session In View)
OSIV (Open Session In View) OSIV (Open Session In View) 는 영속성 컨텍스트를 뷰까지 열어두는 기능이다. 영속성 컨텍스트가 유지되면 엔티티도 영속상태로 유지되며, 이로 인해 뷰에서도 지연 로딩을 사용
wwan13.tistory.com