스프링 부트와 JPA 활용2 - API 개발과 서능 최적화 [김영한 강사님]
@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;
}
}
public class Member {
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
public class Order{
@ManyToOne(fetch = LAZY)
@JoinColumn(name="member_id")
private Member member;
}
public class Delivery{
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = LAZY)
private Order order;
}
Order가 Member에 있고, 해당 Member에 Order가 있어 무한 루프가 발생한다.
객체를 JSON으로 만드는 JACKSON 입장에서는 무한루프를 돌며 객체를 뽑아낸다.
양방향이면 반대편에서 @JsonIgnore 해줘야 한다.
이렇게 해주면 500에러와 함께 "org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor];
발생
이는 지연로딩으로 인해 DB에서 가져오지 않아 나타나는 에러이다.
@ManyToOne(fetch = LAZY)
@JoinColumn(name="member_id")
private Member member;
->
//하이버네이트가 Member를 상속한 프록시객체를 만들어서 넣어둔다.(ByteBuddy 라이브러리에 있음)
@ManyToOne(fetch = LAZY)
@JoinColumn(name="member_id")
//프록시 객체에 가짜를 넣어두고 Member 객체에 접근할 떄 그 때 db에 sql을 날림(=프록시 초기화)
private Member member = new ProxyMember();
@Bean
Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}
기본적으로 초기화 된 프록시 객체만 노출, 초기화 되지 않은 프록시 객체는 노출 안함
order
-> member
와 order
-> address
는 지연로딩 => 실제 엔티티 대신에 프록시 존재Hibernate5Module
을 스프링 빈으로 등록하면 해결@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
이 옵션을 키면 order
-> member
, member
-> orders
양방향 연관관계를 계속 로딩 => @JsonIgnore옵션을 한곳에 주어야 한다.
❗주의❗
엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭 한곳을 @JsonIgnore 처리해야 한다. 안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.
❗주의❗
지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다. 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용
/**
* V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
* - 단점: 지연로딩으로 쿼리 N번 호출
*/
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
List<Order> orders = orderRepository.findAll();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(toList());
return result;
}
@Data
static class SimpleOrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public SimpleOrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}
/*
엔티티를 DTO로 변환하는 일반적인 방법이다.
쿼리가 총 1 + N + N번 실행된다. (N+1 문제)
order 조회 1번(order 조회 결과 수가 N이 된다.)
order -> member 지연 로딩 조회 N 번
order -> delivery 지연 로딩 조회 N 번
예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다. (최악의 경우)
지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
*/
/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
*/
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(toList());
return result;
}
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();
}
order
member, address, orderItem
item
다만, 같은 Entity가 영속성 컨텍스트에 있다면 지연 로딩이더라도 SQL을 실행하지 않음
order 조회 1번(order 조회 결과 수가 N이 됨)
order
-> member
지연 로딩 조회 N번
order
-> delivery
지연 로딩 조회 N번
예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행(최악의 경우)
/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
*/
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(toList());
return result;
}
OrderRepository - 추가 코드
public List<Order> findAllWithMemberDelivery() {
// 쿼리 한번으로 order, memeber, delivery를 조인한 후 select절에서 한번에 가졍오기
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.getResultList();
}
DB의 distinct
JPA의 distinct
SQL에 distinct를 추가해서 실제 distinct 쿼리가 나감
애플리케이션 상에서 다시 한 번 중복을 거름
페이징이 불가능하다는 단점이 있음
fetch join : 진짜 객체 값을 채워서 가져오는 것(JPA에만 있음)
엔티티를 fetch join을 사용해서 쿼리 1번에 조회
페치 조인으로 order
-> member
, order
-> delivery
는 이미 조회된 상태 이므로 지연 로딩이 발생하지 않음
private final OrderSimpleQueryRepository orderSimpleQueryRepository; //의존관계 주입
/**
* V4. JPA에서 DTO로 바로 조회
* - 쿼리 1번 호출
* - select 절에서 원하는 데이터만 선택해서 조회
*/
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
OrderSimpleQueryRepository
package jpabook.jpashop.repository.order.simplequery;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import java.util.List;
@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();
}
}
OrderSimpleQueryDto 리포지토리 Dto 직접 조회
package jpabook.jpashop.repository.order.simplequery;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;
import java.time.LocalDateTime;
@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;
}
}
new
명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다.
쿼리 방식 선택 권장 순서
1. 우선 엔티티를 DTO로 변환하는 방법을 선택
2. 필요하면 페치조인으로 성능을 최적화, 대부분의 성능 이슈가 해결됨
3. 그래도 안되면 DTO로 직접 조회하는 방법 사용
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용