API 개발 고급 - 준비
조회용 샘플 데이터 입력
API 개발 고급 설명을 위해 샘플 데이터를 입력하자.
- userA
- JPA1 BOOK
- JPA2 BOOK
- userB
- SPRING1 BOOK
- SPRING2 BOOK
@Component
@RequiredArgsConstructor
public class InitDb {
private final InitService initService;
@PostConstruct
public void init() {
initService.dbInit1();
initService.dbInit2();
}
@Component
@Transactional
@RequiredArgsConstructor
static class InitService {
private final EntityManager em;
public void dbInit1() {
Member member = createMember("userA", "서울", "1", "1111");
em.persist(member);
Book book1 = createBook("JPA1 BOOK", 10000, 100);
em.persist(book1);
Book book2 = createBook("JPA2 BOOK", 20000, 100);
em.persist(book2);
OrderItem orderItem1 = OrderItem.createOrderItem(book1, 10000, 1);
OrderItem orderItem2 = OrderItem.createOrderItem(book2, 20000, 2);
Order order = Order.createOrder(member, createDelivery(member), orderItem1, orderItem2);
em.persist(order);
}
public void dbInit2() {
Member member = createMember("userB", "진주", "2", "2222");
em.persist(member);
Book book1 = createBook("SPRING1 BOOK", 20000, 200);
em.persist(book1);
Book book2 = createBook("SPRING2 BOOK", 40000, 300);
em.persist(book2);
Delivery delivery = createDelivery(member);
OrderItem orderItem1 = OrderItem.createOrderItem(book1, 20000, 3);
OrderItem orderItem2 = OrderItem.createOrderItem(book2, 40000, 4);
Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
em.persist(order);
}
private Member createMember(String name, String city, String street, String zipcode) {
Member member = new Member();
member.setName(name);
member.setAddress(new Address(city, street, zipcode));
return member;
}
private Book createBook(String name, int price, int stockQuantity) {
Book book = new Book();
book.setName(name);
book.setPrice(price);
book.setStockQuantity(stockQuantity);
return book;
}
private Delivery createDelivery(Member member) {
Delivery delivery = new Delivery();
delivery.setAddress(member.getAddress());
return delivery;
}
}
}
참고: 주문 내역 화면에서는 회원당 주문 내역을 하나만 출력했으므로 하나만 노출된다.
@Component
- 스프링에서 Bean을 자동으로 등록하는 annotation
- 스프링이 시작될 때, @Component annotation이 선언된 클래스를 찾아서 인스턴스를 생성하고, 이를 스프링의 애플리케이션 컨텍스트에 bean으로 등록한다.
- 즉, 위의 코드에서 InitDb는 @Component annotation으로 인해 스프링이 시작될 때 자동으로 빈으로 등록된다.
@PostConstruct
- bean이 생성되고 난 후에 실행되어야 하는 메소드에 사용하는 annotation
- bean의 초기화 작업을 수행하도록 사용하며, 생성자 호출 후에 수행된다.
- bean의 생명주기에 따른 특정 작업을 수행하도록 도와주는 역할
- 즉, 위의 코드에서 init() 메소드는 bean이 생성된 후에 자동으로 호출된다.
API 개발 고급 - 지연 로딩과 조회 성능 최적화
주문 + 배송정보 + 회원을 조회하는 API를 만들자!
지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해 보자!
참고: 지금부터 설명하는 내용은 정말 중요한 부분으로, 실무에서 JPA를 사용하려면 100% 이해해야 한다고 한다...
안 그러면 엄청난 시간을 날리고 강사를 원망하면서 인생을 허비하게 된다고 합니다... 화이팅...
간단한 주문 조회 CASE1: Entity를 직접 노출
/**
* xToOne(ManyToOne, OneToOne) 관계 최적화
* Order
* Order -> Member
* Order -> Delivery
*/
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
/**
* CASE1) Entity 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리
* - 양방향 관계 문제 발생 -> @JsonIgnore
*/
@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;
}
}
- Entity를 직접 노출하는 것은 좋지 않다. ("실전! 스프링부트와 JPA 활용2 - API 개발과 성능 최적화 - 1. 회원 API 개발"에서 이미 설명)
- order ▶️ member와 order ▶️ address는 지연 로딩이다. 따라서 실제 entity 대신에 프록시 존재
- jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모름 ➡️ 예외 발생
- Hibernate5Module을 스프링 bean으로 등록하면 해결(springboot 사용 중)
이것들이 다 무슨 의미인지 이해가 잘 가지 않기에 조금 더 자세히 알아보자.
💡 지연 로딩(Lazy Loading)이란?
데이터베이스와 같은 시스템 리소스에 대한 요청을 최대한 늦추는 방식으로, 필요한 시점에만 데이터를 로드하는 방식을 의미한다. 즉, 실제로 데이터를 사용할 때까지 데이터 로딩을 미루는 방식이다.
위의 코드와 같은 경우는 'order.getMember().getName()'이나 'order.getDelivery().getAddress()'를 호출할 때까지 Member나 Delivery 데이터는 로드되지 않는다는 의미다. 이 둘을 호출함으로써 Member와 Delivery 데이터가 로드되는 것이다! 이것을 '강제 초기화' 라고 부르는 것이다!
따라서, 실제 Entity가 아닌 가짜 객체인 프록시가 존재하는 것이다.
즉시 로딩(Eager Loading)이란?
연관된 데이터를 처음 데이터를 로드할 때 함께 로드하는 전략을 의미한다.
예를 들어, 상품 목록을 조회할 때 모든 상품의 리뷰까지 함께 로딩하게 되는 것이다.
이는 연관된 데이터가 항상 필요로 하는 경우, 또는 데이터 사이즈가 작아서 추가적인 로드 비용이 크지 않은 경우에 유용하지만, 즉시 로딩은 성능면에 있어 신중하게 사용해야 함!
Hibernate5JakartaModule 등록(springboot 3.0 이상 기준)
build.gradle에 아래와 같이 라이브러리를 추가하자.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
JpaShopApplication에 다음 코드 내용을 추가하자.
기본적으로 초기화된 프록시 객체만 노출, 초기화되지 않은 프록시 객체는 노출 안 함
@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
return new Hibernate5JakartaModule();
}
만약 다음과 같은 에러가 발생한다면 springboot 버전을 확인해 보는 것이 좋다.
java.lang.ClassNotFoundException: javax.persistence.Transient
다음과 같이 설정하면 강제로 지연 로딩 가능
@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
Hibernate5JakartaModule hibernate5JakartaModule = new Hibernate5JakartaModule();
// 강제 지연 로딩 설정
hibernate5JakartaModule.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true);
return hibernate5JakartaModule;
}
- 이 옵션을 켜면 order ➡️ member, member ➡️ orders 양방향 연관 관계를 계속 로딩하게 된다. 따라서 @JsonIgnore 옵션을 한 곳에 주어야 한다.
⚠️ 주의: Entity를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭! 한 곳을 @JsonIgnore 처리를 해야 한다. 안 그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.
참고: 계속 강조했듯이 정말 간단한 애플리케이션이 아니면 Entity를 API 응답으로 외부로 노출하는 것은 좋지 않다. 따라서 Hibernate5JakartaModule을 사용하기보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
⚠️ 주의: 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EAGER)으로 설정하면 안 된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워진다.
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 fetch join을 사용해라! (CASE3에서 설명)
간단한 주문 조회 CASE2: Entity를 DTO로 변환
/**
* CASE2) Entity를 DTO로 변환
* - 단점: 지연로딩으로 쿼리 N번 호출
*/
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
List<Order> orders = orderRepository.findAll();
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();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}
- Entity를 DTO로 변환하는 일반적인 방법이다.
- 쿼리가 총 1 + N + N번 실행된다. (CASE1과 쿼리수 결과는 같다.)
- order 조회 1번(order 조회 결과 수가 N이 된다.)
- order ➡️ member 지연 로딩 조회 N번
- order ➡️ delivery 지연 로딩 조회 N번
- 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다. (최악의 경우)
- 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
띠용... 이것이 말로만 듣던 N+1 문제인가?
이해가 잘 가지 않으니 조금 더 자세하게 알아보자!
📌 N+1 문제란?
한 번의 쿼리로 불러올 수 있는 데이터를 N번의 쿼리로 불러오는 비효율적인 상황을 의미한다.
위 코드와 같은 상황은
Order 전체 조회 1번 + Order에 대한 Member 조회 1번 + Order에 대한 Delivery 조회 1번 = 총 3번의 쿼리 실행
Order의 결과가 4개라면?
Order 전체 조회 1번 + 각 Order에 대한 Member 조회 1*4번 + 각 Order에 대한 Delivery 조회 1*4번 = 총 9번의 쿼리 실행
Order Entity에 보면 OrderItem도 있는데 그건 왜 조회를 안 할까?
SimpleOrderDto를 생성하는데 필요한 데이터에는 OrderItem이 포함되지 않아 별도로 조회하지 않는 것이다.
간단한 주문 조회 CASE3: Entity를 DTO로 변환 - fetch join 최적화
참고: fetch join에 대한 자세한 내용은 JPA 기본편을 참고하세요! 정말 중요함!
/**
* CASE3) Entity를 조회해서 DTO로 변환(fetch join 사용 O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
*/
@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;
}
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();
}
- Entity를 fetch join을 사용해서 쿼리 1번에 조회
- fetch join으로 order ➡️ member, order ➡️ delivery는 이미 조회된 상태이므로 지연로딩 ❌
💡 fetch join이란?
자세한 내용은 JPA에 대해서 공부하는 게시글에서 기록을 남기겠지만, 간단하게 말해서 연관된 Entity들을 하나의 SQL 문으로 조회해 오는 것을 말한다.
📌 그러면 그냥 SQL의 join문이랑 같은 의미 아닌가?
이 부분은 실제로 테스트해보면서 쿼리문이 몇 번 실행되는지 확인해 볼 필요가 있다. ▶️ 확인 후 글 남기기!
간단한 주문 조회 CASE4: JPA에서 DTO로 바로 조회
아래 OrderSimpleQueryRepository, OrderSimpleQueryDto는 파일을 새로 만들어 추가해 준다.
private final OrderSimpleQueryRepository orderSimpleQueryRepository; // 의존관계 주입
/**
* CASE4) JPA에서 DTO로 바로 조회
* - 쿼리 1번 호출
* - select 절에서 원하는 데이터만 선택해서 조회
*/
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
@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();
}
}
@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 ➡️ 애플리케이션 네트워크 용량 최적화(생각보다 미비)
- repository 재사용성 떨어짐, API 스펙에 맞춘 코드가 repository에 들어가는 단점
정리
Entity를 DTO로 변환하거나, DTO로 바로 조회하는 두 가지 방법은 각각의 장단점이 있다. 둘 중 상황에 따라서 더 나은 방법을 선택하면 된다. Entity로 조회하면 repository 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.
쿼리 방식 선택 권장 순서
- 우선 Entity를 DTO로 변환하는 방법을 선택한다.
- 필요하면 fetch join으로 성능을 최적화한다. ➡️ 대부분의 성능 이슈가 해결된다.
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
나는 Entity 그대로 Return ➡️ Entity를 DTO로 변환 후 Return ➡️ 성능 이슈가 있다면 DTO로 직접 조회 후 Return
이런 식으로 코드를 작성했던 것 같다. fetch join은 정말 생각도 못했다. 이로써 또 하나를 배워간다.
내가 짠 코드를 대입해서 생각하면 정말 이해가 쏙쏙 되지만, 볼 때마다 한없이 부끄럽다...
바로 이어서 다음 글에는 컬렉션 조회 최적화 부분에 대해서 알아보자.
'springboot' 카테고리의 다른 글
[springboot] 실전! 스프링부트와 JPA 활용2 - API 개발과 성능 최적화 - 1. 회원 API 개발 (0) | 2024.02.28 |
---|---|
[springboot] 실전! 스프링부트와 JPA 활용 1 - 5. 웹 계층 개발 (1) | 2023.11.21 |
[springboot] 실전! 스프링부트와 JPA 활용 - 4. 주문 도메인(Order) (0) | 2023.11.14 |
[springboot] 실전! 스프링부트와 JPA 활용 - 3. 상품 도메인(Item) (0) | 2023.11.14 |
[springboot] 실전! 스프링 부트와 JPA 활용 - 2. 회원 도메인(Member) (2) | 2023.11.13 |