API활용

이효진

Updated:

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을 사용할떄 처럼 원하는값을 선택하여 조회
  • 재사용성이 떨어진다.

권장 순서

  1. 엔티티를 DTO로 변환하는 방법을 선택한다.
  2. 필요하면 fetch join으로 성능을 최적화 한다. -> 대부분의 성능이슈 해결
  3. 그래도 안되면 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 : 페이징 한계 돌파

  1. 먼저 toOne관계를 패치조인 한다. -> toOne은 row에 영향을 주지 않는다.
  2. 컬렉션은 지연로딩으로 가져온다.
  3. 이때 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