Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 자바의 정석 기초편 ch13
- 게시글 목록 api
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch5
- 자바의 정석 기초편 ch14
- 자바의 정석 기초편 ch4
- 2024 정보처리기사 수제비 실기
- 코드로 시작하는 자바 첫걸음
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch11
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch3
- 자바의 정석 기초편 ch2
- 자바의 정석 기초편 ch12
- 스프링 db2 - 데이터 접근 기술
- 스프링 mvc1 - 서블릿
- 자바의 정석 기초편 ch1
- 스프링 입문(무료)
- @Aspect
- 자바의 정석 기초편 ch9
- 자바의 정석 기초편 ch6
- 스프링 mvc2 - 로그인 처리
- 스프링 db1 - 스프링과 문제 해결
- 스프링 mvc1 - 스프링 mvc
- 타임리프 - 기본기능
- jpa - 객체지향 쿼리 언어
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch7
- 자바의 정석 기초편 ch8
- jpa 활용2 - api 개발 고급
Archives
- Today
- Total
나구리의 개발공부기록
API 개발 고급 - 컬렉션 조회 최적화, 주문 조회V1 - 엔터티 직접 노출, V2 - 엔터티를 DTO로 변환, V3.0 - V2 페치 조인 최적화, V3.1 - 페이징과 한계 돌파 본문
인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화
API 개발 고급 - 컬렉션 조회 최적화, 주문 조회V1 - 엔터티 직접 노출, V2 - 엔터티를 DTO로 변환, V3.0 - V2 페치 조인 최적화, V3.1 - 페이징과 한계 돌파
소소한나구리 2024. 10. 24. 18:05출처 : 인프런 - 실전! 스프링부트와 JPA활용2 - API개발과 성능 최적화 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
** 컬렉션인 일대다 관계를 조회할 때 최적화는 방법 알아보기
1. 주문 조회 V1 - 엔터티 직접노출
1) 엔터티를 직접 노출하는 컨트롤러
(1) OrderApiController - ordersV1()
- 지연로딩 성능 최적화에서 다뤄봤던 것처럼 동일하게 DB에서 꺼낸 값을 반복문으로 지연로딩 설정된 필드를 강제로 초기화하여 DB에서 데이터를 가져오도록 설정
- 컨트롤러 등록 후 API를 호출하면 응답값은 정상적으로 반환되지만이렇게 엔터티를 직접 노출하는거는 N+1 성능문제, 확장문제, 보안문제 등등 그냥 문제 투성이기 때문에 절대 사용하지 말것
package jpabook.jpashop.api;
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
/**
* V1. 엔터티 직접노출 - 사용 금지
* - 엔터티가 변하면 API 스펙이 변함
* - 트랜잭션 안에서 지연 로딩이 필요함
* - 양방향 관계 문제 발생하여 @JsonIgnore처리와 Hibernate모듈 등록을 해주어야 함
*/
@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.forEach(orderItem -> orderItem.getItem().getName());
}
return all;
}
}
2. 주문 조회 V2 - 엔터티를 DTO로 변환
1) 엔터티를 DTO로 변환하여 반환하는 컨트롤러
(1) ordersV2(), OrderDto 추가
- ordersV2와 OrderDto클래스를 생성하여 Order Entity를 직접 반환하는 것이 아니라 OrderDto를 반환하도록 코드를 작성
- OrderDto의 값들을 생성자로 입력한 뒤 OrderItems 필드는 OrderItem 엔터티를 반환하기 때문에 생성자에서 강제로 OrderItems의 값을 조회하는 코드를 만들어 프록시를 초기화 시켜야 API 호출 시 값들이 반환될 수 있음
- 그러나 여기엔 함정이 존재하는데, List<OrderItem> orderItems 가 바로 그 함정의 주인공 필드임
(2) Entity를 DTO로 변환하라는 것은 컨트롤러에서 DTO로 한번 감싸서 보내라는 뜻이 아님
- API결과를 보면 OrderItem의 엔터티의 스펙이 전부 외부에 노출되어 버렸기 때문에 v1버전과 다를바가없는 반쪽짜리 DTO를 사용하고 있는 것임
- 여기서 OrderItem의 Entity가변경이되면 마찬가지로 API스펙이 달라져 버리게 됨
- 결국 Entity를 DTO로 변환하라는 것은 단순하게 DTO를 감싸서 반환하라는 뜻이 아니라 DTO클래스에서 Entity와의 의존을 완전히 끊어내는 것을 말하기 때문에 OrderItem도 DTO클래스를 만들어서 반환하도록 수정해야함
** 참고
- Java16버전 부터는 collect()로 toList()를 호출하지 않아도 바로 toList()로 최종연산할 수 있음
- @Data는 getter, setter는 물론 RequiredArgsConstructor, toString, EqualsAndHashCode를 모두 만들어주기 때문에 상황에 따라서는 필요한 것만 적용하는 것이 더 나을 수 있으며, DTO의 경우에는 @Data를 적용하는 경우도 많으니 선택해서 사용
public class OrderApiController {
// ... 기존코드 생략
/**
* V2. 엔터티를 조회 후 DTO로 변환, fetch join 미적용
* - 트랜잭션 안에서 지연 로딩이 필요함
*/
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
return orders.stream().map(OrderDto::new).toList(); // Java16부터는 collect 대신 toList()로 사용가능
}
@Data
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();
order.getOrderItems().stream().forEach(o -> o.getItem().getName()); // 강제 프록시 초기화
orderItems = order.getOrderItems();
}
}
}
(3) OrderItemDto 추가 및 OrderDto 수정
- OrderDto에서OrderItem으로 반환하던 orderItems를 OrderItemDto를 만들어서 원하는 API스펙의 데이터만 반환하도록 변경하고, OrderDto생성자에서 OrderItem을 OrderItemDto로 변환하는 작업을거쳐서 orderItems에 저장
- OrderItemDto는 요구사항에서 정의된 API스펙의 필드를 입력 후 생성자로 값을 저장하도록 하면되며, 여기에서는 상품명과 주문가격, 주문수량을 반환한다고 가정하고 작성
public class OrderApiController {
// ... 기존코드 생략
@Data
static class OrderDto {
// ... 기존코드 생략
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
// ... 기존코드 생략
// order.getOrderItems().stream().forEach(o -> o.getItem().getName()); // 제거
// OrderItem -> OrderItemDto로 변환하여 저장
orderItems = order.getOrderItems().stream().map(o -> new OrderItemDto(o)).toList();
}
}
@Data
static class OrderItemDto {
// 클라이언트에서 OrderItemDto의 모든 정보가 필요한 것이 아닌 상품명, 주문가격, 주문수량 정보만 필요하다고 가정
private String itemName; // 상품명
private int orderPrice; // 주문가격
private int count; // 주문수량
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
}
(4) 적용 결과
- 이제 응답결과를 보면 OrderItem의 필드를 직접 반환하지 않고 orderItems의 필드의 값을 보면 API스펙에서 요구한 값만 정확하게 반환되는 것을 확인할 수 있으며, 향후에 API의 요구 스펙이 변경되어도 쉽게 반영하여 반환할 수 있음
- 즉, Address처럼 임베디드 값타입은 Entity가 아니기 때문에 직접 반환해도 되지만 Entity는 어떠한 경우에도 직접 반환하지 않도록 개발해야함
- 해당 v2버전도 페치조인을 적용하지 않았기때문에 N+1 문제가 발생하는데 컬렉션을 사용하면 N+1쿼리가 더많이 발생하기 때문에 최적화를 꼭 적용해야함
3. 주문 조회 V3.0 - V2를 페치 조인 최적화
** 참고
- 강의시점에는 hibernate버전이 5.x였으므로 컬렉션 조회시 페치조인을 하면 SQL의 join결과에 따라 JPA가 각각의 엔터티를 매핑하게 되어 중복값이 발생하여 데이터가 증가되었지만 hibernate6 이상 버전에서는 컬렉션 조회시 자동으로 중복을 제거하기 때문에 문제가 없음
- https://nagul2.tistory.com/339 해당강의내용 참고
- 그래서 중복값이 발생하여 확인하는 강의의 내용은 생략
1) 페치 조인을 적용하여 최적화를 적용
(1) orderV3() 추가
package jpabook.jpashop.api;
@RestController
@RequiredArgsConstructor
public class OrderApiController {
// ... 기존 코드 생략
/**
* V3. 엔터티를 조회 후 DTO로 변환, fetch join 적용
*
*/
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
return orders.stream().map(OrderDto::new).toList();
}
}
(2) OrderRepository - findAllwithItem() 추가
- JPQL쿼리를 페치 조인을 사용하여 작성
package jpabook.jpashop.repository;
public class OrderRepository {
// ... 기존코드 생략
// 컬렉션 조회를 페치 조인으로 최적화 적용
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", Order.class).getResultList();
}
}
(3) API 호출결과와 쿼리 비교, 그리고 문제점
- 페치조인을 적용한 컨트롤러로 API를 호출하면 v2의 응답결과와 동일한 결과를 반환하지만 발생된 SQL쿼리를 보면 무수히 많이 발생되었던 SQL쿼리들이 한 방 쿼리로 튜닝되면서 N+1문제가 깔끔하게 해결됨
- 그러나, 컬렉션 관계에서 페치조인을 사용한뒤 JPA의 페이징관련 기능을 사용하면 안되는 문제가 있음
- 만약 쿼리에 페이징기능을 사용한뒤 로그를 확인해보면 발생한 SQL에 페이징 쿼리가 발생되는게아니라 모든데이터를 가져온뒤에 메모리에서 페이징을 해버리면서 하이버네이트가 경고문구를 띄움(해당내용도 JPQL 강의에서 자세히 다루고있으며 절대 사용하면 안됨)
- 그리고 컬렉션 페치 조인은 1개만 사용해야 안전하며 둘이상을 사용하게되면 데이터가 부정확하게 조회될 수 있기 때문에 주의가 필요한데, 하이버네이트 6.2 버전에서 사용이 가능하도록 변경되었지만 여전히 성능 문제와 메모리사용량이 늘어날 수 있기 때문에 사용하지 않는것을 권장함
4. 주문 조회 V3.1 - 페이징과 한계 돌파
1) 한계 돌파
(1) 문제점 재정리
- 컬렉션을 페치 조인한뒤 페이징기능을 사용하면 절대 안됨, 즉 불가능하다라고 이해해야함
- 일대다 조인이 발생하여 데이터가 예측할 수 없이 증가하고 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적인데 데이터가 다(N)을 기준으로 row가 생성되기 때문에 제대로된 페이징이 되지않음
- 하이버네이트가 경고로그를 남기며 모든 DB데이터를 읽어서 메모리에서 페이징을 시도하기때문에 최악의 경우 장애가 날 수 있음
(2) 가장 단순하고 성능최적화도 보장하는 해결방법, 1+1쿼리
- 대부분의 페이징 + 컬렉션 엔터티 조회 문제는 이 방법으로 문제를 해결함
- 먼저 ToOne관계의 Entity들은 v2에서 적용한것처럼 페치조인한 뒤 컬렉션은 지연로딩으로 조회하고 그 이후에 성능 최적화를 @BatchSize 애노테이션이나 글로벌설정으로 BatchSize를 적용
- 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회하는 최적화 기능하며 @BatchSize로 개별필드에 적용하거나 설정파일에 hibernate.default_batch_fetch_size를 적용하여 글로벌로 적용할 수 있음
(3) ordersV3_page 추가
- @RequestParam으로 페이징값들을 받는 컨트롤러를 생성
- 받아온 파라미터들을 OrderRepository의 메서드의 인수로 입력하여 페이징 정보를 전달한 뒤 연산결과를 반환
package jpabook.jpashop.api;
@RestController
@RequiredArgsConstructor
public class OrderApiController {
// ... 기존 코드 생략
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
// ToOne관계의 Entity들은 fetch join 적용한 쿼리로 데이터를 조회, 페이징을 적용해도 문제없음
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
return orders.stream().map(OrderDto::new).toList();
}
}
(4) 파라미터로 페이징 정보를 받는 메서드를 추가
- 파라미터로 넘겨받은 값들을 페치 조인을 적용한 쿼리에 페이징정보로 활용하여 DB에서 값을 조회
package jpabook.jpashop.repository;
@Repository
@RequiredArgsConstructor
public class OrderRepository {
// ... 기존 코드 생략
// 파라미터로 offset, limit정보를 입력받아 페이징을 적용하는 메서드를 생성(메서드 오버로딩을 적용)
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();
}
}
(5) 설정파일에 최적화 옵션 적용 - application.yml 수정
- 설정파일에 default_batch_fetch_size: 를 적용하면 글로벌하게 배치사이즈를 적용하며 적용한 개수만큼 IN쿼리를 날려서 DB의 값을 한번에 가져옴
- 즉, DB의 데이터가 1000개이고 size를 100으로 설정했다면 10번의 IN쿼리가 발생되는메커니즘임
- 이렇게 글로벌하게 적용해도되고 개별적으로 필드에 @BatchSize를 적용할 수도 있음
** 참고
- BatchSize는 적당한 사이즈를 입력해야하는데 보통 100 ~ 1000 사이의 값을 선택하는 것을 권장함
- SQL의 IN절을 사용하여 동작하는데 데이터베이스에따라 다르지만 보통 IN절 파라미터를 1000을 기본값으로 제한하기 때문
- 그러나 1000으로 잡으면 한번에 1000개의 데이터를 DB에서 애플리케이션에 전송하기 때문에 DB에 순간적인 부하가 걸릴 수 있음
- 애플리케이션은 100이든 1000이든 어쨋든 전체 데이터를 로딩해야하기때문에 메모리사용량은 같음
- 1000으로 설정하는 것이 가장 성능상에는 가장 좋지만 DB나 애플리케이션에서 순간적인 부하를 얼마나 견딜 수 있는지 파악 후 적절하게 조절하면 됨
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100 #BatchSize 설정 추가
(6) 결과
- API의 요청시 파라미터로 페이징 정보를 입력해보면 최초 페치조인을 적용하여 Order,Member,Deliver를 조회하는 쿼리 1번, OrderItem을 조회하는쿼리1번, Item을 조회하는 쿼리1번으로 엄청난 최적화가 진행됨
- N+M+1의 무수히 많은 쿼리가 발생되는 것을 1+1+1으로 최적화가 되었으며 이정도만으로도 대부분의 성능문제는 해결이되며 컬렉션 조회에서 페이징까지 적용하게 되었음
- 물론 v3버전처럼 한번에 모두 적용하는 것보다 쿼리가 추가로 발생하긴하지만 이정도의 쿼리는 성능에 큰 문제가 없음
- 페이징을 적용하려면 해당 방식밖에 없으므로 페이징을 사용하지 않으면 전체 연관된 필드들을 페치 조인을 적용하고, 페이징을 사용하면 ToOne의 관계인 엔터티들만 페치 조인을 적용하고, 컬렉션으로 가져오는 필드는 BatchSize로 최적화를 하는 방식을 사용하면 됨
** 참고
- 하이버네이트 6.2부터는 동작하는 IN쿼리가 where in이 아닌 array_contains를 사용하여 동작하도록 쿼리가 변경되어 쿼리의 로그를 보면 IN쿼리의 파라미터로 ?가 배치사이즈로 적용된 수만큼 날라가는 것 확인할 수 있음
- 기존의 where in 방식으로 동작하는 것보다 성능이 더 좋게 동작하도록 최적화가 된 것이며, 적용한 size의 개수만큼 보이는 ?에는 실제 쿼리에 들어갈 값만 저장이되고 나머지는 null이 적용되어 실제 전송되는 쿼리는 조회할 데이터가 있는 값만 전송이되는 것이기 때문에 ?가 많이 보인다고 걱정할 필요는 없음
- 디테일하게 들어가면 SQL 구문 캐싱에 대한 내용인데 디테일한 내용이 교안에는 나와있으나 글에서는 생략함