SpringBoot & JPA API설계-2

Kim Dae Hyun·2021년 5월 15일
3

Spring-JPA

목록 보기
2/4
post-thumbnail

본 포스트는 김영한 님의 인프런 강좌를 수강 후에 정리한 내용입니다.

  • 이번에는 엔티티 조회시 컬렉션(ToMany)이 포함된 경우 효율적인 조회 방법과 주의할 점 등을 정리해보겠습니다.

예제로 쓰인 엔티티들의 ERD는 이전 게시물을 확인해주세요.

주문 주회 API - v1

엔티티 직접 노출

    @GetMapping("/api/v1/orders")
    public List<Order> orderV1() {
        List<Order> result = orderRepository.findAllByString(new OrderSearch());
        for (Order order : result) { // Lazy 초기화
            order.getMember().getName();
            order.getDelivery().getAddress();
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(oi->oi.getItem().getName()); // 컬렉션 Lazy초기화
        }
        return result;
    }
  • Order와 ToOne관계에 있는 Member와 Delivery는 Lazy로 설정되어 있기 때문에 직접 getName(), getAddress() 등으로 Lazy를 초기화 시켜 값을 가져온다.
    • 엔티티를 찔러서 깨운다? 의 느낌
  • Order와 ToMany관계에 있는 OrderItem는 컬렉션이기 때문에 직접 루프를 돌며 OrderItem입장에서 ToOne관계에 있는 Item을 찍어서 Lazy초기화를 수행한다.
  • API설계-1에서와 동일하게 절대 엔티티를 직접 노출시키지 않도록 한다.
    • API스펙과 엔티티 간 의존성, 무한순환참조 등 여러 문제가 발생할 수 있다.

주문 주회 API - v2

엔티티를 DTO로 변환하여 노출

  • Controller
    @GetMapping("/api/v2/orders")
    public DtoWrapper<List<OrderDto>> orderV2() {
        List<Order> result = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> collect = result.stream()
                .map(o -> new OrderDto(o))
                .collect(toList());
        return new DtoWrapper(collect,collect.size());
    }
  • DTO
    @Getter
    static class OrderDto{
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItem;
        public OrderDto(Order o) {
            orderId = o.getId();
            name = o.getMember().getName();
            orderDate = o.getOrderDate();
            orderStatus = o.getStatus();
            address = o.getDelivery().getAddress();
            orderItem = o.getOrderItems().stream()
                    .map(oi -> new OrderItemDto(oi))
                    .collect(toList());
        }
    }
    @Getter
    static class OrderItemDto{
        private String itemName;
        private int orderPrice;
        private int count;
        public OrderItemDto(OrderItem oi){
            itemName = oi.getItem().getName();
            orderPrice = oi.getOrderPrice();
            count = oi.getCount();
        }
    }
  • 조회한 엔티티를 DTO로 변환하여 리턴하는 방식
    • 엔티티에서 노출시키고 싶은 필드만을 DTO로 변환하여 노출시킬 수 있다는 장점
    • API스펙과 엔티티의 의존을 제거했다는 장점
  • But, N+1문제 발생
    • 한 개 Order에 1명의 Member, 1개 Delivery, 1개 OrderItem (OrderItem내에 2개 Item)
    • 총 2개 Order를 조회하는데 10개의 쿼리가 생성되었다.
  • DTO설계 시 주의할 점
    • Order엔티티 내에 OrderItem엔티티가 있는데 이를 DTO로 변환시 OrderDTO내에 OrderItem엔티티를 직접 넣어선 안 된다.
    • OrderItem엔티티까지 DTO로 변환하여 OrderDTO클래스에 멤버로 넣어주어야 한다.
    • 이유는 간단하다. API스펙과 엔티티를 분리하고자 하는 것인데, OrderDTO내에 OrderItem엔티티가 있다면 이는 API스펙이 OrderItem엔티티에 의존하는 것이기 때문이다. 신경 써서 잘 바꿔주자.

주문 조회 API - v3

Fetch Join으로 N+1문제 해결

  • N+1문제를 해결한 쿼리 결과

But, 컬렉션을 포함하는 엔티티를 패치 조인으로 조회할 경우 전체 row가 뻥튀기 되는 문제가 발생한다.

  • DB의 row뿐만 아니라 API호출 결과 2개 Order를 조회하였지만 중복되어 4개 Order가 조회된 것을 확인할 수 있다.
// API 호출결과
[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2021-05-15T12:53:30.283024",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "서울로",
            "zipcode": "123-123"
        },
        "orderItem": [
            {
                "itemName": "JPA1",
                "orderPrice": 10000,
                "count": 3
            },
            {
                "itemName": "JPA2",
                "orderPrice": 20000,
                "count": 5
            }
        ]
    },
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2021-05-15T12:53:30.283024",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "서울로",
            "zipcode": "123-123"
        },
        "orderItem": [
            {
                "itemName": "JPA1",
                "orderPrice": 10000,
                "count": 3
            },
            {
                "itemName": "JPA2",
                "orderPrice": 20000,
                "count": 5
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2021-05-15T12:53:30.350375",
        "orderStatus": "ORDER",
        "address": {
            "city": "인천",
            "street": "인천로",
            "zipcode": "123-5234"
        },
        "orderItem": [
            {
                "itemName": "SPRING1",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "SPRING1",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2021-05-15T12:53:30.350375",
        "orderStatus": "ORDER",
        "address": {
            "city": "인천",
            "street": "인천로",
            "zipcode": "123-5234"
        },
        "orderItem": [
            {
                "itemName": "SPRING1",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "SPRING1",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    }
]
  • 2개 Order를 조회했지만 2개 OrderItem에 각기 2개 Item, 총 4개 Item이 있어서 Order의 조회 결과 row가 4개가 되었고, Postman을 통해 조회한 결과 4개의 Order가 조회되었다.
    • 중복된 데이터가 조회되는 것이므로 성능상 문제가 될 수 있다.
  • distinct키워드를 통한 해결
  • 기존 중복이 발생하는 패치 조인 코드이다.
  • distinct 키워드를 추가한 코드이다.
  • distnct 키워드 추가 후 쿼리 (쿼리 상에 달라진 점은 없다.)
  • distinct 키워드 추가 후 API 호출 결과
    • 중복이 제거된 것을 확인
  • JPA의 distinct키워드는 SQL의 distinct와 조금 다르다.
    • SQL의 경우 한 개 row가 완전히 같은 것을 중복으로 하여 제거한다.
    • JPA의 distinct는 from절의 엔티티의 ID값을 기준으로 중복을 제거한다.
    • 애초에 JPA에서 distinct를 추가하여도 쿼리에는 적용되지 않는다.
  • 또 다른 문제, distinct를 사용하여 컬렉션 엔티티를 중복 없이 조회한 경우 페이징이 불가능하다.
    • 페이징은 일대다의 경우 '일'을 기준으로 수행되어야 한다.
    • 하지만 이 경우 '다'를 기준으로 데이터의 row가 결정되므로 '일'을 기준으로 페이징을 할 수 없다.
    • 이는 장애로 이어질 수 있으니 컬렉션 패치 조인시 페이징에 주의하자.

주문 조회 API - v3.1

컬렉션 패치 조인시 페이징을 해보자.

  • ToOne관계의 엔티티는 패치 조인으로 최적화 시킨다.
    • ToOne관계의 엔티티는 아무리 패치 조인을 해도 row를 증가시키지 않기 때문이다.
  • ToMany관계의 컬렉션 엔티티는 패치 조인을 수행하지 않고 Lazy로딩을 수행한다.
    • 그냥 Lazy로딩을 수행하게 되면 N+1문제 등으로 성능이 저하될 수 있지만 최적화 가능하다.
    • hibernate.default_batch_fetch_size 를 사용하자
      • application.yml 혹은 application.properties (외부설정파일)에 설정
      • Lazy로딩되는 엔티티들을 in쿼리를 통해 한 번에 가져온다. (이 경우 100개까지 in쿼리를 통해 한 번에 가져올 수 있다.)
  • Controller 코드
    • 페이징이 되는 것을 확인하기 위해 offset값을 1로 설정하였음 (0부터 시작이므로 2번째 값이 나와야 함)
    • findAllWithMemberDelivery를 통해 ToOne관계인 Member와 Delivery는 패치 조인으로 가져온다.
    • 이후 조회된 Order의 OrderItem에 직접 Lazy초기화를 수행
  • Repository 코드
    • Member와 Delivery만 패치조인을 통해 조회
    • 페이징 테스트를 위해 offset과 limit 설정
  • DTO 코드
    • Order를 받아 Order의 OrderItem의 item을 모두 Lazy초기화 수행
  • 쿼리 결과
  • API 호출 결과
    • 페이징이 된 것을 확인할 수 있다.

주문 조회 API - v4

JPA에서 DTO를 직접 조회

  • Controller 코드
  • DTO
  • Repository 코드
    • ToOne관계인 Member와 Delivery는 join해도 row가 증가되지 않으므로 단순히 join하여 가져온다 (findOrders())
    • new연산자를 통해 DTO의 경로와 생성자의 파라미터를 지정하여 쿼리를 수행한다.
    • 컬렉션의 경우 조회된 Order의 ID를 파라미터로 받아 where절을 통해 하나씩 조회한다.
  • OrderItem을 조회하는 시점에 N+1문제 발생
    • ToMany관계의 컬렉션은 조회시 row가 증가되지 않도록 따로 조회해주어야 하기 때문에 추가적으로 조회 쿼리가 발생한다.

주문 조회 API - v5

JPA에서 DTO 직접 조회 (N+1문제 해결)

  • Controller 코드
  • Repository 코드
    • 컬렉션이 아닌 ToOne관계의 엔티티는 단순 Join을 통해 조회 (v4와 동일)
    • 조회된 ToOne관계 엔티티의 키 값(PK)을 리스트화 한다. (in쿼리를 사용하기 위함)
    • in절을 사용하여 리스트화한 키 값을 파라미터로 하여 컬렉션 엔티티를 조회한다.
    • stream메서드를 활용하여 Order의 ID값을 키로 하는 Map을 생성한다.
    • 단순 Join을 통해 조회된 컬렉션이 아닌 엔티티만 조회된 DTO에 컬렉션을 채워준다.
  • 쿼리 결과

Summary

  • 절대 엔티티를 직접 노출 시키지 않는다.
  • 첫 번째로 엔티티를 조회하여 DTO로 변환하는 방식으로 접근한다.
  • 성능 최적화가 필요하다면 패치 조인을 통해 쿼리의 수를 줄인다.
  • 최적화 시 컬렉션(ToMany)이 있다면 페이징 필요 여부에 따라 최적화 방식을 결정한다.
    • 페이징 필요시, hibernate.default_batch_fetch_size를 통해 in쿼리로 최적화
    • 페이징 불필요시, 컬렉션까지 패치 조인 (+ distinct)
  • 엔티티로 조회하고 패치 조인을 적용하여도 성능이 부족? 하다면 DTO로 직접 조회하는 방식으로 접근한다.

코드의 복잡도와 성능의 최적화 사이에서 선택을 해야 한다.

  • v4와 v5의 코드를 보자..
  • 더 나아가 NativeSQL이나 Spring JdbcTemplate를 사용하면 조금의 성능 향상이 있을 수 있지만 코드의 양은... 그렇다
profile
좀 더 천천히 까먹기 위해 기록합니다. 🧐

3개의 댓글

comment-user-thumbnail
2021년 5월 15일

오 좋네요!

답글 달기
comment-user-thumbnail
2021년 5월 15일

퍼가요~

답글 달기
comment-user-thumbnail
2021년 5월 15일

오오 내가 필요했던 자료 감사합니다

답글 달기