인프런/실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

2) API 개발 고급 - 지연 로딩과 조회 성능 최적화

backend dev 2024. 6. 10.

조회용 샘플 데이터 입력

 

/**
 * 종 주문 2개
 * * 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() {
            System.out.println("Init1" + this.getClass());
            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);

            Delivery delivery = createDelivery(member);
            Order order = Order.createOrder(member, delivery, 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);

            OrderItem orderItem1 = OrderItem.createOrderItem(book1, 20000, 3);
            OrderItem orderItem2 = OrderItem.createOrderItem(book2, 40000, 4);

            Delivery delivery = createDelivery(member);
            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 book1 = new Book();
            book1.setName(name);
            book1.setPrice(price);
            book1.setStockQuantity(stockQuantity);
            return book1;
        }

        private Delivery createDelivery(Member member) {
            Delivery delivery = new Delivery();
            delivery.setAddress(member.getAddress());
            return delivery;
        }
    }
}

 

 

API 개발 고급 - 지연 로딩과 조회 성능 최적화

참고

지금부터 설명하는 내용은 정말 중요합니다. 실무에서 JPA를 사용하려면 100% 이해해야 합니다.

안그러면 엄청난 시간을 날리고 강사를 원망하면서 인생을 허비하게 됩니다.

 

 

간단한 주문 조회 V1: 엔티티를 직접 노출

 

양방향 연관관계로 인해 순환참조에러가 발생하므로 

오더와 멤버 중 하나

오더와 딜리버리 중 하나

@JsonIgnore

를 붙여서 순환참조에러를 방지한다.

 

 

/**
 * xToOne(ManyToOne, OneToOne) 관계 최적화
 * Order 는 Member , Delivery 와 연관
 * Order -> Member
 * Order -> Delivery
 */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository; // 서비스가 로직없이 그저 전달만해준다면 컨트롤러에서 바로 repository를 가져와서 사용해도 된다.

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());// 조건이 비어있으면 모든 데이터 조회한다.
        return all;
    }
}

해당 예외가 발생하는 이유는

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;

멤버필드가 지연로딩 설정이 되어있다. [ 프록시객체가 들어가게 되고 그건 ByteBuddyIntercetpro객체이다.]

그래서 Json으로 변환해서 반환하려고하는데 실제 멤버가 아닌 프록시객체가 있다.
jackson 라이브러리는 해당 프록시객체를 JSON으로 변환하는법을 모르니까 에러가 발생하는것.

 

하이버네이트 모듈 라이브러리를 추가하고

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
@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.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);

이 부분이 없다면

 

다음과 같이 프록시객체가 들어가는 부분은 null로 처리된다.

 

강제 지연 로딩을 설정한다면 JSON을 생성하는 시점에 프록시 초기화를 해서 값을 넣어준다.

 

 

저 옵션을 사용안하고 프록시를 초기화하고 싶다면 

@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;
}

다음과 같이 코드상으로 프록시객체를 초기화해주면 된다.

 

엔티티를 직접 노출하는 것은 좋지 않다. (앞장에서 이미 설명)

order member 와 order delivery 는 지연 로딩이다.

따라서 실제 엔티티 대신에 프록시 존재 jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로

어떻게 생성해야 하는지 모르니까 예외 발생

Hibernate5Module 을 스프링 빈으로 등록하면 해결(스프링 부트 사용중)

 

[ 어차피 엔티티를 사용했을때 발생하는 문제라서, DTO를 사용하면 다 해결된다.]

 

엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은

꼭! 한곳을 @JsonIgnore 처리 해야 한다. 안그 러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.

 

앞에서 계속 강조했듯이 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다

따라서 Hibernate5Module 를 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.

 

지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다!

즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다.

즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다.

항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라!(V3에서 설명)

 

결과를 보면 알수있듯이 , 필요한 정보만 노출되는것이 아닌 모든 정보가 노출되고있다.

 

간단한 주문 조회 V2: 엔티티를 DTO로 변환

/**
     * V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
     * - 단점: 지연로딩으로 쿼리 N번 호출
     */
    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        return orders.stream().map(m -> new SimpleOrderDto(m)).collect(Collectors.toList());
    }

    @Data
    static class SimpleOrderDto{ // Dto 가 엔티티를 의존하는것은 문제가 되지않는다. 중요하지않은곳에서 엔티티를 의존하는것이기 때문에
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDto(Order order) { // 엔티티를 DTO로 변환하는 생성자
            orderId = order.getId();
            name = order.getMember().getName(); // LAZY 초기화 == 프록시 객체 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress(); // LAZY 초기화 == 프록시 객체 초기화
        }
    }

 

엔티티를 DTO로 변환하는 일반적인 방법이다.

 

쿼리가 총 1 + N + N번 실행된다. (v1과 쿼리수 결과는 같다.)

 

order 조회 1번 (order 조회 결과 수[==조회의 결과 로우 == 조회된 데이터갯수]가 N이 된다.)

order -> member 지연 로딩 조회 N 번 ->  name = order.getMember().getName();로 인해 LAZY 초기화될때

 

order -> delivery 지연 로딩 조회 N 번 -> address = order.getDelivery().getAddress(); 로 인해 LAZY 초기화될때

 

[ 만약 모든 오더조회의 결과가 1개의 데이터라면 모든 오더조회 1번 + 해당 오더와 관련있는 멤버조회 1번[다대일 매핑이므로] + 해당 오더와 관련있는 딜리버리 조회 1번 [ 일대일 매핑이므로] 으로 총 3번이지만

 

만약 모든 오더조회의 결과가 n개라면

모든 오더조회 1번 + n개의 오더와 관련있는 멤버조회 n번 + n개의 오더와 관련있는 딜리버리조회 n번  해서 1 + N + N 의 문제가 발생한다. => n +1 문제

[ 한번의 쿼리로 N번이라는 추가적인 쿼리가 실행되는 문제 ]

[ LAZY , EAGER 둘다 N +1 문제는 발생한다. ]

 

예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.(최악의 경우)

지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.

[ 물론 LAZY 경우 이미 조회해서 영속상태인 엔티티는 1차캐시에서 가져오므로 쿼리를 생략한다. LAZY의 경우 초기화 될떄 해당 엔티티를 조회해오므로, 이미 멤버가 영속상태라면 쿼리 생략

최악의 경우 N번이라는것 ,

EAGER는 한번에 JOIN해서 가져오므로 이미 가져온 ORDER가 아니라면 쿼리를 생략하지 않는다.
이미 해당 멤버가 1차캐시에 존재하더라도 어차피 JOIN하므로 ]

 

 

간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

OrderSimpleApiController - 추가

@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    return orders.stream().map(m -> new SimpleOrderDto(m)).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();
}

fetch join[페치 조인]을 사용하게되면 오더의 멤버와 딜리버리가 LAZY(지연로딩)으로 되어있어도

진짜 엔티티를 가져와서 값을 채워버린다.(프록시객체x, 지연로딩 무시)

fetch join으로 인해 쿼리가 한번만 나가는것을 확인할 수 있다.

fetch join을 사용하기전에는 1+N개의 쿼리가 나갔지만

fetch join을 사용함으로써 1개의 쿼리만 발생했다.

 

엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회

페치 조인으로 order -> member , order -> delivery 는 이미 조회된 상태 이므로 지연로딩 발생 X[

[ 멤버와 딜리버리에는 프록시객체가 아닌 진짜 엔티티가 들어있다.]

 

 

하지만 쿼리를보면 DTO에 필요없는 정보까지 조회해오는것을 확인할 수 있다.

 

간단한 주문 조회 V4: JPA에서 DTO로 바로 조회

/**
     * V4. JPA에서 DTO로 바로 조회
     * - 쿼리 1번 호출
     * - select 절에서 원하는 데이터만 선택해서 조회
     */
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
   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.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에 필요한 정보면 조회해오는것을 확인할 수 있다.

 

- 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회

 

- new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환

 

- SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화(생각보다 미비)

 

- 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점

 

정리

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다.

둘중 상황에 따라서 더 나은 방법을 선택하면 된다.

엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다.

따라서 권장하는 방법은 다음과 같다.

 

쿼리 방식 선택    권장 순서

1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.

2. 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.

3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.

4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.

 

[ 조회하는 컬럼 몇개 추가하는것은 성능에 그렇게 영향을 주지않는다. 인덱스와 같은 where 절쪽 문제가 성능에 더 영향을 준다. 하지만 매번 조회해야하는 쿼리에서 컬럼이 너무많다면 fit한 DTO를 만들어서 사용하는것이 좋을것이다.]

 

만약 DTO를 직접 조회하는 경우를 사용해야한다면

다음과 같이 패키지 구조를 정리한다.

 

OrderRepository는 가급적 순수한 엔티티를 조회하는데 사용한다. (Order와 같은) 

OrderRepository는 재사용도 되고 Order엔티티를 사용한다는것이 명확하다.

[ OrderRepository에는 Order만 사용하는것을 두자.]

 

하지만 DTO를 조회해야할때는 별도로 패키지를 빼서 관리하는것이 유지보수성이 증가한다.

 

지금까지는 xToOne ( ManyToOne, OneToOne) 이였는데

다음에는 xToMany에 대해 알아보자. [ 컬렉션 조회 최적화]

 

댓글