[Spring] API 설계 정리 2편(API 최적화)
서버개발

[Spring] API 설계 정리 2편(API 최적화)

 

@Data 어노테이션

Lombok에서 지원하는 어노테이션으로 @Getter @Setter @ToString @RequiredArgsConstructor @EqualsAndHashCode를 합쳐놓은 어노테이션이다. POJO와 bean과 관련된 모든 재사용가능코드를 생성한다

(equals : 두 객체의 내용이 같은지(equality) 비교 / hashcode : 두 객체가 같은지(identity) 비교)

 

Fetch Join이란?

JPQL에서 성능 최적화를 위해 제공하는 기능으로, 연관된 엔티티나 컬렉션을 한번에 같이 조회한다

select m from parent p join fetch p.child;

이렇게하면 연관된 엔티티나 컬렉션을 함께 조회하는데 p와 p.child를 함께 조회한다(별칭은 사용할 수 없다)

 

# fetch join 전/후 쿼리문의 개수 비교

//fetch join 사용 전 ( 1 + N + N )

select
        order0_.order_id as order_id1_6_,
        order0_.delivery_id as delivery4_6_,
        order0_.member_id as member_i5_6_,
        order0_.order_date as order_da2_6_,
        order0_.status as status3_6_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id limit ?

---
select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.name as name5_4_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?
---
select
        delivery0_.delivery_id as delivery1_2_0_,
        delivery0_.city as city2_2_0_,
        delivery0_.street as street3_2_0_,
        delivery0_.zipcode as zipcode4_2_0_,
        delivery0_.status as status5_2_0_ 
    from
        delivery delivery0_ 
    where
        delivery0_.delivery_id=?
 select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.name as name5_4_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?
---        
select
        delivery0_.delivery_id as delivery1_2_0_,
        delivery0_.city as city2_2_0_,
        delivery0_.street as street3_2_0_,
        delivery0_.zipcode as zipcode4_2_0_,
        delivery0_.status as status5_2_0_ 
    from
        delivery delivery0_ 
    where
        delivery0_.delivery_id=?
        
//fetch join 사용 후 (1)

select
        order0_.order_id as order_id1_6_0_,
        delivery1_.delivery_id as delivery1_2_1_,
        member2_.member_id as member_i1_4_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_,
        delivery1_.city as city2_2_1_,
        delivery1_.street as street3_2_1_,
        delivery1_.zipcode as zipcode4_2_1_,
        delivery1_.status as status5_2_1_,
        member2_.city as city2_4_2_,
        member2_.street as street3_4_2_,
        member2_.zipcode as zipcode4_4_2_,
        member2_.name as name5_4_2_ 
    from
        orders order0_ 
    inner join
        delivery delivery1_ 
            on order0_.delivery_id=delivery1_.delivery_id 
    inner join
        member member2_ 
            on order0_.member_id=member2_.member_id

 

등록 API

# ver1. 엔티티에 직접 노출

@PostMapping("/api/v1/members")
    public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member){
        Long id = memberService.join(member);
        return new CreateMemberResponse(id, member.getName());
    }

    @Data
    static class CreateMemberResponse{
        private Long id;
        private String name;

        public CreateMemberResponse(Long id, String name) {
            this.id = id;
            this.name = name;
        }
    }

- Entity를 그대로 repository에서 꺼내 반환하는 형태. Entity의 모든 값이 노출된다 (V1 엔티티를 Request Body에 직접 매핑)

- 응답 스펙을 맞추기 위해 @NotEmpty와 같은 로직이 추가된다

- 실무에서는 같은 Entity에 대해 API가 용도에 따라 만들어지는데, 각각의 API를 위한 응답 로직을 담기 어렵다

- Entity가 변경되면 스펙이 변한다

- 그러므로 Entity를 parameter로 받는 것은 좋지 않다.

 

# ver2. DTO로 반환

  @PostMapping("/api/v2/members")
    public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request){
        Member member = new Member();
        member.setName(request.getName());

        Long id = memberService.join(member);
        return new CreateMemberResponse(id, member.getName());
    }
    
    @Data
    static class CreateMemberRequest{
        private String name;
    }


    @Data
    static class CreateMemberResponse{
        private Long id;
        private String name;

        public CreateMemberResponse(Long id, String name) {
            this.id = id;
            this.name = name;
        }
    }

 

- 새로운 Dto를 Member 엔티티 대신에 RequestBody에 매핑한다

- 엔티티와 프레젠테이션 계층을 위한 로직을 분리할 수 있다

- 엔티티와 API 스펙을 명확하게 분리할 수 있다

- 엔티티가 변해도 API 스펙은 변하지 않는다

 

수정 API

@PutMapping("/api/v2/members/{id}")
public UpdateMemberResponse updateMemberV2(@PathVariable("id") Long id,
                                           @RequestBody @Valid UpdateMemberRequest request){
    memberService.update(id, request.getName());
    Member findMember = memberService.findOne(id);
    return new UpdateMemberResponse(findMember.getId(), findMember.getName());
}

 @Data
 static class UpdateMemberRequest{
     private String name;
 }
 
 @Data
 @AllArgsConstructor
 static class UpdateMemberResponse{
     private Long id;
     private String name;
 }

- update 메소드에서는 변경감지를 통해 해당 member 변경한다

- Dto를 사용하여 형식을 유지한다

- http://localhost:8080/api/v2/members/1와 같이 사용

 

간단한 조회 API(X to One)

# ver1. 엔티티에 직접 노출

@GetMapping("/api/v1/members")
    public List<Member> membersV1(){
        return memberService.findMembers();
    }
    
@GetMapping("/api/v1/simple-orders")
 public List<Order> ordersV1() {
 List<Order> all = orderRepository.findAllByString(new OrderSearch());
     for (Order order : all) {
     	order.getMember().getName(); //Lazy 강제 초기화
     	order.getDelivery().getAddress(); //Lazy 강제 초기환
     }
 	return all;
 }

- Entity를 그대로 repository에서 꺼내 반환하는 형태.  앞서 말한 것처럼 나쁜 방법

- 응답 스펙을 맞추기 위해 @JsonIgnore와 같은 로직이 추가된다

- 실무에서는 같은 Entity에 대해 API가 용도에 따라 만들어지는데, 각각의 API를 위한 응답 로직을 담기 어렵다

- Entity가 변경되면 스펙이 변한다

- 추가로 컬렉션을 직접 반환하면 향후 API 스펙을 변경하기 어렵다. (Result 클래스 생성으로 해결)

- 지연로딩(Lazy)로 설정해놓았기에 필요한 join data를 불러와야한다

- 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다. 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다.

항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해야 한다

 

# ver2. DTO로 반환

@GetMapping("/api/v2/members")
    public Result membersV2(){
        List<Member> findMembers = memberService.findMembers();
        List<MemberDto> collect = findMembers.stream().map(m -> new MemberDto(m.getId(), m.getName(), m.getAddress())).collect(Collectors.toList());

        return new Result(collect);
    }

    @Data
    @RequiredArgsConstructor
    static class Result<T>{
        @GeneratedValue
        private int count;
        private final T data;
    }

    @Data
    @Getter
    @AllArgsConstructor
    static class MemberDto{
        private Long id;
        private String name;
        private Address address;
    }
    
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
     List<Order> orders = orderRepository.findAll();
     List<SimpleOrderDto> result = orders.stream()
     .map(o -> new SimpleOrderDto(o))
     .collect(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();
     orderDate = order.getOrderDate();
     orderStatus = order.getStatus();
     address = order.getDelivery().getAddress();
 	}
}

- 엔티티를 DTO로 변환해서 반환한다

- 엔티티가 변해도 API 스펙이 변경되지 않는다

- 추가로 Result 클래스로 컬렉션을 감싸서 향후 필요한 필드를 추가할 수 있다

- 쿼리가 총 1 + N + N번 실행된다(v1과 같은 횟수) : order 1번 order + member 지연로딩 조회 N번 + delivery 지연로딩 조회N번

 

# ver3. DTO로 변환 - 페치 조인 최적화

@GetMapping("/api/v3/simple-orders")
    public Result orderV3byResult(){
        List<Order> orders = orderRepository.findAllWIthMemberDelivery();
        List<SimpleOrderDto> collect = orders.stream().map(SimpleOrderDto::new).collect(Collectors.toList());

        return new Result(collect);
    }


public List<Order> findAllWIthMemberDelivery() {
        return em.createQuery(
                "select o from Order o " +
                        "join fetch o.delivery d " +
                        "join fetch o.member m",Order.class)
                .getResultList();
}

- fetch join을 사용하여 엔티티를 쿼리 1번에 조회한다 (위 페치조인 문항 참고)

- 페치 조인으로 order->member, order->delivery를 이미 조회한 상태이므로 지연로딩X

 

# ver4. JPA에서 DTO로 바로 조회

@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
 	return orderSimpleQueryRepository.findOrderDtos();
}

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
     private final EntityManager em;
     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();
     }
}

- 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회

- new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환

- 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점

 

 

API 개발 고급 - 컬렉션 조회 최적화(X to Many)

앞의 예제에서는 toOne(OneToOne, ManyToOne) 관계만 있었다.

컬렉션인 일대다 관계 (OneToMany)를 조회하고, 최적화하는 방법을 알아보자

# ver1. 엔티티에 직접 노출

 @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAll(new OrderSearch());

        for (Order order : all) {
            order.getMember().getName();
            order.getDelivery().getAddress();

            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName()); // item join
        }


        return all;
    }

- 위와 마찬가지로 지연로딩인 엔티티를 불러야 정보를 가져올 수 있다

- 엔티티를 직접 노출하므로 좋은 방법이 아니다

- 양방향 연관관계(양쪽에서 연관관계를 계속 조회한다 order > orderItem > order > orderItem > order)

- 위의 경우에 @JsonIgnore을 통해 한 쪽에서 무한루프를 방지한다

 

 

# ver2. 엔티티를 DTO로 변환

@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAll(new OrderSearch());
    List<OrderDto> collect = orders.stream().map(o-> new OrderDto(o)).collect(Collectors.toList());

    return collect;
}

// Entity에 대한 의존을 끊어야한다
static class OrderDto {
        private Long id;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        // OrderItem조차 Dto로 바꾸어야한다
        // private List<OrderItem> orderItems;
        private List<OrderItemDto> orderItems;

        public OrderDto(Order order) {
            this.id = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
            order.getOrderItems().stream().forEach(o -> o.getItem().getName());
            this.orderItems = order.getOrderItems().stream().map(orderItem -> new OrderItemDto(orderItem)).collect(Collectors.toList());
        }
    }

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

- Order의 엔티티뿐만이 아니라 그 안의 엔티티들의 정보 또한 Dto로 변경한다

(엔티티 직접 노출을 막기위해서)

- SQL 실행 수 : order 1 + member, address N + orderItem N + item N

- SQL이 너무 많이 실행되기 때문에 fetch join을 이용해야 한다

 

# ver3. 엔티티를 DTO로 변환 - fetch join 최적화

// fetch join 최적화 + Entity의존 코드도 아니다
    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());

        return collect;
  	}
    
public List<Order> findAllWithItem() {
        // db의 distinct보다 기능이 하나 더 있다 / db의 distinct는 모든 컬럼이 같아야 중복제거가 되나 JPA의 db는 main Entity만 같은 지 비교
        // 단점 : paging 불가능(1대 다를 fetch join하는 순간)
        // db와 JPA의 distinct 기준이 다르기 때문에 하이버네이트는 경고를 내고 모든 데이터를 DB에서 읽어오고 페이징 해버린다 그래서 .. 그래서 메모리 문제도 있고(메모리 초과 등) 안 좋다(1대 다에서, orderItem만)
        // 컬렉션 패치 조인은 하나만, 둘 이상하면 데이터 부정합 온다 ( 1 : N : N ) 같이 되버림 ㄷ.ㄷ.
        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();

        // " join fetch oi.item i", Order.class).setFirstResult(1).setMaxResults(100).getResultList();
}

- fetch join으로 인해 SQL이 1번만 실행된다

- distinct를 사용하면 1대다 조인에서 조회된 데이터베이스 내용 중 중복된 엔티티를 제거해준다. sql의 distinct는 row내 모든 정보가 같아야하는 반면 jpa의 distinct는 추가로 애플리케이션에서 중복을 걸러준다

- 단점으로는 컬렉션 페치조인으로 인해 페이징이 불가능하다. select문의 조회에서는 중복된 데이터가 조회되기 때문에 어느 기준으로 잘라야하는지 프로그래머 입장에서는 알기 어렵고, 그로인해 제대로 된 데이터 조회가 불가능하다

- 컬렉션 fetch join은 1개만 사용할 수 있다. 둘 이상의 컬렉션에 할 경우 데이터가 부정합하게 조회될 수 있다

 

# ver3.1. 엔티티를 DTO로 변환 - fetch join 최적화 + 페이징 한계 돌파

@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page() {
    List<Order> orders = orderRepository.findAllWIthMemberDelivery();
    List<OrderDto> collect = orders.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
    
    return collect;
}

public List<Order> findAllWIthMemberDelivery() {
    return em.createQuery(
            "select o from Order o " +
                    "join fetch o.delivery d " +
                    "join fetch o.member m",Order.class)
            .getResultList();
}

// application.yml

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/test;
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
#        show_sql: true
        format_sql: true
        default_batch_fetch_size: 100

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.type: trace

decorator:
  datasource:
    p6spy:
      logging: slf4j

- ToMany관계에서 조인이 발생하면 데이터의 row가 예측할 수 없이 증가한다. 그렇기 때문에 페이징이 불가능한 것

- 그렇기에 페이징을 하기위해 일단 ToOne인 관계는 모두 fetch join을 한다(To Many 관계가 아니므로 row가 하나만 생성된다)

- 컬렉션은 지연 로딩으로 조회한다

- 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다

- 위 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size만큼 in 쿼리를 사용해 조회하기 때문에 페이징이 가능하다

select
        order0_.order_id as order_id1_6_0_,
        delivery1_.delivery_id as delivery1_2_1_,
        member2_.member_id as member_i1_4_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_,
        delivery1_.city as city2_2_1_,
        delivery1_.street as street3_2_1_,
        delivery1_.zipcode as zipcode4_2_1_,
        delivery1_.status as status5_2_1_,
        member2_.city as city2_4_2_,
        member2_.street as street3_4_2_,
        member2_.zipcode as zipcode4_4_2_,
        member2_.name as name5_4_2_ 
    from
        orders order0_ 
    inner join
        delivery delivery1_ 
            on order0_.delivery_id=delivery1_.delivery_id 
    inner join
        member member2_ 
            on order0_.member_id=member2_.member_id
---            
select
        orderitems0_.order_id as order_id5_5_1_,
        orderitems0_.order_item_id as order_it1_5_1_,
        orderitems0_.order_item_id as order_it1_5_0_,
        orderitems0_.count as count2_5_0_,
        orderitems0_.item_id as item_id4_5_0_,
        orderitems0_.order_id as order_id5_5_0_,
        orderitems0_.order_price as order_pr3_5_0_ 
    from
        order_item orderitems0_ 
    where
        orderitems0_.order_id in (
            ?, ?
        )
---
select
        item0_.item_id as item_id2_3_0_,
        item0_.isbn as isbn3_3_0_,
        item0_.name as name4_3_0_,
        item0_.price as price5_3_0_,
        item0_.stock_quantity as stock_qu6_3_0_,
        item0_.artist as artist7_3_0_,
        item0_.etc as etc8_3_0_,
        item0_.author as author9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id in (
            ?, ?, ?, ?
        )

- 다음과 같이 쿼리에 in문이 들어가면서 쿼리의 양이 대폭 줄어든다

 

API 설계 권장 순서

1. 엔티티 조회 방식으로 우선 접근

2. fetch join으로 쿼리 수를 최적화

3. 컬렉션 최적화

4. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용

5. DTO 조회 방식으로 해결이 안되면 nativeSQL 혹은 스프링 JdbcTemplate 사용

 

 

 

#스프링 프레임워크 #스프링 API 설계 #Spring 람다 #java 람다 #자바 람다 #자바 stream #stream() #RestController #spring API 고급 #spring API #스프링 API 최적화 #@Data 어노테이션 #@fetch join #스프링 패치조인 #spring API 설계 권장 순서