주문 + 배송정보 + 회원을 조회하는 API를 만들어보자.
그리고 위 API를 사용하여 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해 보자.
엔티티
엔티티 부분에는 모든 필드들은 작성하지 않았고 연관관계에 있는 엔티티 객체 타입 필드들만 적어놨다.
Order
@Entity
@Getter
@Setter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id
@Column(name = "order_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
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;
...
}
OrderItem
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id
@Column(name = "order_item_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Item item;
...
}
Delievery
@Entity
@Getter
@Setter
public class Delivery {
@Id
@Column(name = "delivery_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY, mappedBy = "delivery")
private Order order;
...
}
Member
@Entity
@Getter
@Setter
public class Member {
@Id
@Column(name = "member_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
...
}
간단한 주문 조회 V1: 엔티티를 직접 노출
@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;
}
}
// orderRepository 메서드
public List<Order> findAllByString(OrderSearch orderSearch) {
String jpql = "select o from Order o join o.member m";
boolean isFirstCondition = true;
//주문 상태 검색
if (orderSearch.getOrderStatus() != null) {
if (isFirstCondition) {
jpql += " where";
isFirstCondition = false;
} else {
jpql += " and";
}
jpql += " o.status = :status";
}
//회원 이름 검색
if (StringUtils.hasText(orderSearch.getMemberName())) {
if (isFirstCondition) {
jpql += " where";
isFirstCondition = false;
} else {
jpql += " and";
}
jpql += " m.name like :name";
}
TypedQuery<Order> query = em.createQuery(jpql, Order.class)
.setMaxResults(1000);
if (orderSearch.getOrderStatus() != null) {
query = query.setParameter("status", orderSearch.getOrderStatus());
}
if (StringUtils.hasText(orderSearch.getMemberName())) {
query = query.setParameter("name", orderSearch.getMemberName());
}
return query.getResultList();
}
포스트맨으로 위 api를 실행해 보면 아래와 같은 결과가 나온다.
[
{
"id": 1,
"member": {
"id": 1,
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"name": "userA",
"orders": [
{
"id": 1,
"member": {
"id": 1,
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"name": "userA",
"orders": [
{
"id": 1,
"member": {
"id": 1,
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"name": "userA",
"orders": [
{
......
결과를 보면 무한루프에 걸린 걸 볼 수 있다.
무한 루프에 걸린 이유는 뭘까?
List<Order> all = orderRepository.findAllByString(new OrderSearch()) 을 통해 값을 가져오는 부분까지는 무한 루프에 걸리지 않는다. 정말로 안 걸리는지 테스트를 해보자. 코드를 아래와 같이 수정하고 포스트맨으로 요청을 보내보자.
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
List<Order> result = new ArrayList<>();
return result;
}
실행해 보면 아래와 같이 조회 쿼리는 한번 발생하고 포스트맨의 결과는 [] 값을 반환한다.
Hibernate:
select
o1_0.order_id,
o1_0.delivery_id,
o1_0.member_id,
o1_0.order_date,
o1_0.status
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
fetch
first ? rows only
무한 루프에 걸리게 되는 이유는 바로 @RestController을 사용하여 발생하는 JSON 직렬화 때문에 발생한다.
@RestController을 사용하면 메서드가 반환하는 객체는 자동으로 JSON 또는 XML 형식으로 변환된다.
이때 JSON 직렬화를 처리하는 라이브러리는 JSON을 만들기 위해서 A -> B, B -> A를 참조하는 경우에는 모든 참조를 순환하게 된다.
JSON은 객체를 텍스트로 표현하는 형식이기 때문에, 객체 간의 연결을 표현하기 위해 참조하고 있는 객체를 계속해서 재귀적으로 표현하게 된다. 예를 들어, A 객체가 B 객체를 참조하고, B 객체가 다시 A 객체를 참조하는 상황이라면 JSON으로 변환할 때 두 객체가 서로를 계속해서 참조하게 되어 무한 루프가 발생한다.
이러한 문제를 해결하기 위해 아래와 같이 양방향 관계에 있는 orders 부분에 @JsonIgnore을 사용하면 된다.
public class Member {
@Id
@Column(name = "member_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Address address;
@NotEmpty
private String name;
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
하지만 위와 같이 @JsonIgnore을 설정하고 실행해 보면 아래와 같이 오류가 발생한다.
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]->jpabook.jpashop.domain.Order["member"]->jpabook.jpashop.domain.Member$HibernateProxy$AzKasdZD["hibernateLazyInitializer"])
Order와 Member는 지연로딩 관계이다. 그래서 Order을 가져올 때 member에 실제 Member 객체가 아닌 프록시 객체가 저장된다. 이때 JSON 직렬화를 처리하는 라이브러리는 JSON을 만들다가 member에 실제 Member 객체가 아닌 프록시 객체가 저장되어 있는 경우 위와 같은 예외가 발생한다. jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문이다.
이런 문제를 해결하기 위해 Hibernate5Module을 스프링 빈으로 등록하면 해결(스프링 부트 사용 중)할 수 있다.
hibernate5Module는 하이버네이트가 만든 프록시 객체를 JSON으로 읽을 수 있도록 도와주는 역할을 한다.
스프링 부트 3.0 이상: Hibernate5JakartaModule 등록
기본적으로 초기화된 프록시 객체만 노출, 초기화되지 않은 프록시 객체는 노출 안하게 만드는 기능이다.
build.gradle에 다음 라이브러리를 추가하자
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
JpashopApplication에 다음 코드를 추가하자
@Bean
Hibernate5JakartaModule hibernate5Module() {
return new Hibernate5JakartaModule();
}
실행 시 아래와 같이 정상 출력된다.
다음과 같이 설정하면 강제로 지연 로딩 가능
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5JakartaModule hibernate5Module() {
Hibernate5JakartaModule hibernate5JakartaModule = new Hibernate5JakartaModule();
//강제 지연 로딩 설정
hibernate5JakartaModule.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true);
return new Hibernate5JakartaModule();
}
}
이 옵션을 키면 order -> member, member -> orders 양방향 연관관계를 계속 로딩하게 된다.
따라서 @JsonIgnore 옵션을 한곳에 주어야 한다.
포스트맨으로 요청을 보내면 아래와 같이 잘 출력된다.
[
{
"id": 1,
"member": {
"id": 1,
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"name": "userA"
},
"orderItems": [
{
"id": 1,
"item": {
"id": 1,
"name": "JPA1 BOOK",
"price": 10000,
"stockQuantity": 99,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 10000,
"count": 1,
"totalPrice": 10000
},
{
"id": 2,
"item": {
"id": 2,
"name": "JPA2 BOOK",
"price": 20000,
"stockQuantity": 98,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 20000,
"count": 2,
"totalPrice": 40000
}
],
"delivery": {
"id": 1,
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"status": null
},
"orderDate": "2024-08-23T10:41:03.001682",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 2,
"member": {
"id": 2,
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"name": "userB"
},
"orderItems": [
{
"id": 3,
"item": {
"id": 3,
"name": "SPRING1 BOOK",
"price": 20000,
"stockQuantity": 197,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 20000,
"count": 3,
"totalPrice": 60000
},
{
"id": 4,
"item": {
"id": 4,
"name": "SPRING2 BOOK",
"price": 40000,
"stockQuantity": 296,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 40000,
"count": 4,
"totalPrice": 160000
}
],
"delivery": {
"id": 2,
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"status": null
},
"orderDate": "2024-08-23T10:41:03.016708",
"status": "ORDER",
"totalPrice": 220000
}
]
정리
엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭! 한 곳을 @JsonIgnore 처리해야 한다.
안 그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.
앞에서 계속 강조했듯이 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다. 따라서 Hibernate5Module를 사용하기보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안 된다!!
즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다.
즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워진다.
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용하자
간단한 주문 조회 V2: 엔티티를 DTO로 변환
참고
SimpleOrderDto(Order order)와 같이 DTO가 엔티티를 파라미터로 받는 것은 크게 문제가 되지 않는다.
왜냐하면 DTO와 같이 중요하지 않는데서 중요한 엔티티에 의존하는 것이기 때문에 크게 문제가 되지 않는다.
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
return orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.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();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}
엔티티를 DTO로 변환하는 일반적인 방법이다.
문제는 지연로딩으로 쿼리가 총 1 + N + N번 실행된다. (v1과 쿼리수 결과는 같다.)
- order 조회 1번(order 조회 결과 수가 N이라고 가정)
- order -> member 지연 로딩 조회 N 번
- order -> delivery 지연 로딩 조회 N 번
- 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.(최악의 경우)
- 왜 최악의 경우라고 했냐면 지연로딩은 기본적으로 영속성 컨텍스트에서 먼저 조회한다.
- 그래서 이미 조회된 경우 쿼리를 생략할 수도 있기 때문이다.
위 api를 실행하면 실행 쿼리는 아래와 같다.
Hibernate:
select
o1_0.order_id,
o1_0.delivery_id,
o1_0.member_id,
o1_0.order_date,
o1_0.status
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
fetch
first ? rows only
Hibernate:
select
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.name
from
member m1_0
where
m1_0.member_id=?
Hibernate:
select
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zipcode,
d1_0.status
from
delivery d1_0
where
d1_0.delivery_id=?
Hibernate:
select
o1_0.order_id,
o1_0.delivery_id,
o1_0.member_id,
o1_0.order_date,
o1_0.status
from
orders o1_0
where
o1_0.delivery_id=?
Hibernate:
select
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.name
from
member m1_0
where
m1_0.member_id=?
Hibernate:
select
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zipcode,
d1_0.status
from
delivery d1_0
where
d1_0.delivery_id=?
Hibernate:
select
o1_0.order_id,
o1_0.delivery_id,
o1_0.member_id,
o1_0.order_date,
o1_0.status
from
orders o1_0
where
o1_0.delivery_id=?
@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 초기화
}
}
왜 실행 쿼리가 저렇게 출력되는지 알아보자.
List<Order> orders = orderRepository.findAllByString(new OrderSearch())
이 부분에서는 단순히 order 값만 조회해 온다. 왜냐면 member, delivery와 지연로딩이기 때문이다.
그래서 첫 번째 select문은 위 코드로 인해 발생했다.
문제는 아래 코드이다.
return orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
SimpleOrderDto 생성자 부분을 보자.
name = order.getMember().getName(), address = order.getDelivery().getAddress() 부분에서 LAZY 초기화가 발생한다. LAZY 초기화된다는 건 JPA가 엔티티의 id(PK)를 가지고 영속성 컨텍스트에서 엔티티를 찾아본다.
없는 경우 DB에 쿼리를 보내 엔티티 객체를 가져온다. 즉 엔티티의 데이터를 가져오기 위해 추가 쿼리가 발생한다.
현재 가져온 order 객체는 2개다. 따라서 2번 반복문이 돌면서 각 반복문마다 member와 delivery에 대해 각각 select 쿼리가 발생한다.
그래서 총 5번의 select 쿼리가 나오는 게 맞지만 실제 출력된 쿼리를 보면 총 7번의 select 쿼리가 발생한다.
해당 문제는 스프링 부트 3.0 이상을 사용하게 되면 하이버네이트6 버전을 사용하게 된다.
근데 하이버네이트 6버전에서 최적화를 못하고 있는 버그라고 한다.
그래서 원래 출력되는 총쿼리는 5번이라고 보면 된다.
간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
*/
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
return orders.stream()
.map(o -> new SimpleOrderDto(o))
.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();
}
실행쿼리
Hibernate:
select
o1_0.order_id,
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zipcode,
d1_0.status,
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.name,
o1_0.order_date,
o1_0.status
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
join
delivery d1_0
on d1_0.delivery_id=o1_0.delivery_id
- 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
- 페치 조인으로 order -> member, order -> delivery는 이미 조회 된 상태 이므로 지연로딩X
- order -> member : order에서 member를 참조하는 부분
- order -> delivery : order에서 delivery를 참조하는 부분
- 페치 조인은 order, member, delivery 객체를 한번에 조회하는 방식이므로 프록시 객체가 아닌 실제 엔티티 객체의 값이 저장된다. 그러므로 LAZY에 의해 발생하는 쿼리에 대해 걱정하지 않아도 된다.
간단한 주문 조회 V4: JPA에서 DTO로 바로 조회
엔티티를 조회하여 엔티티를 DTO로 변환하는 과정 없이 JPA에서 바로 DTO로 바로 조회하는 방식
/**
* V4. JPA에서 DTO로 바로 조회
* - 쿼리 1번 호출
* - select 절에서 원하는 데이터만 선택해서 조회 */
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
OrderSimpleQueryRepository 조회 전용 리포지토리
리포지토리는 가급적이면 순수한 엔티티를 조회하는 데에 쓰인다.
그래서 기존 리포지토리가 아닌 DTO 조회 전용 리포지토리를 만들었다.
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.repository.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();
}
}
@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;
}
}
- 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회
- new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환
- SELECT 절에서 원하는 데이터를 직접 선택하므로 DB -> 애플리케이션 네트웍 용량 최적화(생각보다 미비)
- 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점
- 리포지토리의 사용이 엔티티에 대한 객체 그래프를 조회하는 용도로 사용이 돼야한다.
- 근데 위 방식은 api 스펙에 맞춰서 리포지토리 코드가 짜인거다.
- 결과적으로는 리포지토리에 api 스펙이 들어가 리포지토리가 화면을 의존하고 있을 수 있다.
정리
엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두 가지 방법은 각각 장단점이 있다.
둘 중 상황에 따라서 더 나은 방법을 선택하면 된다.
엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다.
따라서 권장하는 방법은 다음과 같다.
쿼리 방식 선택 권장 순서
- 우선 엔티티를 DTO로 변환하는 방법을 선택한다.(v2 방식)
- 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.(v3 방식)
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.(v4 방식)
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
'JPA > JPA 활용 2' 카테고리의 다른 글
5. API 개발 고급 - 실무 필수 최적화 (0) | 2024.08.24 |
---|---|
4. API 개발 고급 - 컬렉션 조회 최적화 (0) | 2024.08.24 |
2. API 개발 고급 - 준비 (0) | 2024.08.22 |
1. API 개발 기본 (0) | 2024.08.22 |