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
- 자바의 정석 기초편 ch8
- 자바의 정석 기초편 ch5
- 자바의 정석 기초편 ch9
- 자바 기본편 - 다형성
- @Aspect
- 자바의 정석 기초편 ch3
- 코드로 시작하는 자바 첫걸음
- 스프링 db2 - 데이터 접근 기술
- jpa 활용2 - api 개발 고급
- 자바의 정석 기초편 ch14
- 스프링 mvc1 - 서블릿
- 스프링 입문(무료)
- 스프링 db1 - 스프링과 문제 해결
- 자바의 정석 기초편 ch4
- 게시글 목록 api
- 스프링 mvc2 - 타임리프
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch1
- 자바의 정석 기초편 ch6
- 스프링 mvc1 - 스프링 mvc
- jpa - 객체지향 쿼리 언어
- 자바의 정석 기초편 ch7
- 자바의 정석 기초편 ch13
- 스프링 mvc2 - 로그인 처리
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch12
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch2
- 2024 정보처리기사 수제비 실기
Archives
- Today
- Total
나구리의 개발공부기록
API 개발 고급 - 지연로딩과 조회 성능 최적화, 간단한 주문조회 V1 - 엔터티를 직접 노출, V2 - 엔터티를 DTO로반환, V3 - 페치 조인 최적화, V4 - JPA에서 DTO로 바로 조회 본문
인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화
API 개발 고급 - 지연로딩과 조회 성능 최적화, 간단한 주문조회 V1 - 엔터티를 직접 노출, V2 - 엔터티를 DTO로반환, V3 - 페치 조인 최적화, V4 - JPA에서 DTO로 바로 조회
소소한나구리 2024. 10. 23. 23:50출처 : 인프런 - 실전! 스프링부트와 JPA활용2 - API개발과 성능 최적화 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
** API 개발 고급 - 지연로딩과 조회 성능 최적화 목표
- 실무에서 JPA를 사용하려면 API 고급편의 내용을 100% 이해해야함, 그만큼 매우 중요함
- 주문 + 배송정보 + 회원을 조회하는 API를 생성하고 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결
1. 간단한 주문 조회 V1 - 엔터티를 직접 노출(사용하면 안되는 방식)
1) 엔터티를 직접 노출하는 컨트롤러
(1) OrderSimpleApiController - ordersV1()생성 및 실행 - 첫번째 문제 발생
- 매우 간단하게 리포지토리에서 전체 내역을 조회한 결과를 받은 List를 그대로 반환
- Postman으로 컨트롤러에 매핑한 url로 요청을 보내면 서버는 무한루프에 빠지고 Jackson라이브러리의 안전장치가 동작하여 중첩이 1000이 되었을때 예외를 발생시켜 차단하는 동작이 실행되는 안좋은 상황이 벌어짐
package jpabook.jpashop.api;
/**
* XToOne 관계의 성능 최적화(ManyToOne, OneToOne)
* Order 조회
* Order -> Member
* Order -> Delivery
*/
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
}
(2) 첫번째 문제 해결 - 두번째 문제 발생
- Order와 양방향 연관관계가 걸려있는 Entity들의 필드들을 모두 @JsonIgnore를 입력하여 Json으로 반환하지 못하도록 설정
- 이렇게 하면 양방향 중 하나만 처리하게 되기 때문에 무한루프가 발생하는 문제는 해결됨
- 그러나 다시 API 요청을 보내보면 이제 500 에러가 발생하며 Type definition error .... hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor .... 라는 메시지들을 남김
// Member
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
// OrderItem
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
// Delivery
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
(3) 두번째 문제 해결
- 문제의 원인은 연관관계 매핑시 fetch를 지연로딩으로 설정했기 때문에 DB에서 값을 가져오는 것은 Order의 정보만 가져오고 Member나 Delivery는 가져오지 않고 프록시로 동작하게 됨(프록시 동작방식은 JPA기본편 프록시 내용 참고)
- 즉, Order에서 멤버나 딜리버리를 쭉 가져올 때 프록시로 동작하게 되면서 실제 멤버나 딜리버리가 아니라 프록시객체인 ByteBuddyInterceptor가 대신 동작하고 있어서 문제가 발생한 것이므로 이러한 동작을 하지 않도록 설정해줘야 함
- build.gradle과 main메서드에 아래의 코드들을 입력하고 실행하면 위의 문제가 해결되고 정상적으로 API 응답 메시지가 뜸(연관관계의 필드의 값들은 null로 반환)
- 주석처리된 지연로딩 설정을 강제로 로딩하는 코드를 주석을 풀고 실행하게 되면 null로 되어있던 Member와 Delivery값도 모두 가져오게 됨
- 그러나 LAZY로 되어있던 모든 값들을 DB에서 조회하게 되므로 성능낭비가 매우 심하게 발생함
// build.gradle에 hibernate6 추가
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate6'
// 애플리케이션 main메서드에 아래의 설정을 @Bean으로 등록
@Bean
public Hibernate6Module hibernate6Module() {
Hibernate6Module hibernate6Module = new Hibernate6Module();
// 지연로딩으로 설정한 연관관계들을 강제로 로딩하는 설정
// hibernate6Module.configure(Hibernate6Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate6Module;
}
(4) ordersV1() 수정
- main 메서드에서 지연로딩을 강제로 호출하는 것이아니라 원하는 것만 선택해서 지연로딩 설정된 연관관계 필드들을 로드할 수 있음
- orderRepository에서 조회된 주문들을 반복문으로 꺼내서 원하는 필드만 선택한 뒤에 아무 값을 꺼내면 해당 필드의 값들이 강제로 초기화 되면서 특정 필드의 값만 가져오도록 설정할 수 있음
- API 요청을 보내보면 정상적으로 응답이 반환됨
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); // getName()이 실행될 때 해당 필드가 강제로 초기화 됨
order.getDelivery().getAddress(); // getAddress()가 실행될 때 해당 필드가 강제로 초기화 됨
}
return all;
}
2) 정리
- 그러나 API개발은 이렇게 복잡한 스펙으로 반환되도록 개발하지 않고 필요한 정보만 딱 정하여 API 스펙을 반환하도록 개발해야함
- 그렇기 때문에 계속 강조하듯이 엔터티를 API응답을 외부로 노출하지 말고 DTO로 원하는 스펙만 정의해서 반환해야함
- 지연 로딩을 피하기위해 즉시 로딩으로 연관관계 설정하여 개발하는 경우가 있는데 JPA강의에서 배웠듯이 데이터를 항상 조회하기 때문에 성능 튜닝하는 것이 매우 어려워지고 성능 문제(N+1)가 발생함
- 항상 무조건 DTO를 사용해서 API를 반환하고, 지연 로딩을 기본으로 한 뒤에 성능 최적화가 필요한 경우는 페치 조인(fetch join, V3에서 설명)을 사용하도록 해야함
2. 간단한 주문 조회 V2 - 엔터티를 DTO로 변환
1) DTO를 사용하는 컨트롤러
(1) ordersV2() 추가 및 실행
- 엔터티를 직접 반환하지 않고 API스펙을 정의한 DTO클래스를 생성하여 조회된 값을 DTO로 변환하여 반환
- 매핑된 url로 API 요청을 하면 정의된 스펙대로 응답이오며 Entity를 직접 반환했을때의 문제를 대부분 해결함
- stream을 이용해서 map()메서드를 호출하여 DTO로 변환하는 코드는 인자에 들어갈 람다식을 메서드참조로 변환하여 중간연산을 수행
- collect()로 스트림의 최종연산을 진행하고 인자의 값으로 Collectors클래스를 static import하여 toList()를 호출할 수 있도록하여 최대로 코드를 간략화 시켰음
public class OrderSimpleApiController {
// ... 기존 코드 생략
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
return orderRepository.findAllByString(new OrderSearch()) // 조회 후 stream으로 연산된 결과를 바로 반환
.stream() // 조회된 값을 스트림으로 변환하여 작업
.map(SimpleOrderDto::new) // lamda를 메서드 참조로
.collect(toList()); // Collectors static import
}
@Data
static class SimpleOrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
// 생성자로 Dto의 데이터를 바로 입력할 수 있도록 order를 파라미터로 받는 생성자 추가
public SimpleOrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}
}
(2) 지연로딩설정으로 인한 N+1 문제 발생
- 그러나 v1에서도 발생한 N+1 쿼리 문제가 동일하게 발생되며 응답내용을 정상적이지만 쿼리 로그를 보면 조회되는 데이터갯수만큼 select 쿼리가 발생된 것을 확인할 수 있음
- order 조회 1번, order에서 member 지연로딩 조회 N번, order에서 delivery 지연로딩 조회 N번이 발생하여 order의 결과가 4개일 때 최악의 경우 총 9번(1 + 4 + 4) 실행되어버림
- 물론 지연로딩은 영속성 컨텍스트에서 조회되기 때문에 이미 조회된 경우 쿼리를 생략한다고 해도 성능에 문제가 있는것은 맞기에 최적화를 해주어야 함
3. 간단한 주문 조회 V3 - V2를 페치 조인 최적화
1) JPQL의 페치조인을 사용하는 컨트롤러
(1) orderV3() 추가
- orderV3메서드의 구현은 기존의 orderV2와 동일하나 OrderRepository에서 최적화조회를 위한 메서드를 만들어서 주문리스트를 조회하도록 변경됨
public class OrderSimpleApiController {
// ... 기존 코드 생략
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
return orders.stream().map(SimpleOrderDto::new).collect(toList());
}
}
(2) OrderRepository에 페치조인 사용하여 조회하는 메서드를 추가
- JPQL을 활용하여 Order와 Order의 member와 delivery를 페치 조인(join fetch)를 활용하여 쿼리를 한방에 가져오도록 변경
- 기본값이 fetch join 적용 시 left join으로도 할 수 있으며 페치 조인의 자세한 내용은 JPA강의의 JPQL 문법강의를 참고
- 페치 조인으로 쿼리를 최적화하는 방식은 실무에서 매우 많이 사용하기 때문에 꼭 알고 있어야 함
public class OrderRepository {
// ... 기존 코드 생략
// 페치 조인으로 지연로딩으로 설정된 연관된 필드들을 쿼리 한번에 조회하도록 최적화 적용
public List<Order> findAllWithMemberDelivery() {
return em.createQuery("select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class).getResultList();
}
}
(3) API 응답시 발생한 SQL 쿼리 비교
- v2와 v3의 응답 결과는 동일하지만 발생된 쿼리는 7개와 1개로 어마어마한 성능 최적화가 이루어졌음
** 참고
- 하이버네이트 5에서는 @OneToOne 관계일 때 mappedBy쪽의 프록시 객체가 지연로딩이 제대로 동작하지 않아서 추가 쿼리가 발생되지 않아 5개만 출력되었으나 하이버네이트6에서 이런 부분이 개선되어 오히려 쿼리가 7개로 늘어나게 됨
4. 간단한 주문 조회 V4 - JPA에서 DTO로 바로 조회
1) Repository에서 DTO를 바로 조회하는 컨트롤러
(1) orderV4() 추가
- 컨트롤러에서 DTO로 변환하는 코드가 없이 Repository에서 DTO로 바로 조회하는 메서드를 호출하여 결과를 반환하는 컨트롤러
public class OrderSimpleApiController {
// ... 기존 코드 생략
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderRepository.findOrderDtos();
}
}
(2) OrderRepository 메서드 추가
- 페치 조인을 사용하지 않고 일반 join을 사용
- new 연산자를 사용하여 쿼리의 결과를 Entity가 아닌 별도로 정의한 DTO타입으로 반환하도록 JPQL 쿼리를 작성하여 Order Entity를 조회하는 것이아니라 DTO를 조회함
- new 연산자를 이용하여 select 절에 직접 조회한 대상을 지정할 수 있지만, 타입으로 지정된 DTO클래스의 경로를 패키지까지 모두 작성해줘야하는 번거로움이 있음
- 그러나 생각보다 성능최적화의 성능차이가 미비하며, 리포지토리 재사용성이 떨어지며 API 스펙에 맞춤 코드가 리포지토리에 들어가는 단점이 있음
public class OrderRepository {
// ... 기존 코드 생략
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery("select new jpabook.jpashop.repository.OrderSimpleQueryDto(" +
"o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class ).getResultList();
}
}
(3) OrderSimpleQueryDto생성
- 해당 DTO클래스를 사용할 repository 패키지에 DTO클래스를 정의
- 기존에 API Controller의 inner class로 정의한 SimpleOrderDto와 동일한 구조이지만 Controller의 DTO를 Repository의 반환값으로 사용하게 되면 Controller에서 Repository로 의존관계가 거꾸로 흐르게되어 아키텍처 구조가 깨지기 때문에 Repository패키지에 별도의 DTO를 생성해서 사용함
- JPQL의 new 문법에서 해당 DTO를 사용할 때 생성자의 값으로 Order를 입력하면 Entity가 입력되어 OrderSimpleQueryDto의 필드를 초기화하는 생성자는 Order가 아닌 각각의 필드를 파라미터로 주입받도록 설정
package jpabook.jpashop.repository;
@Data
public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
(4) 적용 후 결과와 v3, v4의 비교
- 위 코드를 적용하고 API를 호출 해보면 생성되는 쿼리에서 select 구문에 원하는 데이터만 정확히 조회되는 것을 확인할 수 있음
- 즉, v4는 일반적인 SQL을 사용할 때처럼 원하는 값을 직접 선택해서 가져오도록 쿼리를 작성한 것임
- join의 쿼리는 둘의 차이가 없지만 v3는 select절에서 DB의 데이터를 더 가져오기 때문에 네트워크를 조금 더 사용하게 되어 성능이 조금 느릴 수 있음
- v4에 비해 재사용성이 좋은 장점이 있으며, 대부분의 성능저하는 join이나 where문에서 조건이 이상해져서 인덱스를 잘못 잡거나 인덱스를 잘못 사용할 때 발생하지 select문에 몇개 더 추가된다고해서 애플리케이션 관점에서는 성능저하가 엄청 크게 난다고 보기는 어려움
- 만일 데이터사이즈의 크기가 매우 커서 select 필드가 막 수십개가 되고 API트래픽이 어마어마하게 들어오거나 고객의 요청이 실시간으로 계속 요청되는 등의 상황에서 단 몇초라도 성능 최적화가 필요한 상황이라면 성능차이가 좀더 날수는 있음
2) 구조 변경
- 지금 OrderRepository는 설계 구조상 Entity를 조회하는 곳에 사용해야 Entity의 객체 그래프를 조회하는 것을 그나마 최적화 할 수있는데, v4버전을 작성하면서 DTO가 repository패키지에 들어오고 DTO를 조회하는 코드와 Entity를 조회하는 코드가 섞여있음
- 이런 경우에는 repository 패키지의 하위 패키지에 추가적인 패키지를 만들어서 별도의 DTO로 조회하는 쿼리나 복잡한로직의 쿼리들을 뽑아서 관리하면 유지보수성이 용이해지기 때문에 실무에서는 화면에 종속되는 DTO는 Entity와 분리해서 관리하는 것이 좋음
- 물론 실무에서는 패키지구조를 order패키지의 하위에 Order관련된 클래스들이 들어있고 DTO로 조회하는 패키지도 해당 패키지의 하위에 생성하여 관리하도록 하겠지만, 지금은 이미 구조가 repository에 전부 모아놨기에 현재 상태에서 리펙토링을 실시
(1) OrderSimpleQueryRepository 생성
- repository에 order.simplequery패키지들을 추가하여 OrderSimpleQueryRepository클래스를 생성하고 OrderRepository에 있던 findOrderDtos()를 옮겨와서 다시 작성
- DTO클래스도 해당 패키지의 위치로 이동할 것이기 때문에 JPQL쿼리의 DTO클래스 경로를 수정해주어야 함
package jpabook.jpashop.repository.order.simplequery;
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery("select new jpabook.jpashop.repository.order.simplequery" +
".OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class ).getResultList();
}
}
(2) OrderSimpleQueryDto 위치 변경
- 새로 생성한 OrderSimpleQueryRepository가 위치한 ~.order.simplequery 패키지로 DTO클래스의 위치를 이동
package jpabook.jpashop.repository.order.simplequery;
@Data
public class OrderSimpleQueryDto {
// ... 기존 코드 동일
}
(3) OrderSimpleApiController 수정
- API 컨트롤러에서 OrderSimpleQueryRepository를 설정하고 findOrderDtos를 사용하도록 변경
package jpabook.jpashop.api;
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
private final OrderSimpleQueryRepository orderSimpleQueryRepository;
// ... 기존코드 생략
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
}
2) 정리
- v1은 절대 사용하지 말것
- 우선 Entity를 DTO로 변환하는 방법을 선택하고 필요할 때 페치 조인으로 성능을 최적화하면 대부분의 성능 이슈가 해결됨
- 그래도 안되는 문제는 DTO로 직접 조회하는 방법을 사용
- 최후의 방법으로는 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용하면 됨