서버는 어떤 상황에서도 클라이언트의 요청을 처리할 수 있어야한다
한 마디로 서버는 최대한 장애를 내면 안된다는건데 렌더링이 아닌 API 방식의 경우
정말정말정말 많은 요청이 클라이언트에서 들어온다
사실 데이터의 삽입과 수정에서는 성능 상 큰 문제가 발생하지 않지만 조회 요청의 경우
배민 개발자 피셜로 약 90% 정도의 성능 장애를 가지고 온다고 한다
특히나 쿼리 문제의 경우 단건 조회에도 수십 개의 쿼리가 날라갈 수 있어 치명적이다.
그럼 이런 성능 장애를 회피하거나 극복할 수 있는 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%의 이해가 필요한 성능 최적화와 장애 해결법
지연 로딩 시 Jackson 라이브러리는 프록시 객체를 이해할 수 없어서 에러
Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor];
@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=?
이렇게 아이템, 카테고리 등 모든 엔티티 정보를 가져온다.
따라서 엔티티를 직접 노출하면 안되고, API 스펙에 대한 간소화도 이루어내야 한다.
(API 스펙을 여과없이 제공 했을 때 외부에서 사용해버렸다면 그 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();
}
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2020-08-29T00:00:58.47231",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
}
},
]
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 요청을..
// 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 조인은 성능은 올리지만 유연성이 감소하기에 이런 부분에 따른 등가교환을 생각해야한다.
만약 실시간 트래픽을 많이 받는 쪽이라면 fetch 조인을 사용하고, 사용자가 적은 admin이라면 즉시 로딩을 통해 유연성에 중점을 둘 수 있다.
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 조인을 적용하면 성능 이슈가 해결
그렇지 못한 경우(데이터 사이즈가 크다거나)에 별도의 쿼리 레포지토리를 통해 DTO를 직접 생성