1. 지연로딩 조회 성능 최적화
-지연로딩으로 발생하는 성능 문제를 해결
양방향관계에서 한군데에 @jsonignore를 해줘야 무한루프가 안걸린다.
/*
lazy로딩 설정시 데이터 요청하면 연관엔티티는 프록시객체가 반환되는데 json binder가 프록시를 인식을 못하기 떄문에 예외가 터진다.
혹시나 아래와 1같은 에러를 만난다면
(bytebuddy는 hibernate가 proxy객체를 만들때 사용하는 라이브러리이다)
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed;
nested exception is org.springframework.http.converter.HttpMessageConversionException:
Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor];
nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and
no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)
(through reference chain: java.util.ArrayList[0]->com.toyproject.kithub.domain.Order["member"]->com.toyproject.kithub.domain.Member$HibernateProxy$qb3Bp0F8["hibernateLazyInitializer"])]
with root cause
해결방법은 아래와 같다
1. HibernateModule을 리턴해주는 메서드를 bean으로 등록한다.
HibernateModule을 활용하여 JPA 옵션을 설정해 줄 수 있는데 lazy로딩시에 default로 json binding을 무시 시켜주기 떄문에 에러는 잡을 수 있으나 , 데이터는 null로 나간다 .
2. 등록만 시켜 놓고 controller에서 연관 엔티티에 접근하여 강제로 로딩시킨다
*/
사실 /**/ 안에 있는 방법들은 결과적으로는 다 쓸데 없고 그냥 엔티티를 반환하지 않고 Dto를 만들어 반환하면 위와 같은 문제는 생기지 않는다. 그냥 그런 방법도 있구나 하면 될 것 같다.
지연로딩 조회 성능 최적화의 3가지 방법
- 방법은 3가지 이지만 최적화의 정도로 봤을 때 3>2>1 의 순이라 생각하면 된다 .
1. 엔티티를 DTO로 변환하여 사용
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2(){
//3개의 테이블을 조인하고 있다.
return orderRepository.findAll().stream()
.map(order -> new SimpleOrderDto(order))
.collect(Collectors.toList());
}
@Data
@AllArgsConstructor
static class SimpleOrderDto{
private Long id;
private String name;
private LocalDateTime orderDateTime;
private Status status;
private Address address;
public SimpleOrderDto(Order order) {
this.id = order.getId();
this.name = order.getMember().getName();
this.orderDateTime = order.getOrderDate();
this.status = order.getStatus();
this.address = order.getDelivery().getAddress();
}
}
ordersV2 의 경우는 order 를 findAll 해서 조회된 리스트르 stream 을 통해 하나씩 꺼내 SimpleOrderDto로 변환해 주고 있다 .
위의 경우 필요한 데이터만 딱 집어서 Dto에 넣어주기 떄문에 엔티티를 반환할 경우 생기는 문제들이 일어나지 않는다 .
단점은 n+1 문제가 일어난다 Order의 연관관계 테이블(Member, Delivery)의 fetch type 이 Lazy로 되어있기 때문에
simpleOrderDto의 생성자에서 getMember.getName() 를 통해 getDeliver.getAddress()가 호출될 때 JPA는 영속성 컨텍스트에 해당 데이터가 존재하는지 확인하고 없다면 DB에 쿼리를 날릴 것이다 .(연관테이블 필드에 접근할때 발생)
주문이 2개일 경우 쿼리는 아래와 같다
오더를 조회하는 쿼리 한번
2개의 오더에 멤버에 관한 쿼리가 한번씩(2번)
2개의 오더에 딜리버리에 관한 쿼리가 한번씩(2번)
총 5번의 쿼리가 날라간다 (상황에 따라 멤버나 딜리버리가 같다면 영속성컨텍스트에 남아 있기 떄문에 쿼리가 더 적을 수도 있다.)
위의 문제를 N+1문제라 한다. 1은 조회하려는 쿼리이고 , N은 연관관계에 있는 테이블 수를 말한다.
오더 1개면 오더 1 멤버1 딜리버리1
오더가 2개면 오더1 멤버 2 딜리버리 2 이런식으로 쿼리가 계속 늘어나는 것이다 .
2.페치 조인사용
위의 문제를 해결하기 위해 JQPL의 페치 조인을 사용한다.
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3(){
//페치 조인으로 가져온다.
return orderRepository.findAllWithMemberDelivery()
.stream()
.map(order ->new SimpleOrderDto(order))
.collect(Collectors.toList());
}
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();
}
findAllWithMemberDelivery의 경우 fetch 조인을 사용하고 있다 .
fetch 조인의 경우 Eagar 처럼 작동하지만 SQL에서 적용되기 떄문에 필요할 때에 필요한 부분만 Eagar처럼 가져올 수 있게 해줘 더욱 유연하게 사용할 수 있다. member와 delivery는 order가 조회될 때 조인으로 한번에 가져와질 것이다 쿼리는 한방만 나간다.
fetch 조인은 Lazy로딩 보다 우선된다.
위의 경우에 member ,delivery, order 의 데이터를 select절에서 모두 들고오기 떄문에 불필요한 데이터까지 들고올 수있는 문제가 있다.
3.DTO로 필요한 정보만 조회
2번 방법에서 조금더 최적화 (필요한 정보만 조회해서 가져오기) 하기 위해 서는 DTO를 JPQL의 select 절에 넘겨서 필요한 정보만 조회할 수 있는 방법이 있다 . 이 방법을 사용하기 위해서는 컨트롤러단에서 사용하는 Dto를 사용하기 보다는 repository단에서 사용할 Dto를 새로 만드는 것이 좋다 (repository -> contoller로 의존관계를 갖는 것은 아키텍쳐상 좋은 방식이 아니다)
SimpleOrderQueryDto
@Data
@AllArgsConstructor
public class SimpleOrderQueryDto {
private Long id;
private String name;
private LocalDateTime orderDateTime;
private Status status;
private Address address;
}
컨트롤러에서 사용하는 것과 내용은 똑같다 . JPQL select 절에 DTO를 넘길때 파라미터로 엔티티를 넘길 수 없기 떄문에 모든 필드를 받는 생성자를 만들어야한다 . (나는 롬복을 사용했다.)
@GetMapping("/api/v4/simple-orders")
public List<SimpleOrderQueryDto> findOrderDto(){
//페치 조인으로 가져온다.
return orderRepository.findOrderDto();
}
컨트롤러에서 바로 위의 Dto를 반환하고 있다 .
public List<SimpleOrderQueryDto> findOrderDto() {
return em.createQuery("select " +
"new com.toyproject.kithub.repository.SimpleOrderQueryDto(o.id,m.name,o.orderDate,o.status,d.address) " +
" from Order o " +
" join o.member m " +
" join o.delivery d " , SimpleOrderQueryDto.class)
.getResultList();
}
findOrderDto()의 보면 Dto를 new 를 통해 생성해서 JPQL 셀렉트절에 넣었다
SQL로 생각하면 id, name ,orderDateTime 이런식으로 필요한 정보들을 셀렉트하는 거와 같은 것이다.
"new com.toyproject.kithub.repository.SimpleOrderQueryDto(o.id,m.name,o.orderDate,o.status,d.address) "
이렇게 하면 Dto의 필드에 해당하는 컬럼 정보만 가져오고 ,반환타입으로 SimpleOrderQueryDto에 조회된 값이 바인딩되어 리턴된다.
엔티티 자체를 조회하는 것이 아니라 필요한 필드만 조회되기 때문에 select 절 쿼리가 줄어든다.
사실 3번이 무조건 좋다고 할 수는 없고 2번과 3번은 사실 각각 trade-off 가 있다 .
2번의 경우 select 절에 데이터를 많이 퍼오긴하지만 엔티티를 가져오기 때문에 조회된 엔티티를 값을 변경하거나 하는 로직을 넣을 수 있다 . 또 엔티티에서 필요한 값을 꺼내어 쓸 수 있기 때문에 다른곳에서 해당 메서드를 재사용 할 수도 있다 .
반면 3번의 경우는 한가지 상황에만 fit하게 짜여저 있어 다른 곳에서 사용할 수 없다 . 또 값만 직접 골라서 가져오기 때문에 엔티티를 활용하여 값을 변경한다거나 다른 로직을 넣기가 힘들다 . (리파지토리 코드가 화면에 최적화 된다.)
리파지토리는 엔티티를 조회하는 용도이기 때문에 3번처럼 특정화면에만 최적화된 메서드는 사용하지 않는 것이 좋다 .
이를 해결하려면
3번 같은 메서드들을 따로 모아둘 화면 조회 쿼리 전용 Repository를 만들어서 쓰는게 좋다 .
상황에 맞게 사용하는 것이 좋다 .
'JPA' 카테고리의 다른 글
API 개발을 위한 JPA 4 - OSIV (open session in view) (0) | 2021.06.15 |
---|---|
API 개발을 위한 JPA 3 - 1:N N:N 관계 컬렉션 조회 최적화 (0) | 2021.06.12 |
API 개발을 위한 JPA 1 - crud 메서드 작성시 유의점 (0) | 2021.06.12 |
JPA 공부 20 - JPQL . 9 벌크 연산 (0) | 2021.06.08 |
JPA 공부 19 - JPQL . 8 Named 쿼리 (0) | 2021.06.08 |