[인프런][JPA2][섹션4] 1,2,3,4강 복습

mutexlocking·2022년 7월 9일
0

섹션3이 LAZY 로딩으로 연관된 특정 Entity를 조회할 때의 최적화와 관련된 장이었다면,
섹션4는 LAZY 로딩으로 연관된 컬렉션을 조회할 때의 최적화와 관련된 장이다.

먼저 [섹션4] 에서 조회할 데이터의 구조를 보고 , 이를 최적화해 나가는 방향에 대해 각 버전으로 설명한다.

  1. 우리의 예제 애플리케이션에서 사용되는 데이터의 구조
  • 이런 ERD 하에 , [섹션 3]에서는 Order를 조회하면서 연관된 Member,Delivery 를 어떻게 최적화 하여 조회할 것인가에 대해 공부했음.
  • 이번 [섹션4]는 Order를 조회하면서 연관된 OrderItem 컬렉션 조회는 어떻게 하는것이 좋으며 - 나아가 Order와 연관된 Member,Delivery,OrderItem컬렉션을 모두 조회할 때 -
    성능 최적화를 하면서도 && 페이징은 어떻게 수행할 것인가
    에 대해 공부할 것

[API 요구사항]
: 모든 주문에 대하여 , 주문과 관련한 정보를 조회하기

  • 즉 Order를 조회하면서
  • Order와 관련된 Member, Delivery, OrderItem 컬렉션 , Item 을 함께 접근하여 , 이들을 통해 주문과 관련한 정보를 추출해야 함.

이제 위 요구사항에 따라 API를 버전을 높여가며 최적화 해보자.

[버전1]
: Entity를 직접 Response에 노출

  • 실제로는 사용하면 안되는 방법
    -> 왜냐하면 Entity 정보가 달라지면 , API 스펙이 변하기 때문
  • 그럼에도 굳이 Entity를 직접 노출해야 한다면 , Entity를 무사히 JSON으로 변환시켜야 하므로
    -> JSON 변환시 양방향 연관관계에 의해 무한루프에 걸리지 않도록 한곳에 @JsonIgnore를 추가해야 하고
    -> JSON으로 무사히 변환 되도록 , Hibernate5Module을 스프링 빈으로 추가해야 한다.

이를 구현한 코드는 아래와 같다.

/**
     * <Order 정보를 통해 , 모든 OrderItem  Item 조회 API>
     *
     *  [버전1]
     *  : Entity를 직접 노출
     *
     *  [문제점]
     *  : 당연히 Entity가 직접 노출되니깐, Entity가 변하면 -> API 스펙이 변한다
     *
     *  => 사실 이렇게 API를 만들면 안된다는 것도 알고, 이렇게 안만들 거니깐, 읽고 넘어가자
     * */
    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1(){
        List<Order> all = orderRepository.findAll();

        for (Order order : all) {
            order.getMember().getName();      // Order Entity와 Lazy 로딩으로 연관된 Member Entity를 강제로 가져오게 함 (강제 초기화)
            order.getDelivery().getAddress(); // Order Entity와 Lazy 로딩으로 연관된 Delivery Entity를 강제로 가져오게 함 (강제 초기화)
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream()
                    .forEach(o -> o.getItem().getName()); //  Order Entity와 Lazy 로딩으로 연관된 각각의 OrderItem Entity들을 강제로 가져오게 함 (강제 초기화)
        }

        return all;
    }

[버전2]
: 필요한 정보를 DTO에 담아서 반환하자

  • 이번 버전에서는 Entity를 그대로 Response에 노출시키지 않고, 필요한 필드값만 추출하여 DTO에 담아서 응답을 보낸다.
  • 실제로도 Entity와 API 스펙을 독립적으로 분리시키기 위해 이러한 DTO를 사용하여 API를 개발해야 한다는 점을 명심하자.
  • 그러나 버전2의 한계점은 성능이 나오지 않는다는 점이다.
    -> 조회한 Order Entity에서 연관된 Entity (Member, Delivery) , 연관된 컬렉션 (List< OrderItem >) 에 접근할 때
    -> LAZY 로딩으로 인해 의도지 않은 쿼리가 나가게 되는데
    -> 이때 너무 많은 쿼리가 나가서 성능이 나오지 않는다.
    -> 따라서 버전 3에서는 페치조인을 써서 , 연관된 Entity 및 컬렉션을 한방쿼리로 조회함으로 써 , 성능을 높이도록 개선해 보이겠다.

어쨋든 버전2로 개선한 API 코드는 아래와 같다.

/**
     * <Order 정보를 통해 , 모든 OrderItem  Item 조회 API>
     *
     *  [버전2]
     *  : DB에서 꺼내온 Entity를 대상으로 -> 필요한 정보만을 꺼내 DTO에 담은 후 -> DTO를 반환
     *
     *  */

    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2(){
        List<Order> orders = orderRepository.findAll();
        List<OrderDto> orderDtoList = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return orderDtoList;
    }
    
     @Data
    static class OrderDto{
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItems;

        public OrderDto(Order order){
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream()
                    .map(oi -> new OrderItemDto(oi))
                    .collect(Collectors.toList());
        }


    }

    @Data
    static class OrderItemDto{
        private String itemName; //상품명
        private int orderPrice;  //주문 가격
        private int count;       //주문 수량


        public OrderItemDto(OrderItem orderItem){
            itemName = orderItem.getItem().getName();
            orderPrice = orderItem.getOrderPrice();
            count = orderItem.getCount();
        }
    }

<버전2 에서 주목할 점>
: Entity를 DTO로 변환하는 버전2에서 주목할 점이 있다.

  • 바로 DTO안의 값을 , "온전히 Entity에 독립적이게" 만들어야 한다는 점이다.
    -> 버전2에서 Entity를 DTO로 변환하는 방식을 보면
    -> DTO의 생성자로 Entity를 받아서, 필요한 정보만을 DTO에 담는 방식을 취하고 있다.

  • 이때 주목해야 할 점은 Order와 연관된 OrderItem,Item의 정보를 어떤 방식으로 DTO에 담을 것인가 이다.
    -> 여기서 만약 OrderDTO의 생성자 안에서 Order를 받아 단순히 OrderItem 및 Item을 그대로 DTO에 넣는다면
    -> OrderDTO안에는 OrderItem이라는 Entity가 그대로 남아있게 되고
    -> 추후에 OrderItem 이 변할 경우 API 스펙이 변하게 된다.

  • 따라서 위의 코드와 같이 Order와 연과된 OrderItem 또한 별도의 DTO로 감싸서 필요한 정보만을 추출하여 OrderDTO안에 담는 방식을 써야 한다.
    -> 핵심은 Entity와 DTO를 완전히 분리하기!

[버전3]
: 예고한 대로 , 페치 조인을 써서 조회할 Entity와 연관된 Entity 및 컬렉션을 한방쿼리로 가져오자

  • 즉 Order를 조회하면서 , 이와 연관된 Member, Delivery, OrderItem컬렉션, Item을 페치조인을 써서 한방쿼리로 가져오는 방법이다.
  • 이때 컬렉션 페치조인의 경우 , Order 입장에서 row수가 늘어나는 데이터 뻥튀기 문제가 발생하므로
    -> distinct 키워드를 JPQL에 사용하므로써 중복된 Entity를 제거해줘야 한다.

이를 아래와 같은 코드로 구현하였다.

/**
     *  <Order 조회>
     *      : Fetch Joing으로 연과된 모든 Entity들을 함께 조회하도록
     *
     *      [주의]
     *      1) 컬렉션이 아닌, Entity의 경우, 둘 이상의 Entity와 함께 페치 조인 가능
     *      2) 단 컬렉션과 페치조인할 경우, 데이터 뻥튀기 문제 때문에 둘 이상의 컬렉션과 페치 조인 불가능
     *      3) 단 1)과 2)는 동시에 가능 -> 즉 둘 이상의 Entity의 && 하나의 컬렉션과는 동시에 페치 조인 가능.
     *      4) 심지어 페치조인 하는 OrderItem들과 연관된 각각의 Item들도 함께 페치 조인으로 조회해올 수 있음!! (이런것 까지 되다니.. )
     * */
    public List<Order> findAllWithItem(){
        return em.createQuery("select distinct o From Order o join fetch o.member m join fetch o.delivery d join fetch o.orderItems oi join fetch oi.item i", Order.class)
                .getResultList();
    }
    
     /**
     * <Order 정보를 통해 , 모든 OrderItem  Item 조회 API>
     *
     *  [버전3]
     *  : Entity를 조회하여 DTO에 담을 때 , Fetch Join으로 Entity를 조회하여 - 한 쿼리안에 필요한 Entity들을 최대한 많이 가져오자
     *
     *  - 단 이때 OneToOne, ManyToOne 뿐만 아니라, OneToMany의 Order-OrderItems 가 있으니, Fetch Join시 데이터 뻥튀기를 주의하자
     *
     *  */

    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3(){

        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDto> orderDtoList = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return orderDtoList;

    }

버전 3에 대한 구현 코드를 작성하면서 ,
페치조인을 사용할 때 주의할 사항을 아래와 같이 정리하였다.

<버전3에서 주목할 부분>
1. Entity 페치조인시 , 한번에 여러 Entity를 함께 페치조인 할 수 있다.
그러나 컬렉션 페치조인시에는 "데이터 뻥튀기" 문제로 인해 한번에 하나의 컬렉션하고만 페치조인이 가능하다.
-> 실제로 Member와 Delivery를 한번에 페치조인하여 가져옴

  1. 둘 이상의 Entity와 , 하나의 컬렉션을 , 함께 페치조인으로 조회할 수 있다.
    -> 실제로 Member와 Delivery 및 OrderItem컬렉션을 함께 페치조인 으로 가져오고 있다.
  1. 별칭을 조회용으로만 사용한다면, 페치 조인의 대상에 별칭을 부여하여 이를 활용할 수 있다.
    -> 실제로 OrderItem에 별칭을 부여하여 , OrderItem과 연관된 Item을 함께 페치조인으로 조회하고 있다.
    -> 이는 실제로 여러 OrderItem과 연관된 각각의 Item을 함께 InnerJoin으로 가져오게 되며
    -> 이런것 까지 가능할 줄은 몰랐다!!
  • 단 , 페치조인의 대상에 별칭을 붙였을 때 이를 조회용으로만 사용해야지 /
    on, where 문에서 필터링 조건으로 사용하면 안된다.

    (엔티티와 DB간 일관성이 깨지기 때문)
  1. 컬렉션 페치조인시 데이터 뻥튀기 문제를 해결하고자 JPQL에 distinct 키워드를 추가한다
  • 이는 번역된 SQL에도 그대로 distinct가 추가되지만
    이에 따라 SQL로 조회되는 row들간의 중복이 제거되지는 않는다.

    (왜냐하면 모든 컬럼이 같아야 distinct에 의해 제거되는데,
    모든 컬럼이 같지는 않기 때문)
  • 오직 JPQL 레벨에서만 조회되는 Entity들간의 중복을 제거해주는 효과가 있다.
  • 그래서 컬렉션 페치조인시에는 페이징이 불가능하다
    -> 왜냐하면 SQL 레벨의 중복된 row들이 제거가 안되니
    -> 이 중복된 row들을 대상으로 페이징을 해봤자 의미가 없기 때문
    (Order을 대상으로 페이징 해야 하는데,
    OrderItem을 대상으로 페이징 하는 꼴)
  • 또한 컬렉션 페치조인시 페이징을 강제로 수행하면 하이버네이트는 해당 엔티티으이 모든 row를 메모리에 올려놓고 메모리상에서 페이징을 수행하는데
    -> 이때 메모리를 초과하면 , 메모리가 뻑갈 수 있다..
    -> 그래서 결론적으로 컬렉션 페치조인시 페이징을 하면 안된다!

즉 연관된 컬렉션을 함께 페치조인으로 가져오면 성능 최적화가 끝날 줄 알았지만,
페이징이 안되는 문제가 존재하였다.
따라서 섹션4에서는 결론적으로 성능최적화도 되면서 && 페이징이 가능한 버전3.1의 방법을 궁극적으로 배우게 된다.
실제로 이 방법을 미리 알았더라면 지난학기 졸업 프로젝트 때 더 효율적인 코드를 작성 할 수 있을 것 같았는데 아쉽다는 생각이 들었으며, 이후 프로젝트 때 이 방법을 적극 활용해야겠다고 생각했다.

[버전3.1]
: 페이징과 한계 돌파
"연관된 Entity들 및 컬렉션을 최적화 하여 조회하면서 && 동시에 페이징도 가능하게 하자"

  • Step1. 일단 연관된 Entity들은 페치조인으로 조회하자 + 그리고 이때 페이징을 수행하자
    -> 컬렉션을 페치조인 하지 않는다면 SQL 레벨에서 중복된 row가 생기지는 않는다.
    -> 따라서 정상적으로 조회하고자 하는 Entity 에 대하여 페이징을 수행할 수 있게 되고
    -> 바로 이 시점에 페이징을 수행해야 한다.
  • 실제로 Order와 연관된 Member 및 Delivery를 페치조인으로 함께 가져오면서 페이징을 하고 있다.
  • 나아가 이때 별칭을 사용하여 Member와 연관된 다른 Entity를 함께 페치조인 할 수 도 있는데
    -> 그렇게 해도 SQL 레벨의 row의 길이가 길어질 뿐, row의 수가 늘어나지는 않기 때문이다!
  • Step2. 이후 연관된 컬렉션은 LAZY 로딩 조회로 쿼리를 날려 가져오되, BatchSize를 설정하여 한번에 많이 가져오도록 최적화 하자!
  • BatchSize를 100으로 설정하면, 실제 SQL 쿼리에서 IN절을 통해 한 쿼리에 많은 컬렉션 Entity들을 조회해 올 수 있게 된다.
    -> 따라서 이 BatchSize를 설정하는 옵션은 디폴트로 켜놓는다고 생각하자!
    -> BatchSize는
    -> hibernate.default_batch_fetch_size를 통해 글로벌 설정을 할 수도 있고
    -> @BatchSize 를 통해 특정 컬렉션에 개별 적용할 수 도 있다.
  • 이때 이 BatchSize는 맥시멈 1000을 넘기지 않는게 좋다
    -> DB별로 1000을 넘길 경우 오류를 일으킬 수도 있기 때문이다.
    -> 또한 1000을 넘기지 않더라도 BatchSize 설정을 통해 DB에서 순간적으로 가져오는 데이터가 너무 많아 DB에 순간 부하를 줄 수 있으니,
    -> 시행착오를 통해 적절한 값을 설정하는 것이 좋다(100~1000이하 값)

이 페이징과 한계돌파 방법을 기반으로 최적화 한 코드는 아래와 같다.

/** < applicaion.yml 파일에서 BatchSize를 글로벌 설정함> */
jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        #        show_sql: true
        format_sql: true
        default_batch_fetch_size: 100

/**
     * <Order 조회>
     *
     *  1) 단 ManyToOne, OneToOne으로 연관된 Member와 Delivery도 함께 페치 조인
     *  (이떄 Member나 Delivery에 대해 ~ToOne으로 연관된 얘들도 별칭 사용으로 함꼐 페치조인 가능)
     *
     *  2) 또한 이떄는 row 수가 늘어나지 않으니깐 , 페이징을 시도한다
     * */
    public List<Order> findAllWithMemberDeliveryPaging(int offset, int limit){
        return em.createQuery("select o from Order o " +
                               "join fetch o.member m " +
                               "join fetch o.delivery d", Order.class)
                .setFirstResult(offset)
                .setMaxResults(limit)
                .getResultList();
    }



/**
     * <Order 정보를 통해 , 모든 OrderItem  Item 조회 API>
     *
     *  [버전3.1]
     *  : 페이징과 한계돌파
     *
     *  - Order와 ManyToOne/OneToOne 관계인 Member와 Delivery의 경우 페치조인으로 같이 가져와도 데이터 뻥튀기가 안됨. so Member와
     *    Deliery는 함께 페치조인으로 가져오면서 && 데이터뻥튀기가 안되는 지금 페이징을 함
     *  - 그 이후 연관된 각 Order별 OrderItem들은 그냥 지연로딩에 의해 추가적으로 나가는 쿼리에 의해 가져옴
     *    단 이떄 @BatchSize값을 설정하여 , 한번에 X개의 Order에 대한 OrderItem들을 미리 가져오게 하므로써 (내부적으로 SQL IN절이 사용됨)
     *    지연로딩에 의한 1+N 문제를 1+X 까지 최적화 시킬 수 있음.
     *
     *  */

    @GetMapping("/api/v3.1/orders")
    public List<OrderDto> ordersV3_page(@RequestParam(value = "offset", defaultValue = "0") int offset,
                                        @RequestParam(value = "limit", defaultValue = "100") int limit){

        List<Order> orderList = orderRepository.findAllWithMemberDeliveryPaging(offset, limit);

        List<OrderDto> orderDtoList = orderList.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return orderDtoList;

    }
profile
개발자가 되고자 try 하는중

0개의 댓글