[JPA 활용] 컬렉션 조회 최적화
[JPA 활용] 컬렉션 조회 최적화
컬렉션 조회
- 이번 게시글에서는 엔티티 내부에 있는 컬렉션 필드의 조회를 최적화하는 방법에 대해서 알아본다.
- 컬렉션 필드를 쓰면 쿼리가 많이 나가서 최적화를 상당히 신경써야 한다.
v1 - 엔티티를 직접 노출
- 엔티티를 그대로 반환하기 때문에 API에서 사용하기에는 좋지 않은 방법
- 지연 로딩에 의해 프록시로 존재하는 부분을 강제로 초기화해줘야 한다.
//주문 조회 v1
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAll();
for (Order order : all) {
order.getMember().getName(); //Lazy 강제 초기화
order.getDelivery().getAddress(); //Lazy 강제 초기환
List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제 초기화
}
return all;
}public List<Order> findAll() {
return
em.createQuery("select o from Order o", Order.class)
.getResultList();
}Hibernate5Module
- JPA 사용 시 일반적으로 fetch 전략을 LAZY로 잡기 때문에 실제 엔티티 객체 대신에 프록시 객체를 갖고 있다.
- jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생한다.
- 이를 해결하기 위해 Hibernate5Module 또는 Hibernate5JakartaModule를 스프링 빈으로 등록해준다.
스프링 부트 2.X를 사용 중인 경우
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'@Bean
Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}스프링 부트 3.X를 사용 중인 경우
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'@Bean
Hibernate5JakartaModule hibernate5Module() {
return new Hibernate5JakartaModule();
}@JsonIgnore
- 엔티티를 직접 반환할 때 해당 엔티티에 양방향 연관관계가 존재한다면 양측 엔티티가 서로 호출하면서 무한 루프가 발생한다.
@JsonIgnore를 추가해서 순환 참조를 막는다.@JsonIgnore는 주로 @ManyToOne 어노테이션이 있는 필드에 추가하면 된다.
v2 - 엔티티를 DTO로 변환
- 엔티티를 DTO로 변환 후 반환하는 방법
- 조회한 엔티티를 DTO의 생성자를 통해 DTO로 변환한다.
- 장점
- 해당 API를 위한 어느정도 고정된 스펙의 DTO를 반환하기 때문에 API의 스펙이 변경될 일이 적다.
- 단점
- 지연 로딩이기 때문에 N + 1의 문제가 존재한다.
- 발생하는 문제의 유형을 부르는 이름이 N + 1인거지 실제로는 더 많은 쿼리가 발생할 수도 있다.
- 컬렉션의 제네릭으로 사용되는 엔티티에 따라서 어마어마하게 많은 추가 쿼리가 발생할 수도 있다.
- 팁
map(o -> new OrderDto(o))는 람다 레퍼런스 방식으로map(OrderDto::new)처럼 표현할 수 있다.
//주문 조회 v2
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAll();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}public List<Order> findAll() {
return
em.createQuery("select o from Order o", Order.class)
.getResultList();
}@Data
public class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); //Lazy 강제 초기화 (Member 엔티티)
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); //Lazy 강제 초기화 (Member 엔티티)
orderItems = order.getOrderItems().stream() //Lazy 강제 초기화 (Member 엔티티)
.map(orderItem -> new OrderItemDto(orderItem))
.collect(toList());
}
}
@Data
public class OrderItemDto {
private String itemName; //상품 명
private int orderPrice; //주문 가격
private int count; //주문 수량
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName(); //Lazy 강제 초기화 (Member 엔티티)
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}v3 - 페치 조인 최적화
- 페치 조인(fetch join)으로 데이터를 한꺼번에 가져온다.
- 엔티티로 조회했기 때문에 DB와의 작업이 수월하다. (ex : 변경 감지)
- 유연도가 높은 방식이다. (v4에 비해서 비교적 높은 편)
- 컬렉션 페치 조인은 하나만 가능하다.
- 억지로 2건 이상이 가능은 한데 하이버네이트가 제대로 쿼리나 쿼리의 결과를 인식 못 할 가능성이 크다.
- distinct 키워드를 사용하면 JPA가 중복 데이터를 제거할 수 있게 설정할 수 있다.
- 하이버네이트 6.0에서는 distinct 키워드를 명시하지 않아도 JPA가 자동으로 중복 제거를 시도한다.
- 문제점
- 컬렉션 조회 & 페치 조인을 할 때는 페이징이 안 된다.
- 정확하게는 페이징이 되는건 맞는데 모든 데이터를 가져와서 메모리 내부에서 페이징 작업을 한다.
- 데이터가 많으면 아웃 오브 메모리가 발생한다.
//주문 조회 v3
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}//distinct 키워드로 중복 제거
//하이버네이트 6.0부터는 distinct 키워드 안 써도 JPA가 알아서 중복을 제거해줌
public List<Order> findAllWithItem() {
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();
}v3.1 - 페이징과 한계 돌파
- 사실 v3에서 페이징을 사용할 수 있는 방법이 있다.
- v3에서 설명했다시피 한꺼번에 가져와서 정렬하는 것이 문제다.
- 한꺼번에 가져오는 데이터의 개수에 제한을 설정하면 된다.
- 환경설정 방식
hibernate.default_batch_fetch_size
- 어노테이션 방식
@BatchSize
- 환경설정 방식
- 컬렉션 조회때문에 1대N대M였던 것을 1대1대1로 바꿀 수 있다.
- “사이즈를 설정한다.”라는 것은 “쿼리 내부에서 사용되는 IN 절에서 포함되는 요소의 개수를 설정한다는 것을 의미한다.
- 사이즈의 숫자는 발생하는 쿼리에 따라 조절해야 한다.
- 일반적으로 100 ~ 1000이 권장된다.
- XToOne 관계만 우선 모두 페치 조인으로 최적화한다.
//주문 조회 v3.1
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return
em.createQuery("select o from Order o "
+ "join fetch o.member m "
+ "join fetch o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}v4 - JPA에서 DTO로 바로 조회
- DB에서 조회한 데이터를 엔티티가 아닌 DTO로 바로 받는 방식
- DTO로 조회했기 때문에 DB와의 작업이 수월하지 못 하다. (ex : 변경 감지)
- JPQL 작성 방법이 좀 번거롭다. (패키지명 직접 명시)
- 1대1이나 N대1 관계를 먼저 조회한 다음에, 1대N 관계를 조회한다. (반복문 이용)
- 단건 조회에서 많이 사용하는 방식
- 유연도가 낮은 방식이다. (v3에 비해서 비교적 낮은 편)
- 컬렉션은 별도로 조회한다.
- 쿼리가 1 + N만큼 실행된다.
- 화면에 최적화되 있는 방식
- 대신에 특정 DTO에 의존도가 높다.
//주문 조회 v4
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueryDtos();
}//컬렉션은 별도로 조회한다.
//쿼리가 1 + N만큼 실행된다.
//단건 조회에서 많이 사용하는 방식
public List<OrderQueryDto> findOrderQueryDtos() {
//1대N이 관게가 아닌 항목들을 한꺼번에 조회한다.
List<OrderQueryDto> result = findOrders(); //쿼리 1번 실행
//반복문을 돌면서 1대N 관계인 컬렉션을 조회한다.
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId()); //쿼리 N번 실행
o.setOrderItems(orderItems);
});
return result;
}
//컬렉션 아닌 항목들을 한꺼번에 조회
//1대N 관계가 아닌 항목들을 한꺼번에 조회
private 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();
}
//1대N 관계인 orderItems을 조회한다.
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();
}@Data
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;
}
}
@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;
}
}v5 - 컬렉션 조회 최적화
- 컬렉션을 IN 절을 통해 한꺼번에 조회하는 방식
- IN 절에서 사용할 id 값 목록을 미리 만들어야 한다.
- v4의 N + 1 문제가 해결된다.
- Collectors.groupingBy를 사용하기 위해서는
@EqualsAndHashCode(of = "orderId")처럼 객체끼리 구분할 수 있는 정확한 방법을 명시해줘야 한다.- Collectors.groupingBy는 그룹화 키를 기준으로 엔티티를 그룹화하기 위해 엔티티의 해시코드를 사용한다.
- 엔티티에 @EqualsAndHashCode 어노테이션이 없으면 기본 해시코드 구현이 사용된다.
- 기존 해시코드 구현은 엔티티의 식별자를 고려하지 않기 때문에 동일한 식별자를 가진 엔티티가 서로 다른 그룹으로 분류될 수 있다.
- 아래 예시의 경우에는 OrderItemQueryDto의 orderId를 기준으로 그룹화하기 때문에 OrderQueryDto에 어노테이션을 추가해줘야 한다.
//주문 조회 v5
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findAllByDto_optimization();
}//데이터를 한꺼번에 처리할 때 많이 사용하는 방식
public List<OrderQueryDto> findAllByDto_optimization() {
//1대1이나 N대1인 관계의 엔티티를 먼저 조회한다.
List<OrderQueryDto> result = findOrders();
//orderItem 컬렉션을 하나의 Map 안에 한꺼번에 저장한다.
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
//반복문을 통해서 컬렉션을 추가한다.
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
//컬렉션 아닌 항목들을 한꺼번에 조회
//1대N 관계가 아닌 항목들을 한꺼번에 조회
private 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();
}
//Id 값만 별개의 List로 추출한다.
private List<Long> toOrderIds(List<OrderQueryDto> result) {
return result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
}
//하나의 Map 안에 한꺼번에 저장한다.
//":orderIds" 부분에 orderIds가 바인딩된다.
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) //이 부분에 있는 IN절이 N번 조회할 것을 1번만 조회하게 해준다.
.setParameter("orderIds", orderIds)
.getResultList();
return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId)); //람다 레퍼런스 사용
}v6 - 플랫 데이터 최적화
- Inner Join으로 쿼리 한 번으로 모든 데이터를 가져오는 방법
- groupingBy()를 위해 ` @EqualsAndHashCode(of = “xxx”)`를 지정해야 한다
- 장점
- 쿼리를 한 번만 실행한다.
- 플랫 데이터에 최적화되어 있다.
- 단점
- 쿼리를 한 번만 실행하기 위해 많은 조인이 발생한다.
- DB에서 애플리케이션에 전달하는 중복되는 데이터의 양이 증가한다.
- 상황에 따라서 성능이 하락할 수도 있다.
- 애플리케이션에서 해야 할 추가 작업이 크다.
- 분해하고 재조립하는 등 할 일이 많고 어렵고 복잡하다.
- 데이터 중복으로 인하여 페이징이 불가능하다.
- 쿼리를 한 번만 실행하기 위해 많은 조인이 발생한다.
//주문 조회 v6
@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());
}public List<OrderFlatDto> findAllByDto_flat() {
return 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();
}@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;
}
}결론
- 왠만하면 엔티티로 조회 후 DTO로 변환한다.
- 만약 성능이 떨어지면 페치 조인을 사용한다.
- 컬렉션을 최적화하자.
- 페이징이 필요하다면
hibernate.default_batch_fetch_size또는@BatchSize를 설정한다. - 페이징이 필요없다면 페치 조인을 사용한다.
- 페이징이 필요하다면
- 엔티티 조회 방식으로 해결이 안 되면 그 때부터 DTO 조회 방식을 사용한다.
- DTO 조회 방식으로도 해결되지 않는다면 Navite Query나 스프링 JdbcTemplete을 사용한다.
- 어지간해서는 페치 조인으로 성능이 많이 개선된다.
- 페치 조인으로도 안 되면 캐시를 사용하는 걸 고려해보는 것이 좋다.
- 엔티티는 영속성 컨텍스트에서 관리되기 때문에 엔티티를 캐시로 사용하면 안 된다.
- DTO만 캐시로 사용해야 한다.
출처
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.