제육's 휘발성 코딩
article thumbnail
반응형

조회용 샘플 데이터 추가

image

  • 다음과과 같이 샘플 데이터를 만들어두자.
    • userA 는 JPA1 BOOK1, JPA2 BOOK2 주문
    • userB 는 SPRING1 BOOK, SPRING2 BOOK 주문
package jpabook.jpashop;

import jpabook.jpashop.domain.*;
import jpabook.jpashop.domain.item.Book;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.PostConstruct;
import javax.persistence.EntityManager;

@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;
        }
    }
}
  • 조회용 샘플 데이터 소스코드는 다음과 같다.

지연 로딩과 조회 성능 최적화

  • 주문 + 배송정보 + 회원을 조회하는 API를 만들어보며, 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해보자.

주문 조회 V1

import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderSearch;
import jpabook.jpashop.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@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().getOrders(); // Lazy 강제 초기화
            order.getDelivery().getAddress(); // Lazy 강제 초기화
        }
        return all;
    }
}
  • V1 상황 : DTO변환을 사용하지 않은 경우
    • Order 는 양방향연관관계로 지연 로딩 설정이 되어 있다. 따라서 실제 엔티티가 아닌 프록시로 존재한다. 현재 상황에서 조회를 하게되면, 무한루프 (서로의 컬렉션이 계속 조회)가 발생한다.
    • 양방향 연관관계에서 한 쪽에 @JsonIgnore 설정을 해줘야한다.
    • jackson 라이브러리는 프록시 객체를 json으로 생성하는 방법을 모르기 때문에 Hibernate5Module 을 스프링 빈으로 등록하여 초기화 된 프록시 객체만 노출 시켜야한다.
@JsonIgnore
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
  • 다음과 같이 양방향 연관관계에서 한 쪽을 @JsonIgnore 설정을 해줘야 한다.

Hibernate5Module (참고)

Hibernate5Module의 방법은 DTO가 아닌 실제 엔티티를 반환하기 위해 사용하는 라이브러리이다. 실무에선 DTO를 사용하여 반환하기 때문에 해당 내용은 참고만 하자.

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
  • build.gradle에 라이브러리 추가
package jpabook.jpashop;

import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class JpashopApplication {

    public static void main(String[] args) {
        SpringApplication.run(JpashopApplication.class, args);
    }

    @Bean
    Hibernate5Module hibernate5Module() {
        Hibernate5Module hibernate5Module = new Hibernate5Module();
//        hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
        return hibernate5Module;
    }
}
  • Hibernate5Module를 빈으로 등록한다. 주석 처리를 한 부분에서 강제로 지연 로딩 설정을 할 수 있다.

양방향 연관관계인 경우 무한 루프 방지를 위해 한 곳을 @JsonIgnore 처리 해야 한다.

지연 로딩을 피하기 위해 즉시 로딩으로 설정하면 안된다. (오히려 성능 상의 문제 발생 및 성능 튜닝이 매우 어려워진다.)

지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우엔 페치 조인을 사용하자.

주문 조회 V2

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
  List<Order> orders = orderRepository.findAllByString(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 초기화 - 영속성컨텍스트에 없으므로 Member를 조회 후 저장
    orderDate = order.getOrderDate();
    orderStatus = order.getStatus();
    address = order.getDelivery().getAddress(); // LAZY 초기화
  }
}
  • 조회 결과를 위한 DTO를 생성했다. 하지만 로그에 찍힌 쿼리 내용을 보면 이상한 점이 있다.
  • 로그 내용 : order 조회 - member 조회 - delivery 조회 - member 조회 - delivery 조회
    • LAZY 초기화 되는 부분에서 member 와 delivery 조회는 당연하다. 하지만 존재하는 데이터 갯수만큼 반복적으로 LAZY 초기화하는 것을 볼 수 있다. (N+1 문제 발생

주문 조회 V3

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();
}
  • 페치 조인을 적용하기 위해 OrderRepository 에 해당 메서드를 추가하자.
@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;
}
  • 페치 조인을 적용해서 로그를 확인해보면 하나의 쿼리문으로 성능이 최적화 된 것을 확인 할 수 있다.

 

주문 조회 V4

package jpabook.jpashop.repository.order.simplequery;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;

import java.time.LocalDateTime;

@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;
    }
}
  • 필요한 데이터를 사용하기 위해 별도의 DTO를 만든다.
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
  return orderSimpleQueryRepository.findOrderDtos();
}
  • DTO 형식의 리스트를 반환하도록 만든다.
package jpabook.jpashop.repository.order.simplequery;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

@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();
    }
}
  • 엔티티가 아닌 DTO를 반환하는 레포지토리는 별도로 생성하자.
    • 레포지토리는 엔티티를 조회하는 용도로 사용되며, 성능 최적화를 위해 DTO를 사용한다면 별도의 레포지토리를 생성하는 것이 올바르다.
    • DTO를 select 하기 위해 new 연산자를 사용해서 불러온다.

 

V3 vs V4

image

성능 상으로는 V4가 좋지만, 리포지토리의 재사용성이 떨어지고 API 스펙에 맞춘 코드가 리포지토리에 들어가게 된다.

<쿼리 방식 선택 권장 순서>

DTO 변환 - V2

필요하면 페치 조인으로 성능 최적화 (대부분의 이슈 해결) - V3

해결이 안된 경우 DTO로 직접 조회하는 방법 사용 - V4

최후의 방법으로 네이티브 SQL 이나 JDBC Template을 사용해서 직접 SQL 사용


본 포스팅은 인프런 김영한님 강의(실전! 스프링 부트와 JPA 활용2)를 토대로 정리한 내용입니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^