본문 바로가기

개인적으로 공부한 것을 정리해 놓은 블로그입니다 틀린 것이 있으면 댓글 부탁 드립니다!


JPA

API 개발을 위한 JPA 3 - 1:N N:N 관계 컬렉션 조회 최적화

반응형
//Controller

@GetMapping("/api/v2/member")

public List<OrderDto> orders(){

    List<Order> orders = orderRepository.findAll();
    
    List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o)).Collect(Collectors.toList());
    return collect;                 
                 
}

//OrderDto


public class OrderDto{

    private Long orderId;
    private String name;
    private Adress address;
    //컬랙션 타입 포함
    private List<OrderItemDto> orderItems;
    
    
    public OrderDto(Order o){
      
      this.orderId = o.getId();
      this.name = o.getMembers().getName();
      this.address = o.getDelivery().getAddress();
	  //컬랙션으로 엔티티가 조회되기 때문에 여기서도 Dto로 받아야한다. 
      this.orderItems = o.getOrderItems().stream().map(oi -> new OrderItemDto(oi)).Collect(Collectors.toList());
    }
}


//OrderItemDto


public class OrderItemDto{

 	private String itemName;
    private int orderPrice;
    private int count;
        
    public OrderItemDto(OrderItem oi){
    
    this.itemName = oi.getItem().getName();
    this.orderPrice = o.getOrderPrice(); 
    this.count = o.getCount();
    
    }
}

 

1. 컬랙션을 Dto로 변환

 

1:N N:N 관계에서도   N:1 1:1 관계와 마찬가지로 엔티티로 바로 반환하는 것이아니라 DTO로 변환한 뒤에 반환하는 것이 좋다 .  

 

1:N N:N 관계에선 컬렉션에 대한 조회가 일어나기 때문에 위처럼 컬렉션을 받아야 할때는 해당 컬랙션도 DTO로 변환하여서 저장하는 것이 좋다 . 

 

위의 경우 오더는 orderItem에 대해 1:N 관계이기 때문에 orderItem을 리스트로 갖고 있다 해당 리스트를 조회하기 위해서는 OrderItem엔티티로 바로 받는 것이아니라 OrderItemDto로 변환해서 받아줘야한다. OrderDto의 생성자를 보면  조회된 OrderItem을 OrderItemDto로 변환해서 저장하고 있다.

 

멤버 2명이 각각 주문을 1개씩 했고 각 주문에는 주문 아이템이 2개 있는 상황이었는데 

쿼리가 9개가 나왔다. 

(오더  대한 맴버 정보 쿼리 1개  , 배송정보 1개 , 아이템 2개에 대한 쿼리 2개)  * 2(오더 수)  = 총 쿼리: 9개

 

엔티티 노출에 대한 문제는 Dto를 반환하는 것으로 해결 됬지만 Lazy로딩에 대한 문제는 해결되지 않기떄문에 패치조인을 사용해서 최적화한다.   

 

 

 

2.페치조인을 통한 컬렉션 조회 최적화

 

orderRepository JPQL fetch join을 사용해서 결과를 리턴하는 메서드이다 . 

컬랙션이 아닌 것을 할때와 똑같이 join fetch o.orederItems oi <- 이렇게 컬랙션 필드를 바로 페치 조인했다 

 

public List<Order> finAllWithItem() {
        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",Order.class
        ).getResultList();
}

 

 

컨트롤러는 똑같다 리파지토리 메서드만 바뀌었다 .

  @GetMapping("/api/v3/orders")
    public List<OrderDto> orderV3(){
        List<Order> orders = orderRepository.finAllWithItem();
        List<OrderDto> orderList = orders.stream()
                .map(order -> new OrderDto(order))
                .collect(Collectors.toList());
        return orderList;
    }

 

 

쿼리는 어떻게 나올까?

 

 

  select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        orderitems3_.order_item_id as order_it1_5_3_,
        item4_.item_id as item_id2_3_4_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        member1_.city as city2_4_1_,
        member1_.street as street3_4_1_,
        member1_.zipcode as zipcode4_4_1_,
        member1_.name as name5_4_1_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.delivery_status as delivery5_2_2_,
        orderitems3_.count as count2_5_3_,
        orderitems3_.item_id as item_id4_5_3_,
        orderitems3_.order_id as order_id5_5_3_,
        orderitems3_.order_price as order_pr3_5_3_,
        orderitems3_.order_id as order_id5_5_0__,
        orderitems3_.order_item_id as order_it1_5_0__,
        item4_.name as name3_3_4_,
        item4_.price as price4_3_4_,
        item4_.stock_quantity as stock_qu5_3_4_,
        item4_.chef as chef6_3_4_,
        item4_.food_type as food_typ7_3_4_,
        item4_.creator as creator8_3_4_,
        item4_.instructor as instruct9_3_4_,
        item4_.dtype as dtype1_3_4_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id 
    inner join
        order_item orderitems3_ 
            on order0_.order_id=orderitems3_.order_id 
    inner join
        item item4_ 
            on orderitems3_.item_id=item4_.item_id

 

길다. 길긴하지만 이거 한번이 끝이다 .  1번에서 쿼리 10번 나왔던게 fetch 조인으로 1번에 해결 됬다. 조인 때문에 문제가 될 것 같긴 하지만 성능 테스트를 해보면 조인이 많더라도 네트워크를 적게 타는 fetch 조인이 더 빠르다 (상황에 따라 다를 수 있음)

  

아주 좋아 보이지만 컬랙션 조회에서 Fetch 조인시 치명적인 문제가 2가지 있다. 

 

1. 페이징 쿼리가 불가능하다 .

 

1대다 관계에서 1을 기준으로 조인하게 될때 데이터가 뻥튀기 되기 때문에 데이터가 데이터가 제대로 나오지 않는다 . 

예를 들어 회원 1명이 1개의 오더를 갖고있고 그 오더에 아이템이 2개라면 오더를 기준으로 아이템을 찾을때

order.order_id =item.order_id;를 조인 조건으로 찾기 때문에 해당하는 아이템은 2개고 오더도 2개로 뻥튀기 된다

위와 같이 원하는 갯수와 다르게 작동한다.

 

distinct를 쓰면 되지 않을까 ? 된다 단 페이징이 아닐 경우에만 가능하다 . 

 

뻥튀기 되어 선택되는 order를 찍어보면 아이디값 뿐만 아니라 order 인스턴스 주소까지 똒같다 하지만 해당 쿼리를 DB에 직접 쳐보면 order에 대한 내용은 같지만 join된 아이템의 내용은 다르다 , DB에서는 완전히 똑같은 로우가 아니면 distinct가 작동하지 않는다 . 하지만   결국 객체입장에서는 똑같은 객체이기 때문에 페이징이 없을 경우 JPQL에 disticnt를 쓰면 db에서 데이터를 가져오고 중복되는 객체가 있다면 제거해주는 방식으로 distinct가 작동한다. 

 

하지만 페이징은 DB에서 하는 것이고 조회된 로우는 뻥튀기 되있기 때문에 방법이 없다..  DB에서는 로우가 완전히 같지 않으면 distict도 작동하지 않으니 페이징을 해도 소용이 없다 . 

 

가장 큰 문제는 JPA가 위와 같은 경우에 DB에서 처리 할 수 없으니 그것을 메모리에서 처리하는 것이다 . JPA는 모든데이터를 끌고와서 메모리에서 페이징한다. 만약 데이터가 100만건이라면 그 데이터가 메모리에 올라가고 페이징 처리된다는 것을 생각하면 말도안되지만 그렇게 셋팅이 되어있다 아웃오브메모리 에러가 일어날 수도 있다.. 때문에 컬랙션에 대해 페치조인할때는 절대 페이징을 사용하지 말아야 한다.

 

2. 1개 컬랙션 밖에 페치조인 할 수 없다 .

 

  컬랙션 1개만해도 데이터가 뻥튀기 되기 떄문에 컬랙션이 늘어날 경우 뻥튀기에 뻥튀기가 된다. 

 

 

 

 

3.default_batch_fetch_size 를 활용(해결방안)

 

 

default_batch_fetch_size 를 활용한다.

 

application .yml 또는 properties를 보면

 

yml 기준 jpa - properties -deault_batch_fetch_size 를 적용한다.

아마 application.properties도 같은 경로일 것이다. 

 

jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
      # show_sql: true #띄어쓰기 8칸
        format_sql: true
        default_batch_fetch_size: 100

 

위처럼 설정해 놓으면 어떻게 돌아가는지 알아보자

 

 

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

        List<Order> orders = orderRepository.findAllWithMemberDelivery(offset,limit);
        List<OrderDto> orderList = orders.stream()
                .map(order -> new OrderDto(order))
                .collect(Collectors.toList());

        return orderList;
}

 

 컨트롤러이다 해당 방법을 쓰면 페이징도 가능하기 때문에 offset과 limit도 받아서 페이징까지 해보려한다.  

 

다음으론 orderRepository.findAllWithMemberDelivery(offset,limit)을 보자

 

public List<Order> findAllWithMemberDelivery(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();
}

 

deault_batch_fetch_size를 활용하기위해선 먼저 N:1 관계에 있는 것만 페치조인으로 가져온다 그렇다면 orderItem과 orderItem에 Item 은 Lazy로딩으로 가져오게 될 것이고 레이지 로딩시 발생하는 1 + N + N문제가 생길 것이다 . 

 

하지만 위처럼 deault_batch_fetch_size를 설정해두면? 호출해서 쿼리를 보자

 

 

일단 N:1관계인 member 와 delivery에 대해 페치조인으로 가져오고 마지막에 보면 limit가 걸렸다 (offset이 0이면  default 값이기 떄문에 JPA가 따로 표시하지 않는다.)

 

 

? 인쿼리로 연관 데이터를 한번에 가져왔다 그냥 레이지 로딩이었다면 ? 저기 안에있는 id 값만큼 쿼리가 날라갔을 것이다 . 

결과적으로 1+N+N 의 쿼리가 1+1+1로 줄었다. 이 방식을 사용하면 레이지 로딩으로 발생하는 1+N 문제를 해결할 수 있다. 

위에서 봤듯이 페이징도 가능하다 .

 

deault_batch_fetch_size 는 설정해놓은 값만큼 연관된 데이터를 인쿼리로 한번에 가져온다 deault_batch_fetch_size=100에서 

100은 인쿼리 안에 들어갈 수를 지정하는 것이다 보통 100~1000사이로 지정해 놓으면 된다고 한다. 

 

100개로 하든 1000개로 하든 결국에  orders변수에 담길 데이터 양은 똑 같기 때문에 애플리케이션 메모리에 가는 영향은 똑같다. 

List<Order> orders = orderRepository.findAllWithMemberDelivery(offset,limit);

 

반면에 DB -> 애플리케이션으로 데이터를 전송할때는 deault_batch_fetch_size가 영향을 줄 수 있다 

 

deault_batch_fetch_size 를 적게 할 경우 그 만큼 쿼리가 여러번 나갈테고 네트워크를 많이 타게 되지만 

DB->애플리케이션으로 데이터를 전송할때 생기는 부하는 줄일 수 있다 .

 

반대로 deault_batch_fetch_size를 크게 할 경우 쿼리는 적게 나가 네트워크를 타는 횟수는 줄지만 

DB->애플리케이션으로 데이터를 전송할때 생기는 부하가 몰릴 수 있다 . 

 

각각의 trade-off가 있기 떄문에 상황에 맞게 사용하는 것이 좋다고 한다 .

 

1000개 이하로 지정하는 이유는 어떤 DB는 in절에 들어가는 수가 1000개 이상일 경우 오류가 일어날 수도 있기 떄문에 확인 후에 사용이 필요하다 . 

 

@BatchSize를 통해 원하는 컬랙션에만 적용할 수도 있다 . 컬랙션(N)일 경우는 엔티티안에 컬랙션 필드위에 붙히면되고 1일 경우에는 해당 엔티티에다 붙혀 주면 된다.  

 

 


 

페이징이 필요없다든가  , 연관된 컬렉션이 1개뿐이고 , 가져오는 데이터가 적을 경우에는 2번 처럼  fetch 조인으로 그냥 한번에 가져오고 Distinct를 활용해서 중복데이터를 제거하여 사용하고 (1대다 관계에서 데이터가 뻥튀기 되는데  데이터가 많을 경우 뻥튀기된 데이터를 애플리케이션으로 일단 다 가져오고 메모리에서 중복값을 제거하기 떄문에 성능상 문제가 있을 수 있다 .) 

 

 

페이징이 필요하거나 , 연관된 컬렉션이 여러개고 , 가져오는 데이터가 많을 경우 3번 처럼 deault_batch_fetch_size를 활용하자 

(ToOne관계에 대해서만 페치조인을하고 컬랙션은 지연로딩으로 유지, deault_batch_fetch_size을 통해 지연로딩되는 데이터들을 인쿼리로 한방에 가져오게 ) 

 

 

 

 

 

 

 

반응형