인프런 수업 강의를 듣고 정리한 내용입니다.
지금까지는 엔티티를 조회해서 DTO로 변환하는 과정을 거쳤는데, 이번에는 DTO로 직접 조회하는 방법을 알아보자!
entity가 아닌, 특정 화면을 위한 쿼리들은 따로 query 디렉터리에 저장한다.
OrderApiController
추가
private final OrderQueryRepository orderQueryRepository;
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueryDtos();
}
✔️ 3가지 클래스
리포지토리 메서드인 findOrderQueryDtos()메서드
에서 DTO에 직접 접근할텐대, 기존에 사용했던 컨트롤러의 inner(내부) 클래스 DTO를 그대로 사용하면 리포지토리에서 컨트롤러를 의존하기 때문에
다르게, 외부 클래스로 리포지토리 계층에 DTO를 따로 위치시켜야 한다! (OrderQueryDto
, OrderItemQueryDto
)
OrderQueryDto
package csjpabook.csjpashop.repository.order.query;
import csjpabook.csjpashop.domain.Address;
import csjpabook.csjpashop.domain.OrderStatus;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.List;
@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {
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;
}
}
OrderItemQueryDto
package csjpabook.csjpashop.repository.order.query;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
@Data
public class OrderItemQueryDto {
@JsonIgnore
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;
}
}
orderId
는 나중에 사용하기 위해 미리 추가!
OrderQueryRepository
OrderRepository
는 순수 엔티티를 다루는 용도이기 때문에 DTO 전용 리포지토리를 따로 만든다.
package csjpabook.csjpashop.repository.order.query;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
/**
* 컬렉션은 별도로 조회
* Query: 루트 1번, 컬렉션 N 번
* 단건 조회에서 많이 사용하는 방식
*/
public List<OrderQueryDto> findOrderQueryDtos() {
//루트 조회(toOne 코드를 모두 한번에 조회) : 1번
List<OrderQueryDto> result = findOrders();
//루프를 돌면서 컬렉션 추가(추가 쿼리 실행) : n번
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
/**
* 1:N 관계인 orderItems 조회
*/
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new csjpabook.csjpashop.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();
}
/**
* 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
*/
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new csjpabook.csjpashop.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();
}
}
Query
: 루트 1번, 컬렉션 N 번 실행xxxToOne
(N:1
, 1:1
) 관계들을 먼저 조회하고, xxxToMany
(1:N
) 관계는 각각 별도로 처리한다.xxxToOne 관계
는 조인해도 데이터 row 수
가 증가하지 않는다. ToMany
(1:N
) 관계는 조인하면 row 수
가 증가한다.row 수
가 증가하지 않는 xxxToOne 관계
는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, xxxToMany 관계
는 최적화 하기 어려우므로 findOrderItems()
같은 별도의 메서드로 조회한다.
실행 결과
GET
http://localhost:8080/api/v4/orders
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-14T16:26:31.049041",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-14T16:26:31.178155",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
✔️ 쿼리 세번 실행 된다.
OrderQueryRepository
(1) findOrders()
메서드를 통해 `*ToOne엔티티들을 조인한 값들을 포함한
List`를 반환한다.**
쿼리 한 번 실행 된다.
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new csjpabook.csjpashop.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();
}
***ToMany(컬렉션) 관계
는 데이터를 증가시키기 때문에 join
하지 않고 따로 메서드를 만들 것이다! (밑에)
(실제로 OrderQueryDto 생성자
에서는 OrderItem
이 포함되어 있지 않다.)
(2) findOrderItems()
호출
List<OrderQueryDto>
에서 각각 OrderQueryDto
마다 findOrderItems() 메서드
를 호출해서 List<OrderItemQueryDto>
를 OrderQueryDto
의 필드에 저장한다. private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new csjpabook.csjpashop.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();
}
orderItem
과 item
은 ***ToOne
관계이다.where
절을 보면, Order
에서 OrderItem
은 OneToMany
관계이다.forEach문
으로 두 번 호출된다.
✏️ 결론
- row 수가 증가하지 않는
***ToOne 관계
는 조인으로 최적화 하기 쉬우므로 한 번에 조회한다.***ToMany
관계는 최적화 하기 어려우므로findOrderItems()
같은 별도의 메서드로 조회한다.- N + 1 문제
findOrders()
에서 query 1번, N건 조회findOrderItems()
에서 query N번 조회
OrderApiController
에 추가
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5(){
return orderQueryRepository.findAllByDto_optimization();
}
OrderQueryRepository
에 추가
/**
* 최적화
* Query: 루트 1번, 컬렉션 1번
* 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
*
*/
public List<OrderQueryDto> findAllByDto_optimization() {
//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();
//orderItem 컬렉션을 MAP 한방에 조회
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
//루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
private List<Long> toOrderIds(List<OrderQueryDto> result) {
return result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
}
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new csjpabook.csjpashop.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();
return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}
Query
: 루트 1번, 컬렉션 1번xxxToOne
관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId
로 xxxToMany
관계인 OrderItem
을 한꺼번에 조회O(1)
)
실행 결과
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-14T17:03:06.680695",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-14T17:03:06.802302",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
1번
2번
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());
}
OrderQueryDto
에 생성자 추가
public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, List<OrderItemQueryDto> orderItems) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.orderItems = orderItems;
}
OrderQueryRepository
에 추가
public List<OrderFlatDto> findAllByDto_flat() {
return em.createQuery(
"select new " +
" csjpabook.csjpashop.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();
}
OrderFlatDto
package csjpabook.csjpashop.repository.order.query;
import csjpabook.csjpashop.domain.Address;
import csjpabook.csjpashop.domain.OrderStatus;
import lombok.Data;
import java.time.LocalDateTime;
@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; //주문 수량
public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate,
OrderStatus orderStatus, Address address, String itemName, int orderPrice, int
count) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
}
(1) 장점
Query
: 1번(2) 단점
- 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 보다 더 느릴 수 도 있다.
- 애플리케이션에서 추가 작업이 크다.
- 페이징 불가능하다.
실행 결과
[
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-14T17:44:40.092839",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
},
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-14T17:44:39.958653",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
}
]
query 1번
💡 참고
OrderQueryDto
클래스에@EqualsAndHashCode(of = "orderId")
:orderId
를 기준으로 공통인 것을 묶어준다.
📌 지금까지 한 내용 정리
(1) 엔티티 조회
- 엔티티를 조회해서 그대로 반환:
V1
- 엔티티 조회 후 DTO로 변환:
V2
- 페치 조인으로 쿼리 수 최적화:
V3
- 컬렉션 페이징과 한계 돌파:
V3.1
- 컬렉션은 페치 조인시 페이징이 불가능
xxxToOne 관계
는 페치 조인으로 쿼리 수 최적화- 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고,
hibernate.default_batch_fetch_size
,@BatchSize
로 최적화
(2) DTO 직접 조회
- JPA에서 DTO를 직접 조회:
V4
- 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화:
V5
(DTO 직접 조회할 때 많이 사용한다. 사실hibernate.default_batch_fetch_size
와 유사한 방식이지만 소스가 복잡하다.)- 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환:
V6
🔔 권장 순서
- 엔티티 조회 방식으로 우선 접근
- 페치조인으로 쿼리 수를 최적화
- 컬렉션 최적화
- 페이징 필요
hibernate.default_batch_fetch_size
,@BatchSize
로 최적화- 페이징 필요X → 페치 조인 사용
- 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
- DTO 조회 방식으로 해결이 안되면
NativeSQL
or스프링 JdbcTemplate
💡 참고
- 엔티티 조회 방식은 페치 조인이나,
hibernate.default_batch_fetch_size
,@BatchSize
같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다.- 반면에 DTO를 직접 조회하는 방식은 성능을 최적화 하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.
- 엔티티는 직접 캐시를 하면 안된다. (DTO로 캐시를 해야 한다.)
💡 참고
- 개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야 한다.
- 항상 그런 것은 아니지만, 보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고간다.
- 엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다.
- 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 줄타기를 해야 한다.
✔️ DTO 조회 방식의 선택지
V4
, V5
, V6
에서 단순하게 쿼리가 1번 실행된다고 V6
이 항상 좋은 방법인 것은 아니다.V4
는 코드가 단순하다. 특정 주문 한건만 조회하면 이 방식을 사용해도 성능이 잘 나온다. 예를 들어서 조회한 Order
데이터가 1건이면 OrderItem
을 찾기 위한 쿼리도 1번만 실행하면 된다.V5
는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4
대신에 이것을 최적화한 V5
방식을 사용해야 한다. 예를 들어서 조회한 Order
데이터가 1000건인데, V4
방식을 그대로 사용하면, 쿼리가 총 1 + 1000번
실행된다. 여기서 1은 Order
를 조회한 쿼리고, 1000은 조회된 Order의 row 수
다. V5
방식으로 최적화 하면 쿼리가 총 1 + 1
번만 실행된다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성능 차이가 날 수 있다. (V5
를 많이 사용한다. V5
는 소스가 복잡한데, hibernate.default_batch_fetch_size
를 사용시, 소스를 생략할 수 있다.)V6
는 완전히 다른 접근방식이다. 쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order
를 기준으로 페이징이 불가능하다. 실무에서는 이정도 데이터면 수백이나, 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다. 그리고 데이터가 많으면 중복 전송이 증가해서 V5
와 비교해서 성능 차이도 미비하다.