REST API와 성능 최적화, 지연 로딩과 N+1쿼리 문제

dropKick·2020년 8월 27일
0

서버는 어떤 상황에서도 클라이언트의 요청을 처리할 수 있어야한다
한 마디로 서버는 최대한 장애를 내면 안된다는건데 렌더링이 아닌 API 방식의 경우
정말정말정말 많은 요청이 클라이언트에서 들어온다
사실 데이터의 삽입과 수정에서는 성능 상 큰 문제가 발생하지 않지만 조회 요청의 경우
배민 개발자 피셜로 약 90% 정도의 성능 장애를 가지고 온다고 한다
특히나 N+1N + 1 쿼리 문제의 경우 단건 조회에도 수십 개의 쿼리가 날라갈 수 있어 치명적이다.
그럼 이런 성능 장애를 회피하거나 극복할 수 있는 API 개발 방식을 공부해보자.

샘플 데이터 삽입

지속적인 단위 테스트를 거치며 개발을 해야하는 경우 샘플 데이터를 삽입하는 일이 필요하다.
스크립트를 짜거나 데이터 자체를 넣을 수도 있지만 JPA와 연계할 경우 영속화 과정이 반드시 필요하다

public void dbInit() {
            Member member = createMember("userB", "진주", "2", "2222");
            em.persist(member);

            Book book1 = createBook("SPRING1 BOOK", 20000, 200);
            em.persist(book1);

            Book book2 = createBook("SPRING2 BOOK", 40000, 300);
            em.persist(book2);

            Delivery delivery = createDelivery(member);
            OrderItem orderItem2 = OrderItem.createOrderItem(book2, book2.getPrice(), 3);
            OrderItem orderItem1 = OrderItem.createOrderItem(book1, book1.getPrice(), 4);

            Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
            em.persist(order);

        }

여기서부터는 100% 아니 120%의 이해가 필요한 성능 최적화와 장애 해결법

지연 로딩과 조회 최적화

지연 로딩 시 실제 객체와 JSON

지연 로딩 시 Jackson 라이브러리는 프록시 객체를 이해할 수 없어서 에러

Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor];

강제 지연 로딩과 N+1 쿼리 문제

@GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByCriteria(new OrderSearch());
        return all;
    }

주문만 가져오는 간단한 요청이지만 강제 지연 로딩에 의해 이 쿼리는 Order 뿐만이 아닌

2020-08-28 23:41:44.426 DEBUG 58495 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    select
        item0_.item_id as item_id2_3_0_,
        item0_.name as name3_3_0_,
        item0_.price as price4_3_0_,
        item0_.stock_quantity as stock_qu5_3_0_,
        item0_.author as author6_3_0_,
        item0_.isbn as isbn7_3_0_,
        item0_.actor as actor8_3_0_,
        item0_.director as director9_3_0_,
        item0_.artist as artist10_3_0_,
        item0_.etc as etc11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?
2020-08-28 23:41:44.429 DEBUG 58495 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    select
        categories0_.item_id as item_id2_1_0_,
        categories0_.category_id as category1_1_0_,
        category1_.category_id as category1_0_1_,
        category1_.name as name2_0_1_,
        category1_.parent_id as parent_i3_0_1_ 
    from
        category_item categories0_ 
    inner join
        category category1_ 
            on categories0_.category_id=category1_.category_id 
    where
        categories0_.item_id=?

이렇게 아이템, 카테고리 등 모든 엔티티 정보를 가져온다.

DTO를 통한 API 간소화, 지연 로딩과 N + 1 문제

따라서 엔티티를 직접 노출하면 안되고, API 스펙에 대한 간소화도 이루어내야 한다.
(API 스펙을 여과없이 제공 했을 때 외부에서 사용해버렸다면 그 API를 변경하기 어려움)

DTO를 사용한 API 로직

// DTO 사용, API 간소화
    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
	return orderRepository.findAllByCriteria(new OrderSearch())
    		.stream()
                .map(SimpleOrderDto::new)
                .collect(toList());
    }
public SimpleOrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
        }
  • 엔티티 데이터 자체가 아닌 DTO를 반환
  • DTO를 통해 필드를 정의하므로 엔티티에서 원하는 데이터만 가져올 수 있음
    API 간소화와 엔티티 보호, 엔티티 변경에 따른 API 스펙 변경 문제를 해결
  • 실제 엔티티가 변경 되더라도 컴파일 에러가 뜨기때문에 빠른 수정이 가능

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2020-08-29T00:00:58.47231",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        }
    },
]
  • 기존 모든 연관 관계를 가져오던 데이터가 아닌 DTO 데이터만 돌려받음
  • 또한 address라고 정의한 값 타입(Value Object)는 엔티티가 아님
    address라는 타입을 정의한 것뿐

지연 로딩에 따른 N + 1 쿼리 문제

DTO를 사용해서 문제를 해결했는데 가장 중요한 문제인 N + 1 문제가 남아있다.
N + 1 문제는 1번의 쿼리로 N개의 쿼리가 추가로 날라가는 문제인데

// DTO 사용, API 간소화
    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch()); 

        return orders.stream()
                .map(SimpleOrderDto::new)
                .collect(toList());
    }

처음 Order 테이블을 조회하고 영속성 컨텍스트에 없으니 DB 쿼리를 통해 N개를 받는다


    @ManyToOne(fetch = LAZY)
    @JoinColumn (name = "member_id")
    private Member member;
    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery")
    private Delivery delivery;

받아온 데이터 Order에는 2개의 지연로딩이 있고, 영속성 컨텍스트에 데이터가 없으니
DB 쿼리가 이루어진다.
그렇게 되면 Order를 받아오는 쿼리 1회
거기서 지연로딩이 걸린 Member 1회 + Delivery 1회
그리고 받아온 데이터는 두 개였으니 다시 Member 1회 + Delivery 1회

Order 1회 + Member 2회 + Delivery 2회
한 번의 조회 쿼리로 4개의 쿼리가 추가로 나갔다.
만약 데이터가 10개였다면 20개의 쿼리가 추가로 나가는 상황
이런 N + 1 문제는 성능 상 치명적이고 이를 해결해야한다.

즉시 로딩을 쓰면 해결되지 않을까?

설명 할 필요도 없이 절대 안된다.
궁금하면 EAGER로 변경하고 API 요청을..

지연로딩과 Collection 조회 최적화

지연 로딩에 따른 문제를 Fetch 조인으로 해결

// Order Repo
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class

JPQL 쿼리를 통해 fetch 조인을 사용


    select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        order0_.delivery 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_.status as status5_2_2_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery=delivery2_.delivery_id

fetch 조인을 사용하면 지연 로딩이 전부 무시되고 객체 그래프 탐색에 의해
정의된 연관 객체를 전부 끌고 온다.
이로 인해 결과물은

select order0_.order_id as order_id1_6_0_, member1_.member_id as member_i1_4_1_, delivery2_.delivery_id as delivery1_2_2_, order0_.delivery 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_.status as status5_2_2_ from orders order0_ inner join member member1_ on order0_.member_id=member1_.member_id inner join delivery delivery2_ on order0_.delivery=delivery2_.delivery_id

이렇게 모든 객체를 그래프 탐색으로 가지고 온 길고 긴 결과가 되고 이를 JPA가 연관 관계에 맞게 재바인딩하여 전달해준다.

항상 fetch 조인에 따른 메소드를 생성해줘야 하는가?

  • API 스펙의 변경 범위에 따라 레포지토리 변경 여부가 결정 된다.
    이미 테이블에 fetch 조인이 들어가 있는 상태에서 특정 컬럼만 추가/변경 된다면 DTO 부분만 수정하면 된다
  • Fetch 조인 하는 테이블 자체가 변경 되는 경우
    레포지토리 메소드 자체가 변경 되어야한다.

따라서 fetch 조인은 성능은 올리지만 유연성이 감소하기에 이런 부분에 따른 등가교환을 생각해야한다.
만약 실시간 트래픽을 많이 받는 쪽이라면 fetch 조인을 사용하고, 사용자가 적은 admin이라면 즉시 로딩을 통해 유연성에 중점을 둘 수 있다.

DTO를 직접 생성하는 최적화


    public List<OrderSimpleQueryDto> findOrderDtos() {
        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();
    }
 select
        order0_.order_id as col_0_0_,
        member1_.name as col_1_0_,
        order0_.order_date as col_2_0_,
        order0_.status as col_3_0_,
        delivery2_.city as col_4_0_,
        delivery2_.street as col_4_1_,
        delivery2_.zipcode as col_4_2_ 
  • DTO를 직접 생성하는 방식
  • 애초에 조회 시점부터 모든 데이터를 지정
  • fetch 조인 방식보다 아주 약간의 성능 향상이 있으나 이렇게 만들어진 레포지토리는
    재사용을 하지 못하고, API 패턴 자체가 들어와버린 상태가 됨

일반적인 경우는 엔티티를 DTO로 변환하고 fetch 조인을 적용하면 성능 이슈가 해결
그렇지 못한 경우(데이터 사이즈가 크다거나)에 별도의 쿼리 레포지토리를 통해 DTO를 직접 생성

0개의 댓글