JPA 활용편 2 - API 개발 고급(컬렉션 조회 최적화)

Stella·2022년 6월 21일
0

Java

목록 보기
16/18

API 개발 고급 (컬렉션 조회 최적화)

  • 컬렉션 조회 : 일대다 조회

주문 조회 V1: Entity 직접 노출

API

@GetMapping("api/v1/orders")
    public List<Order> ordersV1(){
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        // 프록시 객체 강제 초기화
        for (Order order : all) {
            order.getMember().getName();
            order.getDelivery().getAddress();
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o->o.getItem().getName());
        }
        return all;
    }
  • 모든 Order 조회해서 Entity로 반환
  • N+1 문제 -> 프록시 객체 강제 초기화(위의 코드에서 for 부분)
  • json 무한루프 -> Entity에 @JsonIgnore 추가

주문 조회 V2: Entity를 DTO로 변환

API

 @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2(){
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());
        return result;
    }
  • DTO를 통해 JSON 무한 루프 해결
  • N+1 문제 여전히 있음

DTO

 @Getter
    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();
            // entity를 그대로 반환 -> orderItem도 dto로 바꿔서 반환해야함
//            order.getOrderItems().stream().forEach(o -> o.getItem().getName());
//            orderItems = order.getOrderItems();
            // 이렇게 orderItem을 dto로 바꾸서 반환
            orderItems = order.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(Collectors.toList());
        }
    }
    @Getter
    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();
        }

    }

OrderDTO 안에 있는 OrderItem도 Entity를 그대로 반환하지말고 DTO를 사용해서 반환 해야함!

주문 조회 V3: Entity를 DTO로 변환 - Fetch Join

API

@GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3(){
        List<Order> orders = orderRepository.finAllWithItem();
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return result;
    }
  • fetch join으로 n+1 문제 해결 -> sql 1번 실행
  • 1:N 관계에서 join하면 N만큼 결과가 나옴(데이터 뻥튀기)
    • e.g. order가 2개에 각각 orderItem이 2개씩 있다면 결과가 4개로 나옴(중복결과) => fetch join에 distinct 추가하면 중복제거 가능

페이징 불가능!!!

  • fetch join과 페이징 같이 하면 -> 메모리에서 페이징 처리해버림, 데이터가 많으면 위험함
    • 1:N관계에서 join하면 N만큼 데이터가 뻥튀기되어서 거기서 페이징처리를 하면 개수가 맞지않음 -> 어쩔 수 없이 hibernate는 경고를 보내고 메모리에서 페이징 처리함
      => 1:N fetch join에서는 페이징 사용X
      => 컬렉션 fetch join은 1개만 사용 -> 컬렉션 둘 이상에 사용하면(1:N:M) 데이터가 많아져서 부정합하게 조회 될 수 있음

OrderRepository

 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 i",Order.class)
                .getResultList();
    }

jpa에서의 distinct 기능

  • db에 distinct query를 날려줌
  • entity가 중복인 경우 중복을 걸러서 컬렉션에 담아줌
    • db에서 distinct는 한 줄이 전부 다 똑같아야 중복제거
    • jpa에서 distinct는 order를 가져올 때 order가 같은 id값이면 중복을 제거해줌

주문 조회 V3.1: Entity를 DTO로 변환 - 페이징과 한계 돌파

  • 컬렉션을 fetch join 하면 페이징이 불가능
  • 1:N에서는 1을 기준으로 페이징 하는 것이 목적이지만, 데이터는 N을 기준으로 row가 생성(N만큼 데이터 생성해서 기준이 됨)

API

@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> orders = orderRepository.findAllWithMemberDelivery(offset,limit);

        for (Order order : orders) {
            System.out.println("order ref=" + order + " id=" + order.getId());

        }
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return result;
    }
  • ToOne(OneToOne,ManyToOne)관계를 모두 fetch join -> ToOne 관계는 row수를 증가시키지 않으므로 페이징 query에 영향을 주지 않음
  • 컬렉션은 지연로딩
  • 지연 로딩 성능 최적화: hibernate.default_batch_fetch_size, @BatchSize 적용
    • hibernate.default_batch_fetch_size: 글로벌 설정
    • @BatchSize: 개별 최적화
    • 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 in query로 조회
    • Batch size에 지정한 크기 만큼의 데이터를 미리 가져옴
    • 1:M:N이 1:1:1로 바뀜(데이터 크기에 따라 다름)
    • fetch join 보다 많은 query 발생, 하지만 정규화된 데이터만 가져옴(중복x)

Batch size 적용

1. Global 적용: application.yml에 추가

spring:
  jpa:
    hibernate:
  	  default_batch_fetch_size: 100

2. 개별 적용

Collection인 경우: Field에 적용

@BatchSize(size = 100)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();

Collection이 아닌 경우: Class에 적용

@BatchSize(size = 100)
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item { 

적당한 Batch size?

  • 100~1000개 사이 권장
  • 크기가 크면 순간적으로 DB와 애플리케이션에 순간 부하가 증가할 수 있음
  • DB와 애플리케이션이 순간 부하를 어디까지 견딜 수 있는 지에 따라 결정
  • 100개든 1000개든 메모리 사용량은 똑같음

주문 조회 V4: JPA에서 DTO 직접 조회

API

@GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4(){
        return orderQueryRepository.findOrderQueryDtos();
    }
  • OrderQueryDto를 만들고 JPA에서 DTO를 직접 조회하는 findOrderQeuryDtos() 호출해서 dto를 반환

OrderQueryRepository

    public List<OrderQueryDto> findOrderQueryDtos(){
        List<OrderQueryDto> result = findOrders();

        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }

    private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)"+
                 " from OrderItem oi" +
                " join oi.item i" +
                " where oi.order.id = :orderId", OrderItemQueryDto.class)
                .setParameter("orderId", orderId)
                .getResultList();
    }

    private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                        " from Order o"+
                        " join o.member m"+
                        " join o.delivery d",OrderQueryDto.class)
                .getResultList();
    }
}
  • View와 관련된 OrderQueryRepository 생성(view 관련된 경우 query와 밀접하게 될 때가 많음)
  • 중요한 핵심 비즈니스 로직은 orderRepository에서 해결
  • Qudry: 루트 1번, 컬렉션 N번 실행
  • ToOne 관계들을 join을 이용해서 한번에 조회 한 후, ToMany 관계는 각각 별도 처리 -> 여기서는 findOrderItems()를 생성해서 이용
  • orders를 한번에 찾아온 후 for를 순회하면서 fidOrderItems() 호출해서 orderItem과 Item을 조회해서 저장 -> 결과적으로 N+1 문제 발생

OrderQueryDto

@Data
public class OrderQueryDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemQueryDto> orderItems;

    public OrderQueryDto(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;
    }
}

OrderItemQueryDto

@Data
public class OrderItemQueryDto {

    @JsonIgnore
    private Long orderId;
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}
  • DTO 따로 생성
  • Controller에 만들어 놓은 dto를 사용하게 되면 repository가 controller를 참조하게 됨(역행)

주문 조회 V5: JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

API

@GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5(){
        return orderQueryRepository.findAllByDto_optimization();
    }

OrderQueryRepository

 public List<OrderQueryDto> findAllByDto_optimization() {
        List<OrderQueryDto> result = findOrders();

        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

        return result;
    }

    private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
        List<OrderItemQueryDto> orderItems = em.createQuery("select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                        " from OrderItem oi" +
                        " join oi.item i" +
                        " where oi.order.id in :orderIds", OrderItemQueryDto.class)
                .setParameter("orderIds", orderIds)
                .getResultList();

        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(OrderItemQueryDto -> OrderItemQueryDto.getOrderId()));
        return orderItemMap;
    }

    private List<Long> toOrderIds(List<OrderQueryDto> result) {
        List<Long> orderIds = result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());
        return orderIds;
    }
  • Query: 루트 1번, 컬렉션 1번 => 2번 발생 (N+1 문제 해결)
  • ToOne 관계를 조회(findOrders()) -> orderId만 따로 추출(toOrderIds) -> orderId로 ToMany 관계인 OrderItem을 한번에 조회하고 Map으로 변환(findOrderItemMap()) -> 순회하면서 OrderItem을 저장(setOrderItems())

주문 조회 V6: JPA에서 DTO 직접 조회, 플랫 데이터 최적화

API

    @GetMapping("/api/v6/orders")
    public List<OrderFlatDto> ordersV6(){
        return orderQueryRepository.findAllByDto_flat();
  • Query: 1개 실행
  • Order로 페이징 불가능, OrderItems가 기준인 상태
  • 컬렉션을 따로 처리하지 않고 한번에 JOIN 해서 가져옴 -> 데이터 중복 발생 -> Application에서 추가작업 필요 -> 아래 코드

API: OrderQueryDto 반환

List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
        return flats.stream()
                .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(),o.getAddress()),
                        mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
                )).entrySet().stream()
                .map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
                .collect(toList());
  • API 스펙을 OrderQueryDto 형식으로 반환하려면 일일이 바꿔줘야함
  • OrderQueryDto로 변환해서 데이터 중복되지 않도록 가능

OrderFlatDto

@Data
public class OrderFlatDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    private String itemName;
    private int orderPrice;
    private int count
  • order와 orderItem와 item 조인해서 한번에 가져올 수 있도록 dto 설계

정리

Entity 조회

  • V1: Entity 조회 후 그대로 반환 -> Entity 그대로 반환 금지!
  • V2: Entity 조회 후 DTO로 변환
  • V3: Fetch join으로 query 수 최적화
  • V3.1: 컬렉션 페이징과 한계돌파
    • 컬렉션은 fetch join시 페이징 불가
    • ToOne은 fetch join
    • 컬렉션은 lazy 로딩 유지하고 @BatchSize로 최적화

DTO 직접 조회

  • V4: JPA에서 DTO 직접 조회
    • 코드 단순, 특정 주문 한건만 조회하면 성능 잘나옴
  • V5: 컬렉션은 IN 절을 활요해서 메모리에 미리 조회해서 최적화 (페이징 가능)
    • 코드 복잡,여러 주문을 한번에 조회하는 경우는 V4보다 V5 방식 사용
  • V6: JOIN 결과를 그대로 조회 후 Application에서 원하는 스펙으로 직접 변환(Order로 페이징 불가능)

권장 순서

  1. Entity 조회 방식
    1-1. fetch join으로 query 수 최적화
    1-2. 컬렉션 최적화
    2-1. 페이징 필요: hibernate.default_batch_fetch_size, @BatchSize로 최적화
    2-2. 페이징 불필요: fetch join 사용
  2. Entity 조회 방식으로 해결 안되면 DTO 조회 방식 사용
  3. DTO 조회 방식으로 해결 안되면 NativeSQL or Spring JDBCTemplate
    Entity 조회 방식 권장 이유?
  • fetch join이나 batchsize처럼 코드를 거의 수정하지 않고 최적화 시도 가능
  • dto 조회는 코드 변경 많이 필요
profile
Hello!

0개의 댓글