API 개발 고급 에서는 주문 + 배송정보 + 회원을 조회하는 API를 만들 것이다.
지연 로딩 때문에 발생하는 성능 문제를 해결하는 것이 목표이다.
Order - Member
는 ManyToOne, Order - Delivery
는 OneToOne 이다. 여기에서는 @XToOne
에 대한 성능 최적화를 위한 과정을 진행한다. Order - OrderItem은 OneToMany이며 Collection 형태로, 나중에 진행한다.
00. 컨트롤러 작성
요청을 받아 처리할 컨트롤러를 작성하자.
@RestController
로 Json 형태로 객체 데이터를 반환함을 명시한다. @RequiredArgsConstructor
로 final 필드인 orderRepository를 주입받는다.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
}
01. 무한루프 발생
가장 단순한 형태인 주문 정보를 조회하는 API를 만들어 보자. 다음과 작성하자.
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
참고로, Order, Member, Delivery, OrderItems는 다음과 같이 작성되어 있다.
- Order 클래스
@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") // FK 명이 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; // 주문 상태
// 생성 메서드, 연관관계 메서드 등
}
- Member 클래스
@Entity
@Getter
@Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@NotEmpty
private String name;
@Embedded
private Address address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
- Delivery 클래스
@Entity
@Getter
@Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status;
}
- OrderItem 클래스
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
private int orderPrice;
private int count;
// 생성 메서드, 비즈니스 로직 등
}
API를 호출하면 다음과 같이 나온다.
이것은 무한루프에 빠진 것이다. 여기서 첫 번째 문제에 직면하게 된다.
객체를 Json으로 만들어주는 Jackson 라이브러리는 Order 엔티티에서 Member 엔티티로, Member 엔티티에서 Order 엔티티로.. 서로를 무한 참조하게 된다. 즉, 양방향 관계에서 문제가 발생한 것이다. 이에 대한 해결책으로, 양방향 연관관계가 있으면 둘 중 하나의 엔티티에 @JsonIgnore
를 작성하는 것이다.
02. 무한루프 해결
서로를 참조하는 문제를 해결하기 위해 @JsonIgnore
를 작성한다.
- Member 클래스
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
- Delivery 클래스
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
- OrderItem 클래스
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
이제 다시 API를 호출해보자.
03. 지연로딩과 관련된 문제 발생
API를 호출하면 다음과 같은 에러가 나온다.
Type definition error
가 발생한다. 이 에러는 다음과 같은 이유에서 발생한 것이다.
Order의 member는 fetch가 LAZY로 설정되어 있다. 즉, 지연로딩이다. 지연로딩은 데이터를 DB에서 가져오는 것이 아니다. Hibernate는 가짜 ProxyMember 객체를 생성하여 넣어놓은 것이다. Jackson 라이브러리가 member를 조회하려고 하는데 Member가 객체가 아니라서 처리가 불가능해 에러가 발생한 것이다.
프록시 기술을 사용할 때 ByteBuddy 라는 기술을 많이 쓴다.
04. 지연로딩과 관련된 문제 해결
해결 방법으로, Hibernate5JakartaModule
을 빈으로 등록하는 것이 있다.
먼저, build.gradle
의 dependencies에 다음 내용을 추가한다.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
버전은 명시하지 않아도 스프링부트가 알아서 찾아준다.
그리고 이 애플리케이션의 메인 클래스인 JpashopApplication 에 Hibernate5JakartaModule
를 다음과 같이 빈으로 등록한다.
@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
return new Hibernate5JakartaModule();
}
이제 API를 호출하면 아래와 같은 결과가 나온다.
members, orderItems, delivery 필드가 null 인 것을 볼 수 있다. 지연로딩이기 때문에 DB에서 조회한 것이 아니라, Hibernate5JakartaModule의 기본 전략이 동작한 것이다. 기본전략은 지연로딩을 무시하는 것이다.
04. 지연로딩을 강제 초기화
지연로딩을 강제로 초기화하는 방법이 있다. for-each문을 이용해 ordersV1 메소드를 결론적으로 다음과 같이 작성한다.
@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());
for(Order order : all) {
order.getMember().getName(); // LAZY 강제 초기화
order.getDelivery().getAddress(); // LAZY 강제 초기화
}
return all;
}
}
for-each문 내에서, order.getMember()
여기까지는 프록시 객체를 가져오는 것이다. 하지만 .getName()
까지 붙으면 실제 객체에 접근하므로 쿼리가 나가고 실제 데이터를 가져오게 된다.
이제 API를 호출하면 다음과 같은 결과가 나온다.
여기까지 작성했지만, 이 API는 엔티티를 외부에 노출시킨다. 당연히 절대 이렇게 개발하면 안된다.
Dto 클래스를 만들어 사용하자. 다음 글에 이어서 Dto 클래스를 이용한 V2를 작성할 예정이다.
'API' 카테고리의 다른 글
API 개발 고급 - 간단한 주문 조회 V2: 엔티티를 DTO로 변환 (0) | 2024.02.05 |
---|---|
API 개발 기본 (2) | 2024.02.02 |