Spring/[인프런 김영한 실전 스프링 부트와 JPA 활용 2
[인프런 김영한 실전 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] API 개발 고급 - 컬렉션 최적화
h2boom
2024. 9. 7. 21:19
컬렉션 조회 최적화
@Data
private class OrderDto { //주문 DTO
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItem> orderItems;
}
- DTO 설계 시 DTO 내부에도 엔티티가 존재하면 안된다.
- DTO 내부에 존재하는 엔티티 조차도 모두 필요한 데이터만을 가지고 DTO 형태로 만들어야 한다.
- 일대다 관계에서 페치 조인해서 조회하는 경우
- ex) Member(N) - Team(1) 연관관계, Team1(Member1, Member2), Team2(Member3, Member4)가 있을 때
Team과 Member를 조인해서 Team을 조회하는 경우에 원래대로라면 Team1, Team2가 조회되는 것이 정상
하지만 조인을 통해서 1:N관계에서 N기준에 맞춰서 Member1에 대한 Team1, Member2에 대한 Team1로 Team1이 2개로 데이터가 늘어나고 마찬가지로 Team2도 2개로 늘어나게되며 DB에서 데이터 뻥튀기 현상이 발생한다. - JPA에서는 DB에서의 데이터 뻥튀기를 임의로 해결할 수 없기 때문에 JPA에서 distinct 옵션을 줘야 한다.
- DB의 모든 데이터를 가지고 애플리케이션에서 한번 더 필터링하는 것으로 DB의 모든 데이터를 애플리케이션으로 가져와야 한다는 단점이 있다.
- 1:N관계에서는 N:1과 달리 페치 조인을 하는 경우 페이징이 불가능하다.
- DB 입장에서는 데이터가 뻥튀기 된 상태에서 페이징을 할 수가 없기 때문에
- 이 경우 모든 데이터를 DB에서 읽어오고 메모리에서 페이징을 한다 (매우 위험하므로 사용하지 말 것)
- ex) Member(N) - Team(1) 연관관계, Team1(Member1, Member2), Team2(Member3, Member4)가 있을 때
- DB distinct vs JPA distinct
- DB의 distinct - 모든 데이터가 다 같아야 중복으로 인식하고 제거한다.
- JPA의 distinct - DB 쿼리에 distinct 추가 + 애플리케이션에서 엔티티의 식별자 값이 같으면 중복으로 인식하고 제거한다.
- 컬렉션(1:N) 페치 조인은 1개만 사용할 수 있다.
- 컬렉션(1:N) 둘 이상에 페치 조인을 사용하면 데이터 뻥튀기가 엄청 심해지며 데이터가 부정합하게 조회될 수 있다.
- 1:N 관계에서 페이징과 컬렉션 엔티티를 함께 조회하는 방법은?
- 먼저 XToOne 관계 (OneToOne, ManyToOne)는 모두 페치 조인한다.
- XToOne 관계는 데이터 뻥튀기가 일어나지 않기에 페이징 쿼리에 영향을 주지 않는다.
- XToMany 관계가 데이터 뻥튀기가 일어나는 관계
- 컬렉션은 지연 로딩으로 조회한다.
= 컬렉션은 페치 조인을 사용하지 않는다. - 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.
- hibernate.default_batch_fetch_size : 글로벌 설정
- application.yml 파일에 설정
- @BatchSize : 개별 최적화
- 컬렉션인 경우 컬렉션 필드에 사용 / 엔티티의 경우 클래스에 사용
- 이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.
- hibernate.default_batch_fetch_size : 글로벌 설정
- 먼저 XToOne 관계 (OneToOne, ManyToOne)는 모두 페치 조인한다.
- 일반적인 지연 로딩과 BatchSize 옵션 부여 후 조회 쿼리 차이
- ex) Order - OrderItem - Item 연관관계(1:N:1)에
Order 1 - OrderItem 1 - Item 1,2
Order 2 - OrderItem 2 - Item 3,4 일 때 - 일반적인 지연 로딩을 사용했을 때 조회 쿼리
- Order 조회하는 쿼리 1개 + OrderItem 1,2번을 각각 조회하는 쿼리 2개 (N+1문제) + Item 1,2,3,4번을 각각 조회하는 쿼리 4개 (N+1문제)로 총 7번의 쿼리가 수행된다.
- BatchSize 옵션을 사용했을 때 조회 쿼리 (BatchSize를 충분히 크게 지정했을 때)
- Order 조회하는 쿼리 1개 + OrderItem 1,2번을 한번에 IN 쿼리로 조회하는 쿼리 1개 + Item 1,2,3,4번을 IN 쿼리로 한번에 조회하는 쿼리 1개로 총 3번의 쿼리가 수행된다.
- ex) Order - OrderItem - Item 연관관계(1:N:1)에
- BatchSize 옵션을 사용하면 1+M+N...의 문제를 1+1+1...로 엄청난 성능 최적화를 한다.
- 컬렉션에서 BatchSize 옵션을 사용하는 방식은 fetch join을 사용하는 방식보다 조회 쿼리 수는 약간 많을 수 있지만 각각을 조회하므로 데이터 중복이 없는 더 정규화된 데이터를 조회할 수 있다.
- BatchSize 옵션 사용 시 조회 쿼리 수가 약간 증가하지만 DB 데이터 전송량이 감소하며 페이징이 가능하다.
- SQL IN 연산자 : 여러 데이터 값을 조회할 때 사용한다.
- = 연산자는 조건으로 한 가지만 지정할 수 있지만 IN 연산자는 여러 조건 (목록)으로 지정할 수 있다.
- BatchSize의 크기는 100 ~ 1000 사이를 선택하는 것을 권장한다.
- 100이나 1000이나 전체 데이터를 로딩해야하므로 메모리 사용량은 같지만 순간 부하를 어디까지 견딜 수 있는지 에따라서 결정하는 것이 좋다.
- 페치 조인 결론 정리
- XToOne 관계는 페치 조인을 해도 페이징에 영향을 주지 않기에 페치 조인으로 쿼리 수를 줄이자
- XToMany 관계는 BatchSize로 최적화 하자
- 의존 관계는 한 방향으로만 의존관계가 형성되어야 한다.
- ex) Controller -> Repository 의존관계가 형성되어야 하는데 Repository도 Controller를 의존하면 안된다.
// JPQL로 DTO를 직접 조회하는 Repository V4 예제
public List<OrderQueryDto> findOrderQueryDtos() {
List<OrderQueryDto> result = findOrders();
result.forEach(o -> {
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();
}
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();
}
- JPQL을 작성해서 DTO를 직접 조회할 때 new 연산자 안에는 컬렉션이 들어갈 수 없기에 컬렉션을 조회하기 위해 따로 JPQL을 작성해서 컬렉션을 반환해야 한다.
- XToOne(N:1, 1:1) 관계를 먼저 조회하고 XToMany(1:N, M:N) 관계는 각각 별도로 처리한다.
- XToOne 관계는 조인해도 데이터 수가 증가하지 않기에 최적화하기 쉽기에 한번에 조회 (쿼리 1번 => N개)
- XToMany 관계는 조인하면 데이터 수가 증가하기에 최적화하기 어렵기에 findOrderItems() 같은 별도의 메소드로 조회한다. (쿼리 N번으로 N+1 문제 발생)
//DTO 직접 조회 - 컬렉션 조회 최적화 V5 예제
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders();
List<Long> orderIds = result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
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(o -> o.getOrderId()));
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
- JPQL을 사용해서 IN 쿼리를 통해 한번에 조건에 맞는 모든 데이터를 조회를 해서 가져오는 방식이다.
- Collectors.groupingBy(매개변수) : 매개변수를 Key 값으로 갖는 Map으로 만든다.
- 이전 예제인 JPQL을 사용해서 DTO를 직접 조회하는 예제(V4)와 V5의 차이점
- 이전 예제(V4)는 반복문을 통해 OrderItem을 조회할 때마다 쿼리를 수행해서 조회 쿼리를 여러 번 수행
- 현 예제(V5)는 OrderItem을 IN 쿼리 한 번에 모두 조회한다. (쿼리 총 2번)
- Map으로 만들지 않고 반복문을 통해 수행해도 된다.
// DTO 직접 조회, 플랫 데이터 최적화 V6 예제
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,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();
}
@Data
public class OrderFlatDto {
//OrderQueryDto
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
//OrderItemQueryDto
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;
}
}
- JPQL 작성 시 일반적인 SQL과 유사하게 동작하기에 컬렉션이 포함될 수 없다.
이 문제를 보완하기 위해서 조인할 데이터들을 모두 한 DTO 안에 포함시켜서 조회하는 방식(V6)- 총 1번의 조회 쿼리를 수행함으로 조회할 수 있지만 중복 데이터가 추가되고 애플리케이션에서 추가 작업이 크며 페이징이 불가능하다.
=> Order(1)를 기준으로 조회했지만 OrderItem(N)을 기준으로 데이터가 뻥튀기 되었기에 중복 데이터가 추가되었고 페이징이 불가하다.
- 총 1번의 조회 쿼리를 수행함으로 조회할 수 있지만 중복 데이터가 추가되고 애플리케이션에서 추가 작업이 크며 페이징이 불가능하다.
- 권장 순서
- 엔티티 조회 방식으로 우선 접근
- 페치 조인을 통해 쿼리 수를 최적화
- 컬렉션 최적화
- 페이징 필요 => BatchSize 옵션 사용
- 페이징 필요 x => 페치 조인 사용
- 엔티티 조회 방식으로 해결이 되지 않으면 DTO 직접 조회 방식 사용
- DTO 직접 조회 방식으로 해결이 안되면 네이티브 SQL, 스프링 JdbcTemplate 사용
- 엔티티 조회 방식으로 우선 접근
- 엔티티 조회 방식은 코드를 거의 수정하지 않고 페치 조인이나 BatchSize 옵션을 사용함으로 성능 최적화를 할 수 있다.
- 캐시하는 경우에는 엔티티말고 DTO로 해야하는 경우를 제외하고는 주로 엔티티 조회 방식이 좋다.
- DTO 직접 조회의 경우 성능 최적화를 하려면 많은 코드 수정이 필요하다.
- DTO 조회 방식을 선택하는 경우
- V4 예제는 코드가 단순하고 유지보수가 쉽다. 특정 데이터 한건만 조회하는 경우 사용해도 성능이 잘 나온다.
- 1+N 문제가 발생하기에 데이터가 많아질수록 성능에 좋지 않다.
- V5 예제는 코드가 복잡하고 여러 데이터를 한 번에 조회하는 경우 사용하기에 적합한 방식이다.
- 데이터 개수와 상관없이 조회 쿼리는 총 1+1번 실행된다.
- V5 예제는 DTO 조회 방식을 선택하는 경우 주로 사용하는 방법
- V6 예제는 쿼리는 총 한 번으로 최적화 됐지만 페이징이 불가하고 V5에 비해서 성능 차이가 미비하다.
- 페이징 사용이 많은 실무에서 사용하기엔 어려운 방법이다.
- V4 예제는 코드가 단순하고 유지보수가 쉽다. 특정 데이터 한건만 조회하는 경우 사용해도 성능이 잘 나온다.
출처: [인프런 김영한 실전 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화]
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 강의 | 김영한 - 인프런
김영한 | 스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 스프링 부트, 실무에서 잘 쓰고 싶다면? 복잡한 문제까지 해결하는 힘을 길
www.inflearn.com