본 포스트는 김영한 님의 인프런 강좌를 수강 후에 정리한 내용입니다.
- 이번에는 엔티티 조회시 컬렉션(ToMany)이 포함된 경우 효율적인 조회 방법과 주의할 점 등을 정리해보겠습니다.
예제로 쓰인 엔티티들의 ERD는 이전 게시물을 확인해주세요.
엔티티 직접 노출
@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스펙과 엔티티 간 의존성, 무한순환참조 등 여러 문제가 발생할 수 있다.
엔티티를 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엔티티에 의존하는 것이기 때문이다. 신경 써서 잘 바꿔주자.
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가 결정되므로 '일'을 기준으로 페이징을 할 수 없다.
- 이는 장애로 이어질 수 있으니 컬렉션 패치 조인시 페이징에 주의하자.
컬렉션 패치 조인시 페이징을 해보자.
- 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 호출 결과
- 페이징이 된 것을 확인할 수 있다.
JPA에서 DTO를 직접 조회
- Controller 코드
- DTO
- Repository 코드
- ToOne관계인 Member와 Delivery는 join해도 row가 증가되지 않으므로 단순히 join하여 가져온다 (findOrders())
- new연산자를 통해 DTO의 경로와 생성자의 파라미터를 지정하여 쿼리를 수행한다.
- 컬렉션의 경우 조회된 Order의 ID를 파라미터로 받아 where절을 통해 하나씩 조회한다.
- OrderItem을 조회하는 시점에 N+1문제 발생
- ToMany관계의 컬렉션은 조회시 row가 증가되지 않도록 따로 조회해주어야 하기 때문에 추가적으로 조회 쿼리가 발생한다.
JPA에서 DTO 직접 조회 (N+1문제 해결)
- Controller 코드
- Repository 코드
- 컬렉션이 아닌 ToOne관계의 엔티티는 단순 Join을 통해 조회 (v4와 동일)
- 조회된 ToOne관계 엔티티의 키 값(PK)을 리스트화 한다. (in쿼리를 사용하기 위함)
- in절을 사용하여 리스트화한 키 값을 파라미터로 하여 컬렉션 엔티티를 조회한다.
- stream메서드를 활용하여 Order의 ID값을 키로 하는 Map을 생성한다.
- 단순 Join을 통해 조회된 컬렉션이 아닌 엔티티만 조회된 DTO에 컬렉션을 채워준다.
- 쿼리 결과
- 절대 엔티티를 직접 노출 시키지 않는다.
- 첫 번째로 엔티티를 조회하여 DTO로 변환하는 방식으로 접근한다.
- 성능 최적화가 필요하다면 패치 조인을 통해 쿼리의 수를 줄인다.
- 최적화 시 컬렉션(ToMany)이 있다면 페이징 필요 여부에 따라 최적화 방식을 결정한다.
- 페이징 필요시, hibernate.default_batch_fetch_size를 통해 in쿼리로 최적화
- 페이징 불필요시, 컬렉션까지 패치 조인 (+ distinct)
- 엔티티로 조회하고 패치 조인을 적용하여도 성능이 부족? 하다면 DTO로 직접 조회하는 방식으로 접근한다.
코드의 복잡도와 성능의 최적화 사이에서 선택을 해야 한다.
- v4와 v5의 코드를 보자..
- 더 나아가 NativeSQL이나 Spring JdbcTemplate를 사용하면 조금의 성능 향상이 있을 수 있지만 코드의 양은... 그렇다
오 좋네요!