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

LeeKyoungChang·2022년 4월 13일
0
post-thumbnail

인프런 수업 강의를 듣고 정리한 내용입니다.

 

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

💡 참고
지금부터 설명하는 내용은 정말 중요합니다. 실무에서 JPA를 사용하려면 100% 이해해야 한다.
안그러면 엄청난 시간을 날리고 강사를 원망하면서 인생을 허비하게 된다.

 

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

OrderSimpleApiController


/*
 * xToOne(ManyToOne, OneToOne)
 * Order
 * Order -> Member (ManyToOne)
 * Order -> Delivery (OneToOne)
 */

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;
	
	/**
    * V1. 엔티티 직접 노출
	* - Hibernate5Module 모듈 등록, LAZY=null 처리 
	* - 양방향 관계 문제 발생 -> @JsonIgnore
	*/

    @GetMapping("/api/v1/simple-orders")
    public List<Order> orderV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }
}

위와 같이 코드를 작성한 뒤, http://localhost:8080/api/v1/simple-orders로 접속하면 다음과 같이 엔티티가 무한으로 호출되는 결과를 확인할 수 있다.

스크린샷 2022-04-12 오후 4 56 05 스크린샷 2022-04-12 오후 5 01 31

 

Order (클래스)에서 (JSON)Member를 다대일 관계로 간다.
그런데 Member에서도 일대다 관계로 Order로 간다.
Order → Member → Order → ...를 무한 반복한다.

스크린샷 2022-04-18 오후 6 09 41 스크린샷 2022-04-18 오후 6 11 02

이는 ordermember가 서로를 참조하기 때문에 무한순회를 한다. (양방향 연관관계에서 발생하는 문제점이다.)

 

✔️ 양방향 연관관계 무한순회를 방지하기 위해서는 JsonIgnore를 추가해주어야 한다.

Member.java

    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

 

OrderItem.java

    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;	// 주문

 

Delivery

    @JsonIgnore
    @OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
    private Order order;

위와 같이 @JsonIgnore를 추가해보자!

  • 양방향으로 걸리는 곳에 @JsonIgnore을 추가해야 한다.
  • 둘 중 하나를 끊어야 한다. Json생성할 때, JsonIgnore이면 반대쪽으로는 실행하지 않는다.

 

다시 http://localhost:8080/api/v1/simple-orders로 접속하면 다음과 같이 다른 문제가 발생한다.

스크린샷 2022-04-12 오후 5 09 02 스크린샷 2022-04-12 오후 5 09 35

ByteBuddyInterceptor에서 Type definition error가 발생하였다.

  • ordermember, orderaddress지연 로딩이므로 실제 엔티티가 아닌 프록시 객체가 존재한다.
  • jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모른다. → 예외 발생
  • Hibernate5Module을 스프링 빈으로 등록하면 해결할 수 있다. (스프링 부트 사용중)

 

✔️ Hibernate5Module 등록
build.gradle에 다음과 같은 라이브러리를 추가한다. (뒤에 버전을 적지 않으면, 자동으로 최신버전을 등록해준다.)

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

 

JpashopApplication (main)에 다음 코드를 추가하자!

@Bean
Hibernate5Module hibernate5Module() {
    return new Hibernate5Module();
}
  • 기본적으로 초기화된 프록시 객체만 노출된다.
  • 초기화 되지 않은 프록시 객체는 노출하지 않는다.

 

스크린샷 2022-04-12 오후 5 21 19
  • 잘 출력된다.

 

다음과 같이 설정하면 강제로 지연 로딩 가능하다. (지연 로딩을 한 번에 다 가져온다.)

@Bean
	Hibernate5Module hibernate5Module() {
		Hibernate5Module hibernate5Module = new Hibernate5Module(); //강제 지연 로딩 설정
		hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
		return hibernate5Module;

	}
  • 이 옵션을 켜면 order → member, member → order 양방향 연관관계를 계속 로딩하게 된다. 따라서 @JsonIgnore 옵션을 한 곳에 주어야 한다.

 

Lazy 강제 초기화

    private final OrderRepository orderRepository;

    /**
     * V1. 엔티티 직접 노출
     * - Hibernate5Module 모듈 등록, LAZY=null 처리
     * - 양방향 관계 문제 발생 -> @JsonIgnore
     */

    @GetMapping("/api/v1/simple-orders")
    public List<Order> orderV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName(); //Lazy 강제 초기화
            order.getDelivery().getAddress(); //Lazy 강제 초기화
        }
        return all;
    }
  • 엔티티를 직접 노출하는 것은 좋지 않다.
  • ordermemberorderaddress 는 지연 로딩이다. 따라서 실제 엔티티 대신에 프록시 객체가 존재한다.

 

📌 정리
(1) 양방향 연관관계인 경우, 한 곳을 @JsonIgnore 처리해주기

  • 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 한 곳을 꼭 @JsonIgnore 처리해야 한다!
  • 그렇지 않으면 양쪽을 서로 호출하면서 무한 루프가 걸린다.

 

(2) 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다.

  • Hibernate5Module를 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다!
  • 엔티티를 그대로 노출하면 나중에 엔티티가 바뀌면 API 스펙이 다 바뀐다.

 

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

  • 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다.
  • 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라! (다음에 나오는 V3에 배운다!)

 

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

OrderSimpleApiController - 추가

    /**
     * V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
     * - 단점: 지연로딩으로 쿼리 N번 호출
     */
    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        // ORDER 2개
        // N + 1 -> 1 + 회원 N + 배송 N
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());

        List<SimpleOrderDto> result = orders.stream()
                .map(SimpleOrderDto::new)
                .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) {
            this.orderId = order.getId();
            this.name = order.getMember().getName(); // LAZY 초기화
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress(); // LAZY 초기화
        }
    }
  • 엔티티를 DTO로 변환하는 일반적인 방법이다.

 

실행 결과

스크린샷 2022-04-12 오후 5 47 59

 

📖 A. 1 + N + N 번 실행된다.

첫번째 쿼리 결과 N번에 추가로 N번 실행되는 것

(1) sql 1번 실행

List<Order> orders = orderRepository.findAllByString(new OrderSearch());  // 2개 조회된다.
스크린샷 2022-04-19 오후 1 02 45
  • 현재 2개가 조회된다.

 

(2) 루프를 돈다. - 1 (총 2회)
스크린샷 2022-04-19 오후 1 08 34

  • orders에는 2개가 들어가 있다.
  • SimpleOrderDto를 호출한다.

 

SimpleOrderDto에서는
스크린샷 2022-04-19 오후 1 08 50

  • Lazy 초기화되며, 주문한 멤버를 찾아야 한다.

  • Member select문 실행
    스크린샷 2022-04-19 오후 1 02 50

  • Lazy 초기화되면서, 주문한 배달 업체를 찾아야 한다.

  • Delivery select문 실행
    스크린샷 2022-04-19 오후 1 02 54

 

(3) 다시 루프를 돈다. - 2
스크린샷 2022-04-19 오후 1 08 34

  • orders 마지막 한 개를 실행한다.

 

SimpleOrderDto에서는
스크린샷 2022-04-19 오후 1 08 50

  • Lazy 초기화되며, 주문한 멤버를 찾아야 한다.
  • Member select문 실행
    스크린샷 2022-04-19 오후 1 02 59
  • Lazy 초기화되면서, 주문한 배달 업체를 찾아야 한다.
  • Delivery select문 실행
    스크린샷 2022-04-19 오후 1 03 03

➡️ 총 5번 실행된다.

 

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

  • order 조회 1번(order 조회 결과 수가 N이 된다.)
  • order -> member 지연 로딩 조회 N 번
  • order -> delivery 지연 로딩 조회 N 번
  • 예) order의 결과가 2개면 최악의 경우 1 + 2 + 2번 실행된다. (최악의 경우)
    • 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.

➡️ Order 조회가 많아질수록 쿼리 수행이 많아지고 성능 저하가 일어날 수 있다!

 

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

OrderSimpleApiController - 추가

	/**
     * V3. 엔티티를 조회해서 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(SimpleOrderDto::new)
                .collect(Collectors.toList());
        return result;
    }

 

OrderRepository - 추가 코드

    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();
    }
  • findAllWithMemberDelivery 메서드를 호출하면서, select 절이 실행될 때 쿼리 1번으로 연관된 엔티티나 컬렉션을 한 번에 같이 조회한다.
  • 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회한다.
  • 페치 조인으로 order -> member, order -> delivery는 이미 조회 된 상태이므로 지연로딩이 발생하지 않는다.

 

실행 결과

스크린샷 2022-04-12 오후 6 26 35 스크린샷 2022-04-12 오후 6 37 56

이전 V2는 쿼리가 5번 실행되었는데, V3에서는 쿼리가 한 번만 실행된다.

페치 조인은 적극적으로 활용해야 한다.

 

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

OrderSimpleApiController - V4 추가

	private final OrderSimpleQueryRepository orderSimpleQueryRepository; //의존관계

    /**
     * V4. JPA에서 DTO로 바로 조회
     * - 쿼리 1번 호출
     * - select 절에서 원하는 데이터만 선택해서 조회
     */
    @GetMapping("/api/v4/simple-orders")
    public List<OrderSimpleQueryDto> ordersV4() {
        return orderSimpleQueryRepository.findOrderDtos();
    }

 

order/simplequery/OrderSimpleQueryRepository 생성 - 조회 전용 리포지토리

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {

    private final EntityManager em;


    public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
                        "select new csjpabook.csjpashop.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();
    }
}
  • new를 이용해서 객체를 생성할 때 full package path를 입력해야 한다. (생성자 매개변수 타입과 순서가 같아야 한다.)

 

order/simplequery/OrderSimpleQueryDto 생성 - 리포지토리에서 DTO 직접 조회

@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 스펙에 맞춘 코드가 리포지토리에 들어가는 단점이 있다.

 

실행 결과

V3 실행 결과
스크린샷 2022-04-12 오후 6 57 45

 

V4 실행 결과
스크린샷 2022-04-12 오후 6 57 19

  • 원하는 것만 select문이 실행되었다.
    • 직접 쿼리를 만들었기 때문이다.
  • v4가 v3보다 성능상 최적화 되어있다.
  • 다만, v4는 코드가 복잡하다. (재사용성이 좋지 않다.)

 

postman 실행 결과

스크린샷 2022-04-12 오후 7 15 46

 

📌 정리

  • 엔티티를 DTO로 변환하거나(V3), DTO로 바로 조회하는(V4) 두가지 방법은 각각 장단점이 있다.
  • 둘중 상황에 따라서 더 나은 방법을 선택하면 된다.
    • 원하는 테이블 전체적으로 조회하고 싶을 때는(외부 건드리지 않고) V3(엔티티를 DTO로 변환)하는 방법을 사용한다.
    • 필요한 필드, 데이터만 조회하고 싶을 때는(외부 건드려서 재사용성 좋지 않다.) V4(DTO로 바로 조회)하는 방법을 사용한다.
  • 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다.

 

🔔 쿼리 방식 선택 권장 순서
1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다. : V2
2. 필요하면 페치 조인으로 성능을 최적화한다. → 대부분의 성능 이슈가 해결된다. : V3
3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다. : V4
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글