주문 조회 V1: 엔티티 직접 노출

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {  //api 엔티티 직접 노출.
        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;
    }
}

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

@GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2(){
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> collect = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(toList());
        return collect;
    }
@Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime localDateTime;
        private OrderStatus orderStatus;
        private Address address;
        //private List<OrderItem> orderItems;   //dto 안에 엔티티가 있으면 안좋다. api스펙에 엔티티 노출됨.
        private List<OrderItemDto> orderItems;  //OrderItem도 dto로 바꿔야함.

        public OrderDto(Order order) {
            this.orderId = order.getId();
            this.name=order.getMember().getName();
            this.address=order.getDelivery().getAddress();
            this.orderStatus = order.getStatus();
            this.localDateTime = order.getOrderDate();
            this.orderItems=order.getOrderItems()
                    .stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(toList());
//            order.getOrderItems().forEach(orderItem -> orderItem.getItem().getName());
//            this.orderItems=order.getOrderItems();
        }
        @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();
            }
        }
    }

지연 로딩 (FetchType.LAZY)

영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행한다. 따라서 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않는다.

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

@GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3(){   //join fetch를 이용한 최적화. 쿼리가 한번만 나감.
        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDto> collect = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(toList());
        return collect;

    }

OrderRepository에 추가

public List<Order> findAllWithItem(){

    return em.createQuery(
            "select distinct o from Order o" +  //order가 같은 id값을 가지면 중복을 제거해줌.
                    " join fetch o.member m" +
                    " join fetch o.delivery d" +
                    " join fetch o.orderItems oi" + //OneToMany fetch join 에서는 paging을 하면 안됨. 데이터가 의도치 않게 증가됨(many를 기준으로 데이터가 생성됨).
                    " join fetch oi.item i", Order.class
    ).getResultList();
}

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

컬렉션을 페치 조인하면 페이징이 불가능

@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
        @RequestParam(value = "offset", defaultValue = "0") int offset,
        @RequestParam(value = "limit", defaultValue = "100") int limit){   //join fetch를 이용한 최적화. 쿼리가 한번만 나감.
    List<Order> orders = orderRepository.findAllWithMemberDelivery(offset,limit);   //아이템을 한번에 가져옴으로써 쿼리가 3번만 나감 (order + orderItem + item).
    List<OrderDto> collect = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());
    return collect;
}

OrderRepository에 추가

public List<Order> findAllWithMemberDelivery(int offset, int limit){ //ordersV3_page에 사용.
    return em.createQuery(
                    "select o from Order o" +
                            " join fetch o.member m" +
                            " join fetch o.delivery d", Order.class)
            .setFirstResult(offset) //몇번째 데이터부터 가져올지 선택. 0부터 시작.
            .setMaxResults(limit)
            .getResultList();
}

ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다. ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.

컬렉션은 지연 로딩으로 조회한다.

application.yml

Spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100 #in query의 개수. N + 1 a문제 해결.

지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size(글로벌) , @BatchSize(개별) 를 적용

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

@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4(){  //쿼리 3번
    return orderQueryRepository.findOrderQueryDtos();
}

OrderQueryRepository

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;
    private final OrderRepository orderRepository;

    /**
     * 컬렉션은 별도로 조회
     * Query: 루트 1번, 컬렉션 N 번
     * 단건 조회에서 많이 사용하는 방식
     */
    public List<OrderQueryDto> findOrderQueryDtos() {
        //루트 조회(toOne 코드를 모두 한번에 조회)
        List<OrderQueryDto> result = findOrders();  //query 1개 -> N개 결과

        //루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
        result.forEach(o->{
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());    //query N 번
            o.setOrderItems(orderItems);
        });
        return result;
    }

    //1:N 관계(컬렉션)을 제외한 나머지를 한번에 조회.
    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();
    }

    //1:N 관계인 orderItems 조회.
    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();
    }
}

OrderQueryDto

@Data
@EqualsAndHashCode(of = "orderId")
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;
    }
}

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

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

OrderQueryRepository에 추가

/**
     * 최적화
     * Query: 루트 1번, 컬렉션 1번
     * 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
     * @return
     */
    public List<OrderQueryDto> findAllByDto_optimization(){ //1 + 1
        //루트 조회(toOne 코드를 모두 한번에 조회)
        List<OrderQueryDto> result = findOrders();  //1

        //orderItem 컬렉션을 MAP 한방에 조회
        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result)); //1

        //루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));;

        return result;
    }

    private List<Long> toOrderIds(List<OrderQueryDto> result) {
        return result.stream()
                .map(o->o.getOrderId())
                .collect(Collectors.toList());
    }

    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();
        return orderItems.stream()
                .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
    }

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

@GetMapping("/api/v6/orders")
    public List<OrderQueryDto> ordersV6(){  //쿼리 1번
        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());
    }

OrderQueryDto에 생성자 추가

public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate,
                         OrderStatus orderStatus, Address address, List<OrderItemQueryDto> orderItems) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.orderItems = orderItems;
    }

OrderQueryRepository

public List<OrderFlatDto> findAllByDto_flat(){
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d" +
                        " join o.orderItems oi" +
                        " join oi.item i", OrderFlatDto.class
        )
                .getResultList();
    }

OrderFlatDto

@Data
public class OrderFlatDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate; //주문시간
    private Address address;
    private OrderStatus orderStatus;
    private String itemName;//상품 명
    private int orderPrice; //주문 가격
    private int count; //주문 수량

    public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate,
                        OrderStatus orderStatus, Address address, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

API 개발 고급 정리

엔티티 조회

DTO 직접 조회