[JPA 활용2] [4] API 개발 고급 - 컬렉션 조회 최적화(하편)
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의
스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 스프링 부트, 실무에서 잘 쓰고 싶다면? 복잡한 문제까지 해결하는 힘을 길러보세요
www.inflearn.com
인프런 김영한 님의 강의를 듣고 작성한 글입니다.
[5] 주문 조회 V4 : JPA에서 DTO 직접 조회

- 화면이나 API 용도에 한정된 쿼리는 패키지를 따로 파서 구현한다.
- 이렇게 하면 화면과 관련된 리포지토리와 범용 리포지토리를 분리할 수 있다.
OrderApiController
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4(){
return orderQueryRepository.findOrderQueryDtos();
}
OrderQueryRepository
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
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 jpabook.jpashop.repository.order.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();
}
public List<OrderQueryDto> findOrders() {
return em.createQuery("select new jpabook.jpashop.repository.order.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();
}
}
- findOrders()로 DB로부터 직접 OrderQueryDto에 데이터를 받아온다. (아직 컬렉션 필드인 OrderItems 값이 빈 상태)
- 반복문으로 OrderQueryDto 객체를 탐색한 뒤 findOrderItems() 메서드를 호출한다. 이때 OrderQueryDto 객체 id값을 넘겨준다.
- findOrderItems()는 OrderQueryDto 객체에 담긴 id값을 이용해 DB에서 orderItem 값을 조회한다.
- 조회된 OrderItem값을 o.setOrderItems()를 이용해 OrderQueryDto에 담고 반복문이 끝나면 컬렉션 필드가 할당된 OrderQueryDto를 반환한다.
OrderQueryDto
@Data
public class OrderQueryDto {
@JsonIgnore
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemQueryDto> orderItems;
public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
- 생성자에서 컬렉션 필드인 orderItems를 생략했다.
- 데이터 뻥튀기 문제를 해결하기 위해 직접 주입하는 방식을 택했기 때문이다.
- 만일 클라이언트가 요구하는 API 스펙에서 orderId가 필요 없다면 @JsonIgnore를 붙여주면 된다.
OrderItemQueryDto
@Data
public class OrderItemQueryDto {
private Long orderId;
private String itemName;
private int orderPrice;
private int count;
public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
this.orderId = orderId;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
}
- 컬렉션 필드를 위한 DTO다.
■ 실행결과

하지만 이렇게 하면 결과적으로 N + 1 문제가 발생한다.

where절의 비교 대상이 하나 이므로 다수를 조회하면 쿼리를 반복실행할 수밖에 없기 때문이다.
[6] 주문 조회 V5 : JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
[5] 챕터에서 발생한 N + 1 문제를 해결하려면 IN 키워드를 사용하면 된다.
OrderApiController
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findAllByDto_optimization();
}
OrderQueryRepository - findAllByDto_optimization()
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders();
//주문 데이터만큼 Map에 올림
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
//모자랐던 orderItem 데이터 채워준다.
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
List<OrderItemQueryDto> orderItems = em.createQuery("select new jpabook.jpashop.repository.order.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()));
return orderItemMap;
}
private static List<Long> toOrderIds(List<OrderQueryDto> result) {
List<Long> orderIds = result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
return orderIds;
}
① toOrderIds()
findOrders()로 불러온 OrderQueryDto 리스트의 OrderId를 List<Long>에 담고 리턴. 이 값을 인자로 findOrderItemMap()을 호출
② findOrderItemMap()
1. LIst<OrderItemQueryDto> orderItems
- where 조건에 in :orderIds 추가 - in으로 파라미터를 모아 한 번에 보내는 게 키워드
- toOrderIds()로 받아온 orderId들을 파라미터로 담아 보냄.
2. Map<Long, ...> orderItemMap : Long 부분에 orderItemQueryDto.getOrderId() 키 값으로 넣어 줌.
3. orderId를 키값으로 하고 List<OrderItemQueryDto>를 value값으로 하는 OrderItemMap을 반환
③ result.forEach()
OrderItemMap에 orderId값을 넣어서 일치하는 값을 가져온 후 setOrderItems로 비었던 orderItem 데이터를 채워준다.
■ 실행결과
Query 2번 : 루트 1번, 컬렉션 1번


우측 쿼리를 보면 기존엔 루프를 돌았는데, 여기선 in 절로 한꺼번에 조회하는 것을 확인할 수 있다.
정리
① ToOne관계들을 먼저 조회한다.(findOrders)
② findOrders()로 얻은 식별자 OrderId로 ToMany 관계인 orderItem을 한꺼번에 조회 (JPQL에 in 넣어 줌)
③ MAP을 사용해 매칭 성능 향상 O(1)
[7] 주문 조회 V6 : JPA에서 DTO 직접 조회 - 플랫 데이터 최적화
쿼리를 한 번만 호출해서 데이터를 가져오자!
OrderFlatDto
@Data
public class OrderFlatDto {
private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간
private Address address;
private OrderStatus orderStatus;
private String itemName;//상품 명
private int orderPrice; //주문 가격
private int count; //주문 수량
//생성자 생략
}
OrderQueryRepository - findAllByDto_flat()
public List<OrderFlatDto> findAllByDto_flat() {
List<OrderFlatDto> resultList = em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
" from Order o" +
" join o.member m" +
" join o.delivery d" +
" join o.orderItems oi" +
" join oi.item i", OrderFlatDto.class)
.getResultList();
return resultList;
}
- 일대다 조인이므로 데이터가 중복되어서 조회된다.
- 따라서 컨트롤러에서 중복을 걸러내 OrderQueryDto에 알맞게 매칭하는 작업이 필요하다.
OrderApiController
@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
return flats.stream()
.collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
)).entrySet().stream()
.map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(),
e.getKey().getAddress(), e.getValue()))
.collect(toList());
}
stream()으로 OrderFlatDto를 OrderQueryDto 형태로 변환하는 작업을 진행. (여기서 중복이 걸러진다)
추가]
- OrderQueryDto에 컬렉션까지 포함한 dto 생성자를 추가해야 한다.
- 중복을 구분하기 위해선 OrderQueryDto에 @EqualsAndHashCode(of="orderId) 애노테이션을 추가해야 한다.
■ 정리
장점
① Query 1번으로 모든 데이터를 정리할 수 있다.

단점
① 쿼리는 한 번이만, 일대다 조인으로 인해 DB => 애플리케이션으로 전달하는 데이터에 중복데이터가 추가돼 상황에 따라 V5보다 더 느릴 수도 있다.

② 애플리케이션에서 분리, 중복제거하는 작업이 복잡한 편
③ 페이징이 불가능하다. (DB에서 중복된 데이터가 날아 옴 )
[8] API 개발 고급 정리
■ 정리
① 엔티티 조회 방식
- 엔티티를 그대로 반환하는 것은 사용하면 안 된다.
- 엔티티를 조회한 후 DTO로 변환해서 반환한다.
- 지연로딩을 통해 엔티티를 조회하면 많은 쿼리가 날아가므로 페치 조인을 사용한다.
- 컬렉션 타입은 페치 조인을 쓰면 페이징이 불가능하다.
- 해결책 : ToOne관계는 페치 조인으로 쿼리 수를 최적화하고 컬렉션은 지연 로딩을 유지한다. 대신hibernate.dfault_batch_fetch_size 혹은 @BatchSize로 페이징 처리를 한다.
② JPA에서 DTO로 직접 조회
- JPA에서 DTO로 직접 조회할 때 컬렉션(일대다)은 IN절을 이용해 최적화한다. 이때 메모리를 활용
- 플랫 데이터를 최적화하려면 JOIN 결과 그대로 조회 후 애플리케이션에서 원하는 모양으로 다듬어야 한다.
■ 권장 개발 순서
- 엔티티 조회 방식으로 우선 접근
- 페치 조인으로 쿼리 수 최적화
- 컬렉션 최적화
- 페이징 필요하면 hibernate.dfault_batch_fetch_size 혹은 @BatchSize로 최적화
- 페이징 필요 X → 페치 조인 사용
- 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
- V4는 코드가 단순하고 유지보수가 쉬운 게 장점. 특정 주문 한 건만 조회할 땐 이 방법이 제일 낫다.
- V5는 V4에서 발생하는 N+1문제 해결 가능
- V6는 쿼리조차 한 번에 해결. but 페이징이 불가능하고 데이터가 많으면 중복 전송이 증가해 V5와 비교 시 성능 차이도 미비하다.
- 실무에선 DTO 조회방식을 쓸 땐 paging이 사실상 필수여서 V5를 많이 쓴다.
- DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 jdbcTemplate 사용