![[Spring] 지연 로딩과 조회 성능 최적화](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn5orr%2FbtscHMuRdAF%2F9nWw4HcFTkSFCaKgABXGkK%2Fimg.png)

인프런 김영한 님의 "실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화"를 참고하였습니다.
아래 코드는 주문 + 배송정보 + 회원을 조회하는 API입니다.
주문과 회원은 다대일 관계이며, 주문과 배송정보는 일대일 관계입니다.
주문 엔티티
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status;
}
회원 엔티티
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@Embedded
private Address address;
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
배송정보 엔티티
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status;
}
엔티티를 직접 노출(V1)
OrderSearch의 필드는 회원 이름과 주문 상태를 가지고 있습니다. 아래 코드를 실행시켜 주문 정보를 가져오겠습니다.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
private final OrderSimpleQueryRepository orderSimpleQueryRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1(){
List<Order> all = orderRepository.findAll(new OrderSearch());
return all;
}
}
포스트맨 실행결과 아래와 같은 에러가 발생합니다.

이유는 Order의 Member 필드와 Delivery 필드는 지연로딩인데, 이는 DB에서 Member와 Delivery는 안 끌고 오고 Order 정보만 끌고 옵니다. 하지만, 이 과정에서 Member 필드와 Delivery 필드를 null로 할 수 없어 하이버네이트는 Member에 가짜 프록시객체인 ByteBuddyInterceptor를 생성해서 넣어둡니다. jackson 라이브러리는 이 프록시 객체를 json으로 생성을 못하기에 위와 같은 예외가 발생합니다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id") //FK
private Member member = new ByteBuddyInterceptor(); //member에 ByteBuddyInterceptor객체 넣어둠
//나중에 멤버객체를 필요하면 DB에서 멤버객체를 가져와 넣어둠. 이를 프록시를 초기화 한다고 함
그렇기에 지연로딩의 경우 Hibernate5Module을 설치해야 합니다.
build.gradle에 아래 dependency를 추가한 뒤, Hibernate5Module을 빈으로 등록하면 해결됩니다.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5Module hibernate5Module(){
return new Hibernate5Module();
}
}
아래와 같이 Order 정보를 잘 가져오는 것을 확인할 수 있습니다. 위에서 언급했다시피 Member와 Delivery는 지연로딩이기에 무시합니다.

하지만 위와 같은 방법처럼 엔티티를 직접 노출하는 것은 실무에서 거의 사용되는 일이 없기에 간단히 이 정도만 알아두겠습니다.
엔티티를 DTO로 변환(V2)
Order정보를 가져온 뒤, DTO로 변환합니다. 이 방법은 API스펙에 딱 맞춰서 결과를 가져올 수 있습니다.
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
List<Order> orders = orderRepository.findAll(new OrderSearch());
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o)).collect(Collectors.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(); //LAZY 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); //LAZY 초기화
}
}

하지만 이 방법은 N + 1문제를 발생시킵니다. 예를 들어 처음에 Order를 조회하고 주문의 수가 order1, order2 두 개라 하면, 지연로딩이기 때문에 order1에 대한 회원 쿼리와 배송쿼리가 실행됩니다. 그다음 order2에 대한 회원 쿼리와 배송쿼리가 또 실행됩니다. 즉, 쿼리가 총 1 + 2 + 2로 5번 실행됩니다.
엔티티를 DTO로 변환 - 페치 조인 최적화(V3)
페치 조인은 JPQL에서 성능 최적화를 위해 제공하는 기능으로 연관된 엔티티나 컬렉션을 한 번에 같이 조회할 수 있는 기능을 말합니다. 아래와 같이 사용하여 주문, 멤버, 배송정보를 한 번에 가져옵니다.
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();
}
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> orderV3(){
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream().map(o -> new SimpleOrderDto(o)).collect(Collectors.toList());
return result;
}
v2와 v3의 결괏값은 같지만, v2처럼 쿼리를 5번 생성하는 게 아닌 아래와 같이 쿼리 1번에 조회를 하기에 성능 최적화가 가능합니다. 페치 조인으로 Order → member, order → delivery는 이미 조회된 상태이므로 지연로딩이 일어나지 않습니다.

엔티티를 DTO로 바로 조회(V4)
query에 new 명령어를 사용해서 JPQL 결과를 DTO로 즉시 변환합니다. 이 방법은 Select절에서 원하는 데이터를 직접 선택하므로 DB → 애플리케이션 네트워크 용량 최적화가 됩니다.
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> orderV4(){
return orderSimpleQueryRepository.findOrderDtos();
}
@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) {
orderId = this.orderId;
name = this.name;
orderDate = this.orderDate;
orderStatus = this.orderStatus;
address = this.address;
}
}
@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();
}
}

V3와 V4는 트레이드 오프가 있습니다. V3는 재사용성이 높습니다. 반면 V4는 코드가 복잡하고 리포지토리 재사용성이 떨어집니다. 성능은 V3보다 조금 더 좋습니다.(사실 큰 차이는 없습니다.) 그렇기에 V3 사용을 권장합니다.
쿼리 방식 선택 권장 순서
- 우선 엔티티를 DTO로 변환하는 방법을 선택한다. (V2)
- 필요하면 패치 조인으로 성능을 최적화합니다. (V3) → 대부분의 성능 이슈가 해결
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용합니다. (V4)
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용합니다.
'Spring' 카테고리의 다른 글
[Spring] OneToMany 관계에서 페치 조인 최적화 (0) | 2023.05.03 |
---|---|
[Spring] spring.datasource.driver-class-name 에러 (0) | 2023.04.25 |
[Spring] 양방향 순환참조 해결하기 (0) | 2023.04.15 |
[Spring] 즉시 로딩(Eager Loading)과 지연 로딩(Lazy Loading) 알아보기 (0) | 2023.03.19 |
[Spring] Cascade옵션 알아보기 (0) | 2023.03.15 |
느리더라도 단단하게 성장하고자 합니다!
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!