Spring & SpringBoot

실전 스프링부트와 JPA활용2 - 조회 API 성능 최적화, OSIV

땅콩콩 2024. 1. 11. 16:01

Order 엔티티를 보면 아래와 같이 Member, Delivery과는 to one관계를, OrderItem과는 to many관계를 맺고 있는 것을 확인할 수 있다.

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED) //생성자 숨기기
public class Order {
    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

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

    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;
}

 

이런 상황에서 주문, 배송정보, 회원, 주문한 아이템 등을 조회하는 API를 만들면 지연로딩 때문에 별도로 처리가 필요하다.

지연로딩은 실제 엔티티 대신에 프록시로 존재하기 때문에, 지연로딩 강제 초기화가 필요한 것이다.

그리고 이 과정에서 성능을 어떻게 개선할 수 있을지 살펴보자.

 

우선은 to one관계인 배송정보, 회원까지만 가져오는 간단한 주문 조회 API를 만들어보자.

 

1. 배송정보, 회원까지만 가져오는 간단한 주문 조회 API

 

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;
    private final OrderSimpleQueryRepository orderSimpleQueryRepository;
    
    //v1 -> 엔티티를 직접 노출
    @GetMapping("/api/v1/simple-orders")
    public List<Order> orderV1(){
        List<Order> all = orderRepository.findAllByCriteria(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName(); //Lazy 강제 초기화
            order.getDelivery().getAddress(); //Lazy 강제 초기화
        }
        return all;
    }

    //v2 -> 엔티티를 조회해서 DTO로 변환
    //얘도 역시 지연로딩으로 인해 너무 많은 쿼리를 날리는 문제가 있다.. 쿼리 N번 호출!
    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDTO> orderV2(){
        List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());
        List<SimpleOrderDTO> collect = orders.stream()
                .map(o -> new SimpleOrderDTO(o))
                .collect(Collectors.toList());
        return collect;
    }

    //v3 -> 엔티티를 조회해서 DTO로 변환하고, 지연로딩의 문제점을 패치조인으로 해결!
    //기본적인건 다 Lazy join으로 깔고 필요한 부분만 fetch join으로 땡겨오면 대부분의 성능문제는 다 해결된다!
    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDTO> orderV3(){
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        List<SimpleOrderDTO> collect = orders.stream()
                .map(o -> new SimpleOrderDTO(o))
                .collect(Collectors.toList());
        return collect;
    }

    //JPA에서 엔티티를 꺼내는게 아니라 DTO로 바로 끄집어내는 방법! -> 논리적 계층 붕괴..
    //but V3, V4는 트레이드오프가 각각 있기 때문에 선택의 문제이다...
    //성능으로는 V4가 낫지만, 재사용성은 오히려 V3가 더 좋음!! (성능차이가 생각보다는 크게 안난다..)
    @GetMapping("/api/v4/simple-orders")
    public List<OrderSimpleQueryDTO> orderV4(){
        return orderSimpleQueryRepository.findOrderDTO();
    }

    @Data
    static class SimpleOrderDTO {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDTO(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
        }
    }
}

 

v1은 엔티티를 직접 노출하는 방식으로, 엔티티가 변경되면 API 스펙이 변한다는 것 이외에 크게 두가지 문제점이 있다.

 

첫째, 양방향 연관관계 문제가 생긴다!
양방향 연관관계가 있으면 순환 참조에 빠지게 되기 때문에 둘 중 하나는 @JsonIgnore가 필요하다.

둘째, @ManyToOne, @OneToOne필드에서 지연로딩때문에 성능에 문제생김.
Hibernate5Module을 사용해서 강제 지연로딩을 설정할 수도 있지만 그냥 DTO를 사용하는 것이 좋다.

 

v2는 엔티티를 조회해서 DTO로 변환하는 방식이다. 

DTO를 사용하기 때문에 엔티티를 직접 노출하여 발생하는 문제는 없지만,

지연로딩으로 인해 여전히 N+1문제를 가지고 있다.

 

v3는 엔티티를 조회해서 DTO로 변환하고, 지연로딩의 문제점을 페치조인으로 해결하는 방식이다.
이처럼 to one관계는 다 Lazy join으로 깔고 필요한 부분만 fetch join으로 땡겨오면 대부분의 성능문제는 다 해결된다.

아래는 OrderRepository의 메서드이며, v3에서 사용하고 있다.

//패치 조인: 한방 쿼리로 order, member, delivery를 조인한 후에 한번에 다 땡겨온다!
    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();
    }

 

v4는 엔티티를 조회하지 않고, 리포지토리에서 DTO를 직접 꺼내오는 방식이다.

성능으로는 개선된 방법이지만, 논리적으로는 적절한 계층 구조가 아니기 때문에 트레이드오프가 있다. (재사용성은 v3가 낫다!)

아래는 OrderSimpleRepository의 메서드이며, v4에서 사용하고 있다.

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
    private final EntityManager em;

    //V4가 성능은 좀 더 좋지만 논리적으로는 계층이 깨진다..
    public List<OrderSimpleQueryDTO> findOrderDTO() {
        return em.createQuery(
                "select new jpabook.jpashop.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();
    }
}

 

 

2. 배송정보, 회원 + 주문한 상품정보까지 추가로 조회하는 복잡한 주문조회 API

 

주문내역에서 배송정보, 회원뿐만 아니라 주문한 상품정보까지 추가로 조회하려면, Order기준으로 컬렉션인 OrderItem과 Item을 조회해야 한다.

또한 이렇게 컬렉션인 to many관계는 앞서 살펴본 to one관계와 약간 다른 방식으로 조회하고, 최적화해야한다.

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

    //v1 -> 엔티티 직접 노출
    @GetMapping("/api/v1/orders")
    public List<Order> orderV1(){
        List<Order> all = orderRepository.findAllByCriteria(new OrderSearch());
        for (Order order : all) {
            //lazy loading 강제 초기화
            //->hibernate5Module 기본설정이 Lazy loading 프록시이면 데이터를 안 뿌리게 되어있기 때문. (강제 초기화가 필요)
            order.getMember().getName();
            order.getDelivery().getAddress();
            List<OrderItem> orderItems = order.getOrderItems();
            for (OrderItem orderItem : orderItems) {
                orderItem.getItem().getName();
            }
        }
        return all;
    }

    //v2 -> 엔티티 조회해서 DTO로 변환
    @GetMapping("/api/v2/orders")
    public List<OrderDTO> orderV2(){
        List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());
        List<OrderDTO> collect = orders.stream()
                .map(o -> new OrderDTO(o))
                .collect(toList());
        return collect;
    }

    //v3 -> 컬렉션으로 페치 조인해서 성능을 최적화! (페이징 불가)
    @GetMapping("/api/v3/orders")
    public List<OrderDTO> orderV3(){
        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDTO> result = orders.stream()
                .map(o -> new OrderDTO(o))
                .collect(toList());
        return result;
    }


    //v3.1 -> 페치 조인 + 성능 최적화! (페이징 가능)
    @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> result = orders.stream()
                .map(o -> new OrderDTO(o))
                .collect(toList());
        return result;
    }

    @GetMapping("/api/v4/orders")
    public List<OrderQueryDTO> ordersV4(){
        return orderQueryRepository.findOrderQueryDTO();
    }

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

    @GetMapping("/api/v6/orders")
    public List<OrderQueryDTO> ordersV6(){
        List<OrderFlatDTO> flats = orderQueryRepository.findAllByDTO_flat();
        return flats.stream()
                .collect(Collectors.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());
    }
}

 

v1은 엔티티를 직접 노출하는 방식이다. 엔티티가 변하면 API스펙이 변한다는 문제점과 함께 앞서 살펴본 양방향 연관관계, 지연로딩 강제 초기화가 필요하다는 문제점이 모두 존재한다.

 

v2는 페치조인 없이 엔티티를 조회해서 DTO로 변환하는 방식이다. 그런데 이때 OrderDTO내부에 orderItems는 DTO가 아닌 엔티티 그대로 사용하고 있기 때문에 orderItems조차도 DTO로 바꿔줘야 한다.

아래와 같은 구조로 만들어주면 된다.

@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(orderItem -> new OrderItemDTO(orderItem))
                    .collect(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();
        }
    }

 

v3은 페치조인을 사용해서 엔티티를 조회하고 DTO로 변환해주는 방식이다.

아래는 OrderRepository의 메서드이며, v3에서 사용하고 있다.

//JPA구현체인 하이버네이트6부터는 자동으로 중복제거해서 distinct 옵션이 없어도 됨.
    public List<Order> findAllWithItem() {
        return em.createQuery(
                "select 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();
    }

 

그런데 이때 신경써야할 부분이 있다.

member, delivery같은 to one관계는 페이징에 아무 문제가 없지만, orderItem같은 to many관계는 데이터가 many쪽에 맞추어 불어나기때문에 페치조인을 사용하는 방법으로는 페이징이 불가하다. (데이터가 예측할 수 없이 증가함)

만약, to many관계인 컬렉션에 페치 조인을 사용하면 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고 메모리에서 페이징해버리는 위험한 일이 발생한다...

 

그러면 페이징도 하면서 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?

v3.1의 방법을 사용하면 된다.

v3.1에서는 to one 관계는 페치 조인으로 쿼리 수를 줄이고, 나머지는 default_batch_fetch_size로 최적화하는 방식을 사용한다.
(글로벌 설정하려면 application.yml에 default_batch_fetch_size: 100 추가)

default_batch_fetch_size 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN쿼리로 조회한다.

 

아래는 OrderRepository의 메서드이며, v3.1에서 사용하고 있다.

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();
    }

 

 

v4는 엔티티가 아니라 DTO로 바로 조회하는 방식이고, 1+N 쿼리가 발생한다. (페이징 가능)

아래는 OrderQueryRepository의 메서드이며 v4에서 사용하고 있다.

public List<OrderQueryDTO> findOrderQueryDTO(){
        List<OrderQueryDTO> result = findOrders();
        result.forEach(o -> {
            List<OrderItemQueryDTO> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }

 

 

v5는 엔티티가 아니라 DTO로 바로 조회하는 방식이지만 최적화를 통해 1+1 쿼리가 발생한다. (페이징 가능)

아래는 OrderQueryRepository의 메서드이며 v5에서 사용하고 있다.

public List<OrderQueryDTO> findAllByDTO_optimization() {
        List<OrderQueryDTO> result = findOrders();
        List<Long> orderIds = getOrderIds(result);
        Map<Long, List<OrderItemQueryDTO>> orderItemMap = getOrderItemMap(orderIds);
        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
        return result;
    }

    private Map<Long, List<OrderItemQueryDTO>> getOrderItemMap(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 static List<Long> getOrderIds(List<OrderQueryDTO> result) {
        List<Long> orderIds = result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());
        return orderIds;
    }

 

 

v6은 DTO로 바로 조회하는 방식을 최대로 최적화하여 1개의 쿼리문만 발생한다. (페이징 불가능)

아래는 OrderQueryRepository의 메서드이며 v6에서 사용하고 있다.

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();
    }

 

그런데 DTO로 조회하는 방법도 각각 장단이 있다. V4, V5, V6에서 단순하게 쿼리가 1번 실행된다고 V6이 항상 좋은 방법인 것은 아니다.

따라서 상황에 따라 적절한 방법을 사용하는 것이 중요하다.

 

 

3. OSIV와 성능 최적화

 

- Open Session In View: 하이버네이트 
- Open EntityManager In View: JPA 
 이것들을 관례상 OSIV라고 부른다.

 

1. spring.jpa.open-in-view: true (기본값)인 경우

OSIV 전략이 동작하는 경우, 데이터베이스의 최초 커넥션 시점부터 API응답이 끝나는 순간까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다.

물론 지연로딩은 영속성 컨텍스트가 살아 있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지하기 때문에 이는 장점이지만, 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용하기 때문에 자칫하면 데이터베이스의 커넥션이 모자라 장애로 이어질 수 있다.

 

2. spring.jpa.open-in-view: false 인 경우

OSIV를 끄면 트랜잭션 종료시 영속성 컨텍스트도 함께 닫고, 데이터베이스 커넥션도 반환한다. 따라서 커넥션 리소스를 낭비하지 않는다.

그런데 OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야하기 때문에, 트랙잭션이 끝나기 전에 지연로딩을 강제로 호출해두어야 한다.

이렇게 OSIV를 끈 상태로 복잡성을 관리하려면 커맨드와 쿼리를 분리하면 된다.

 

핵심 비즈니스 로직을 담고 있는 Service

복잡한 화면, API에 맞춘 QueryService

 

이렇게 서비스계층을 분리해서 해결할 수 있다.

앞서 살펴본 복잡한 API의 v3, v3.1 의 경우로 살펴보자.

@Repository
@RequiredArgsConstructor
public class OrderRepository {
    private final EntityManager em;
    
    //생략
    
    public List<Order> findAllWithItem() {
        return em.createQuery(
                "select 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();
    }

    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();
    }
}

 

리포지토리 계층에서 페치 조인을 통해 지연로딩을 호출하고,

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderQueryService {
    private final OrderRepository orderRepository;

    public List<OrderDTO> orderV3(){
        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDTO> result = orders.stream()
                .map(o -> new OrderDTO(o))
                .collect(toList());
        return result;
    }

    public List<OrderDTO> orderV3_1(int offset, int limit){
        List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
        List<OrderDTO> result = orders.stream()
                .map(o -> new OrderDTO(o))
                .collect(toList());
        return result;
    }
}

 

리포지토리 계층에서 조회해온 엔티티들을 서비스 계층에서 DTO로 변환한다.

@RestController
@RequiredArgsConstructor
public class OrderApiController {

	private final OrderQueryService orderQueryService;
    
    //OSIV = false로 돌리고 트랜잭션 안에서 페치조인 사용해서 최적화하는 방법!
    @GetMapping("/api/v3/orders")
    public List<jpabook.jpashop.service.query.OrderDTO> orderV3(){
        return orderQueryService.orderV3();
    }
    
    //OSIV를 끄면 엔티티와 관련된 모든걸 디비 트랜잭션 안에서 해결해야한다.
    @GetMapping("/api/v3.1/orders")
    public List<jpabook.jpashop.service.query.OrderDTO> orderV3_1(
            @RequestParam(value = "offset", defaultValue = "0") int offset,
            @RequestParam(value = "limit", defaultValue = "100") int limit
    ){
        return orderQueryService.orderV3_1(offset, limit);
    }

 

그리고 컨트롤러에서는 서비스 계층의 코드를 단지 실행하기만 하면 된다.

이렇게 하면 OSIV를 끈 상태에서도 지연로딩을 적절하게 조회해서 성능문제 없이 컬렉션 값을 조회해 가져올 수 있다.

 

 

* 위 내용은 인프런의 실전! 스프링부트와 JPA활용 2편의 내용을 포함하고 있습니다.