강의 출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-API%EA%B0%9C%EB%B0%9C-%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 강의 - 인프런
스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 스프링 부트, 실무에서 잘 쓰고 싶다면? 복잡한 문제까지 해결하는 힘을 길러보세요
www.inflearn.com
해당 강의는 Inflearn에 등록된 김영한님의 Spring Boot 강의입니다.
주문 + 배송정보 + 회원을 조회하는 API를 만들어 보는 시간이다. 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해보자.
1. 간단한 주문 조회 V1: 엔티티를 직접 노출
OrderSimpleApiController
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
/api/v1/simple-orders
API
/**
* V1. 엔티티 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리
* - 양방향 관계 문제 발생 -> @JsonIgnore
*/
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByCriteria(new OrderSearch());
for (Order order : all) {
//멤버랑,배송정보만 조회(OrderItems는 null)
order.getMember().getName(); //Lazy 강제 초기화,(order.getMember()까지는 프록시 객체)
order.getDelivery().getAddress(); //Lazy 강제 초기화
}
return all;
}
JPA에서 "지연 로딩(Lazy Loading)"은 연관된 엔티티를 실제로 사용할 때까지 데이터베이스에서 불러오지 않는 방식이다.
예를 들어, Order 엔티티가 Member 엔티티와 연관되어 있을 때, Order 엔티티를 조회하더라도 Member 엔티티의 데이터는 바로 불러오지 않고, Member 엔티티의 데이터에 접근(예: order.getMember().getName())할 때 비로소 불러오게 된다.
"강제 초기화" 과정에서 getName(), getAddress() 등의 메서드를 호출하면, 이는 JPA에 해당 엔티티의 프록시 객체가 아닌 실제 엔티티 인스턴스를 데이터베이스에서 로드하도록 한다. 이 과정에서 연관된 엔티티의 전체 데이터가 로드되며, 특정 속성(예: 이름 또는 주소)에 접근하기 위해 이 데이터를 사용할 수 있게 된다.
build.gradle
// Hibernate5JakartaModule 등록
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
JpaBookApplication
에 main함수 밑에 다음과 같은 코드 추가
@Bean
Hibernate5JakartaModule hibernate5Module() {
Hibernate5JakartaModule hibernate5Module = new Hibernate5JakartaModule();
//강제 지연 로딩 설정(Category등등 전부 LAZY_Loading된것들은 전부 조회해버림-> 성능 저하)
//hibernate5Module.configure(Hibernate5JakartaModule .Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
다음과 같이 설정하면 강제로 지연 로딩 가능
기본적으로 초기화 된 프록시 객체만 노출, 초기화 되지 않은 프록시 객체는 노출 안함
jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모름. 예외 발생
Hibernate5Module을 스프링 빈으로 등록하면 해결(스프링 부트 사용중)
@JsonIgnore 설정 안해줘서 생기는오류
XToOne
관계는 @JsonIgnore
를 설정 해줘야한다.
(사실 한쪽에만 걸면 상관없긴한데, LAZY로 초기화면서 같이 해버리자.)
엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳에 @JsonIgnore
설정을 안해주면 안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.
여기서는 OrderItem은 Order에 대해서는 Ignore를 해주었는데 Item엔티티에는 하지 않았다. 이유는
Order는 OrderItem은 양방향 연관관계가 성립되어 있다. 그러나 OrderItem과 Item은 단방향으로만 설정되어 있다.
Item에서는 OrderItem은 엔티티 접근이 없고, OrderItem에는 Item 엔티티 접근을 하기에 서로 무한루프에 걸리지 않으므로 설정하지 않았다.
@JsonIgnore 설정 엔티티: Order에서 Member,Delivery에 설정, OrderItem은 Order에만 설정.
결과
여기서 OrderItem도 보려면 밑과 같이 주석
을 지우면 된다.
@Bean
Hibernate5JakartaModule hibernate5Module() {
Hibernate5JakartaModule hibernate5Module = new Hibernate5JakartaModule();
//강제 지연 로딩 설정(Category등등 전부 LAZY_Loading된것들은 전부 조회해버림-> 성능 저하)
hibernate5Module.configure(Hibernate5JakartaModule .Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
주의 사항
2. 간단한 주문 조회 V2: 엔티티를 DTO로 변환
/**
* V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
* - 단점: 지연로딩으로 쿼리 N번 호출
*/
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2(){
List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());
List<SimpleOrderDto> result=orders.stream()
.map(o-> new SimpleOrderDto(o))//map: A->B로 변경(order 엔티티를 ->DTO로 변경)
.collect(Collectors.toList()); // 루프를 돌면서 List를 map
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();
}
}
N+1**문제(지연로딩에 의해 발생..영속성에서 찔러봄)-> 주문이 두개**
-> 2개의 order 1조회 -> 멤버 1조회, 배송 1조회-> 멤버 1조회, 배송 1조회
따라서 1+(회원N)+(배송N) 문제 발생 => 성능저하
Postman으로 api 데이터(json) 확인
DTO로 설정한 속성한 값만 JSON형태의 데이터로 반환을 했음을 확인했다.
3. 간단한 주문 조회 V3: 엔티티를 DTO로 변환-페치조인 최적화
OrderSimpleApiController
에 추가
/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출(Order와 Member join,Order와 Delivery join)
* -> 쿼리를 한개만 보낼 수 있어 성능 최적화 가능!!
*/
@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(Collectors.toList());
return result;
}
OrderRepository
에 추가
/**
* OrderRepository 클래스(member,delivery)
* 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
*/
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order as o" +
" join fetch o.member as m" +
" join fetch o.delivery as d", Order.class
).getResultList();
}
Jpa의 join fetch 를 통해 한번에 쿼리를 join해서 갖고 와서 조회 가능(성능 최적화)
페치 조인이란 JPA에서 연관된 엔티티를 즉시 로딩할 때 사용하는 방법으로, 페치 조인을 사용하면 연관 엔티티를 위한 별도의 쿼리를 실행하지 않아도 된다. 이는 연관 엔티티에 대한 지연 로딩(LAZY) 대신 즉시 로딩(EAGER)을 수행하여 성능을 최적화한다.
따라서 lazy loading + fetch join을 본질적으로 필요할 때만 즉시로딩으로 한거번에 불러올 수 있기때문이다.
여기서 의문이 생겼다. 분명 페치 조인으로 갖고와서 DTO로 반환했는데 왜 원하는 컬럼만 조회하는 쿼리가 안나간 것인가 의문이 생겼다.
곰곰히 생각해보니 조회쿼리는 페치 조인시에만 조회하고, 그 조회한것을 DTO로 변환해서 응답한 것이므로 쿼리는 하나만 생성하는게 맞았다.
4. 간단한 주문 조회 V4: JPA에서 DTO로 바로 조회
repository에 Dto만을 위한 패키지 생성
OrderSimpleApiController
* V4. JPA에서 DTO로 바로 조회
* - 쿼리 1+n번 호출
* - select 절에서 원하는 데이터만 선택해서 조회
*/
private final OrderSimpleQueryRepository orderSimpleQueryRepository; //의존관계 주입
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> orderV4()
{
return orderSimpleQueryRepository.findOrderDto();
}
`OrderSimpleApiQueryDto`
@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;
}
}
OrderSimpleApiQueryRepository
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDto()
{
return em.createQuery(
"select new jpabook.jpashop.repository.order" +
".OrderSimpleQueryDto(o.id, m.name,o.orderDate, o.status, d.address)"+
" from Order as o"+
" join o.member as m"+
" join o.delivery as d", OrderSimpleQueryDto.class
).getResultList();
}
}
결과
V3과 달리 원하는 컬럼
만 갖고오므로 쿼리가 훨씬 더 간결해졌다.
V3 vs V4 정리
V3이 V4보다 재사용성이 더 높다.
하지만 V4는 원하는 컬럼만 조회 가능하여 성능상은 더 좋지만 효과는 미비하다.