API활용
Intro
회원, 주문을 조회하는 API를 여러방법으로 만들어보고 그러면서 발생하는 문제들을 단계적으로 해결하여 성능을 개선하는 과정을 보여드리겠습니다.
예제 테이블
1. 간단한 조회
회원조회 API -V1 : 엔티티 반환
@GetMapping("/api/v1/members")
public List<Member> membersV1(){ //entity의 정보가 외부에 노출됨
return memberService.findMembers();
}
- 응답값으로 엔티티가 외부에 노출된다.
- 기본적으로 엔티티의 값이 모두 노출된다
- 엔티티에 프레젠테이션계층을 위한 로직이 추가된다. ex) @NotEmpty, @JsonIgnore
- 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어질 텐데 각 API 를위한 로직을 한 엔티티에 담기는 어럽다
- 엔티티가 변경되면 api도 변경해야한다.
회원조회 API -V2 : DTO반환
@GetMapping("/api/v2/members")
public Result membersV2(){
List<Member> members = memberService.findMembers();
List<MemberDto> collect = members.stream()
.map(m -> new MemberDto(m.getName()))
.collect(Collectors.toList());
return new Result(collect.size(),collect);
}
@Data
@AllArgsConstructor
static class Result<T>{
private int count;
private T data;
}
@Data
@AllArgsConstructor
static class MemberDto{
private String name;
}
- 엔티티를 DTO로 변환해서 반환한다.
- 엔티티가 변해도 API스펙이 변경되지 않는다.
2. 연관관계 조회
V1 : 엔티티 직접 반환
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1(){
List<Order> all = orderRepository.findAll();
for (Order order: all) {
order.getMember().getName(); //Lazy 강제 초기화
order.getDelivery().getAddress(); //Lazy 강제 초기화
}
return all;
}
- 위에서 설명했듯이 엔티티를 직접 반환하는것은 좋지않다.
- 위의 API를 호출하면 ORDER <-> MEMBER와 ORDER <-> DELIVERY가 무한 순회하여 API를 제대로 가져오지 못한다.
- 위와같은 문제는 양방향 연관관계중 한 부분에 @JsonIgnore를 추가해서 해결한다.
@Entity
public class Delivery {
@Id
@GeneratedValue
@Column(name="delivery_id")
private Long id;
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
- 위의 문제를 해결하면 ByteBuddyInterceptor 에서 Type definition error가 발생한다.
- -> toOne의 경우 lazy전략을 사용하는데 이럴경우 ORDER를 조회하면 Member와 Delivery는 프록시객체를 가지게된다. 그렇기 때문에 jackson라이브러리는 이 프록시 객체를 어떻게 사용할지 모르기때문에 위의 에러가 발생한다.
@Bean
Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}
- Hibernate5Module을 스프링빈으로 등록하면 위의 문제를 해결할수 있다.
V2 : DTO로 반환
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2(){
List<Order> orders = orderRepository
.findAllByString(new OrderSearch());
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
@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(); //lazy초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); //lazy초기화
}
}
- 엔티티를 DTO로 변환하는 방법
- 쿼리가 총 1 + N + N 번 실행된다. (조회된order의 수 N)
- order 1회
- order -> member N번
- order -> delivery N번
- order의 결과가 많을수록 쿼리수행이 급격히 늘어나서 성능저하를 야기할수 있다.
V3 : 패치조인으로 최적화
public List<Order> findAllWithMemberDilivery() {
return em.createQuery("select o from Order o "+
"join fetch o.member m "+
"join fetch o.delivery d",Order.class).getResultList();
}
- 엔티티를 페치조인을 사용해서 한방에 조회
- 이미 member와 delivery가 조회되었으므로 지연로딩 발생 x
쿼리 실행 결과
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_id 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_.delivery_state as delivery5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
V4 : DTO로 바로 조회
public List<SimpleOrderQueryDto> findOrderDtos(){
return em.createQuery("select new com.example.demo.repository.SimpleOrderQueryDto(o.id,m.name,o.orderDate, o.status, d.address) from Order o "+
"join o.member m "+
"join o.delivery d",SimpleOrderQueryDto.class).getResultList();
}
- dto조회는 fetch를 사용할수 없고,엔티티가 아니기 때문에 더티체킹 x
- new를 통해 객체생성할때 전체 패키지명을 입력해야한다.
- 일반적인 sql을 사용할떄 처럼 원하는값을 선택하여 조회
- 재사용성이 떨어진다.
권장 순서
- 엔티티를 DTO로 변환하는 방법을 선택한다.
- 필요하면 fetch join으로 성능을 최적화 한다. -> 대부분의 성능이슈 해결
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
3. 컬렉션 조회
V1 : 엔티티 직접 조회
@GetMapping("/api/v1/orders")
public List<Order> orderV1(){
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;
}
- 엔티티를 직접 노출하므로 좋은 방법은 아니다.
- 무한 루프에 빠지지 않게 한곳에 JsonIgnore를 추가한다.
V2 : 엔티티를 DTO로 변환
@GetMapping("/api/v2/orders")
public List<OrderDto> orderV2(){
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
return collect;
}
@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(o -> new OrderItemDto(o)).collect(Collectors.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();
}
}
- orderItem도 엔티티이기 때문에 DTO로 변환해줘야한다.
- 지연로딩으로 너무 많은 sql문이 실행된다.
V3 : 패치조인 최적화
@GetMapping("/api/v3/orders")
public List<OrderDto> orderV3(){
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
return collect;
}
public List<Order> findAllWithItem() {
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();
}
- 패치조인으로 인해 sql이 한번만 실행된다.
- oneToMany의 관계에서는 row의 수가 many의 수만큼 증가하기 때문에 엔티티가 중복되어 조회된다.
- distinct를 사용하면 sql에서도 distinct가 나가고 엔티티의 중복도 지워준다.
- 페이징이 불가능 하다
- 일단은 모든 결과를 가져오고 메모리상에서 페이징을 하는데 out ou memry가 생길수 있다.
V3.1 : 페이징 한계 돌파
- 먼저 toOne관계를 패치조인 한다. -> toOne은 row에 영향을 주지 않는다.
- 컬렉션은 지연로딩으로 가져온다.
- 이때 default_batch_fetch_size를 사용한다.
- -> 설정한 사이즈만큼 in쿼리로 조회
@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
.findAllWithMemberDilivery(offset,limit);
List<OrderDto> collect = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return collect;
}
default_batch_fetch_size 사용법
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
- 쿼리호출 수가 1+N에서 1+1로 최적화된다.
- 패치조인과 비교하여 쿼리호출수가 약간 증가하지만 페이징이 가능하다.
결론
- toOne 관계는 패치조인으로 쿼리를 줄이고 나머지는 default_batch_fetch_size를 사용한다.
V4 : JPA에서 DTO직접조회
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4(){
return orderQueryRepository.findOrderQueryDtos();
}
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 com.example.demo.repository.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 com.example.demo.repository.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();
}
- ToOne 관계들을 먼저 조회하고 ToMany는 별도로 처리
V5 : JPA에서 DTO직접조회 최적화
public List<OrderQueryDto> findAllByDto_oprtimization() {
List<OrderQueryDto> result = findOrders();
List<Long> orderIds = result.stream().map(o -> o.getOrderId()).collect(Collectors.toList());
List<OrderItemQueryDto> orderItems = em.createQuery("select new com.example.demo.repository.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()));
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
- 쿼리는 1+1로 나간다
- toOne관계를 먼저 조회하고 여기서얻은 id로 toMany관계를 한번에 조회한다.
Leave a comment