김영한 님의 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 강의를 보고 작성한 내용입니다.
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)
private Delivery delivery;
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태 : ORDER, CANCEL
private LocalDateTime orderDate;
...
}
@xToOne 관계인
Order
,Order ➜ Member( N : 1 )
,Order ➜ Delivery ( 1 : 1 )
에 대한 내용입니다.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
return orderRepository.findAllByString(new OrderSearch());
}
}
문제점 1 : 무한 루프
Order 를 보면 Member 가 있습니다. Member 를 가보면 List<Order>
가 있습니다. 그래서 양방향 관계에 의한 무한 루프에 빠지게 되고, 오류가 발생하게 됩니다.
➜ 해결 : 모든 양방향 관계에서 한쪽에 @JsonIgnore
를 사용합니다.
문제점 2 : Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]
Order 에서 Member 는 지연 로딩이기 때문에 Member 에 대한 내용은 가져오지 않고 하이버네이트에서 BtyeBuddy 라는 프록시 기술을 통해 프록시 객체를 생성해서 넣어둡니다.
Member 에 프록시 객체를 가짜로 넣어두고, Member 객체의 값을 이용하는 경우 그때 DB 에서 SQL 을 날려서 객체의 값을 가져와 채워주게 됩니다.
Jackson 라이브러리가 응답을 만들면서 Member 를 뽑아보려 할 때 순수한 Member 객체가 아니기 때문에 이 프록시 객체를 json으로 어떻게 생성해야 하는지 몰라 예외가 발생합니다.
➜ 해결 : Hibernate 5 라이브러리를 추가하고 Hibernate5Module
을 스프링 빈으로 등록
@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
return new Hibernate5JakartaModule();
}
초기화 된 프록시 객체만 노출되고, 초기화 되지 않은 프록시 객체는 노출하지 않기 때문에 NULL 로 표현하게 됩니다.
NULL 로 표현되는 객체에 데이터를 출력하기 위해서 아래와 같은 방법을 사용할 수 있습니다.
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
Hibernate5Module
을 빈으로 등록할 때 위의 코드처럼 작성하면 연관관계에 있는 모든 데이터들도 함께 가져오게 됩니다.
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName();
}
return all;
}
만약 연관관계 중 원하는 데이터만 가져오고 싶다면 Controller 에 위처럼 작성합니다.
문제점 2에서 설명한 것처럼 프록시 객체의 값을 이용하기 때문에 DB 에서 Member 의 데이터를 가져오게 됩니다.
하지만 엔티티를 노출하는 방법은 이전 게시글에서 말한 것처럼 좋지 않은 방법입니다. 또 강제로딩을 설정해 정보를 가져와도 안됩니다.
public class OrderSimpleApiController {
...
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV2 () {
// 2개
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
// orders 에 의해 2번 반복
return orders.stream().map(order -> new SimpleOrderDto(order)).toList();
}
@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 초기화
}
}
}
이전 게시글에서 한 것처럼 필요한 정보만을 세팅한 DTO 를 만들어서 반환합니다. 이전과 다른 점은 연관관계가 있다는 점입니다.
위에서 프록시 객체의 값을 이용하면 실제 데이터를 불러온다고 했는데 이를 Lazy 초기화라고 합니다.
Lazy 초기화는 영속성 컨텍스트가 member의 ID 를 가지고 영속성 컨텍스트에서 찾습니다. 없다면 DB 에 쿼리를 날려서 데이터를 가져오게 됩니다.
문제점 : 1 번과 2번의 동일한 문제인데 날라가는 쿼리의 수가 많다는 점입니다.
( N + 1 문제 )
현재 Order 에 데이터가 2개 존재합니다. 그래서 findAllByString()
의 결과가 2개가 나오게 되고, DTO 로 변환하는 stream 이 2번 반복되게 됩니다.
처음 루프를 돌 때 getMember()
에 의해 Member 테이블에, getDelivery()
에 의해 Delivery 테이블에 쿼리가 나가게 됩니다. 두 번째 루프를 돌 때도 동일하게 2번의 쿼리가 나가게 됩니다.
결과적으로 총 5번( Order 1번, Member 2번, Delivery 2번)의 쿼리가 나가게 됩니다.
위와 같은 상황을 N + 1 문제
라고 하는데 처음 나간 쿼리의 결과로 N 번만큼 쿼리가 추가 실행 되는 것을 말합니다.
1 은 처음에 나간 쿼리를 의미합니다. 쿼리의 결과는 2개이므로 N = 2
가 됩니다.
첫 번째 N 은 회원, 두 번째 N 은 Delivery 가 되어 아래와 같은 계산식이 나옵니다.
(N + 1) ➜ (1 + 회원 N + 배송 N)
, 여기서 N = 2 라고 하였으니 1 + 2 + 2 = 5가 되어 5번의 쿼리가 나가게 됩니다.
단> 지연 로딩은 DB 에 쿼리를 바로 날리지 않고 먼저 영속성 컨텍스트를 찾기 때문에 영속성 컨텍스트에 있는 경우 쿼리가 생략됩니다.
예를 들어, 주문 두 개 모두 동일한 유저가 주문한 것인 경우 처음에는 영속성 컨텍스트에 없기 때문에 Member 테이블에 쿼리가 나가게 되고, 두 번째 루프돌 때는 영속성 컨텍스트에 있기 때문에 Member 테이블에 쿼리가 나가지 않게 됩니다.
v1 에서 order ➜ member 는 지연로딩이므로 실제 엔티티 대신 프록시 객체가 존재합니다.
jackson 라이브러리는 프록시 객체 자체를 json 으로 어떻게 생성하는지 모르기 때문에 Hibernate5JakartaModule
을 스프링 빈으로 등록해서 문제를 해결합니다.
하지만 v2 를 호출할 때 Hibernate5JakartaModule
을 제거하고 실행했을 때 정상적으로 응답이 됩니다. 그 이유는 v1 에서는 Entity 를 직접 반환하기 때문에 프록시 객체를 읽어야 됐었지만, v2 의 경우에는 Entity 를 직접 반환하는 것이 아닌 DTO 를 반환하기 때문에 지연로딩으로 인한 프록시 객체가 있어도 DTO 는 jackson 라이브러리가 읽을 수 있는 객체이기 때문에 제거해도 정상적으로 동작합니다.
Fetch join
은 데이터베이스에서 엔티티를 조회할 때 연관된 엔티티의 데이터를 함께 가져오는 기능을 말합니다. 이것은 지연 로딩으로 설정된 연관된 엔티티를 즉시로딩하여
N + 1 문제
를 방지하고 성능을 최적화하는 데 사용됩니다.
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV3 () {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
return orders.stream().map(order -> new SimpleOrderDto(order)).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();
}
Order 를 조회하면서 Member 와 Delivery 를 한 번에 가져오는 JPQL 입니다.
Member 와 Delivery 가 Lazy 가 걸려있지만 이를 다 무시하고 위의 쿼리를 수행할 때는 그냥 다 값을 채워서 프록시가 아닌 진짜 객체를 가져옵니다.
결과적으로 이전에 5번의 쿼리로 조회하던 것을 한 번의 쿼리로 조회할 수 있게 됩니다.
즉, 기본적으로 전부 Lazy 를 걸어놓고, 필요한 것만 fetch join 으로 가져오는 방법이 가장 좋은 방법입니다.
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> orderV4 () {
return orderRepository.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) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.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번은 Entity 의 모든 필드들을 들고오는 반면 new
를 사용해서 JPQL 의 결과를 DTO로 즉시 변환함으로써 일반적인 SQL을 사용할 때처럼 원하는 값을 선택해서 조회할 수 있습니다.
다만 엔티티로 조회할 떄는 모든 필드를 반환하기 때문에 나중에 필요한 필드를 뽑아서 사용할 수 있지만, 특정한 필드만을 가진 DTO 를 반환하기 때문에 재사용성이 떨어지게 됩니다.
즉, findAllWithMemberDelivery()
는 다른 곳에서도 사용할 수 있지만 findOrderDtos()
는 그렇지 않다는 의미입니다.
영한님이 권장하는 쿼리 방식 선택 순서는 아래와 같습니다.
우선 엔티티를 DTO로 변환하는 방법을 선택한다.
필요하면 패치 조인으로 성능을 최적화 한다. ➜ 대부분의 성능 이슈가 해결된다.
그래도 안되면 DTO로 직접 조회하는 방법을 사용한다. ➜ DTO 를 반환하는게 패치조인보다 성능이 좋음
최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.