안녕세계
[Spring] JPA 성능 최적화 본문
[Spring] JPA 성능 최적화
Junhong Kim 2023. 5. 6. 23:39728x90
반응형
들어가기 전에
API 요청과 응답에는 entity를 사용하지 않고, API 스펙에 따라 별도의 DTO를 만드는 것을 권장합니다.
🙅♀️ 요청/응답에 entity를 사용하면?
- 특정 API 전용의 DTO가 아니기 때문에 어떤 요청이 넘어올지 알기 어렵습니다.
- entity 정의가 바뀌면 API 스펙이 변경되어 API가 깨집니다.
🙆♂️ 요청/응답에 DTO를 사용하면?
- entity와 presentation layer를 분리할 수 있습니다.
- entity는 DB에서 사용되는 데이터 구조를 정의하고 DB와 상호작용하는 클래스입니다.
- persentation layer는 클라이언트가 요청한 데이터를 받아 그 데이터를 가공하여 응답하는 역할을 합니다.
예제 entity 정의
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id
@GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate; //주문시간
@Enumerated(EnumType.STRING)
private OrderStatus status; //주문상태 [ORDER, CANCEL]
}
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "item_id")
private Item item;
@JsonIgnore
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "order_id")
private Order order;
private int orderPrice; //주문 가격
private int count; //주문 수량
}
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {
@Id
@GeneratedValue
@Column(name = "item_id")
private Long id;
private String name;
private int price;
private int stockQuantity;
@ManyToMany(mappedBy = "items")
private List<Category> categories = new ArrayList<>();
}
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@Embedded
private Address address;
@JsonIgnore // 양방향 관계 설정시 무한 루프 방지를 위함
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status; //READY, COMP
}
예제 DTO 정의
@Data
static 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();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(toList());
}
}
@Data
static class OrderItemDto {
private String itemName;//상품 명
private int orderPrice; //주문 가격
private int count; //주문 수량
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
OneToOne, ManyToOne 최적화
entity 조회 후 DTO 변환 (페치 조인 최적화)
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
// entity로 데이터 조회
List<Order> orders = orderRepository.findAllWithMemberDelivery();
// entity를 DTO로 변환
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(toList());
return result;
}
public List<Order> findAllWithMemberDelivery() {
// join fetch를 사용해서 연관된 엔티티 한번에 조회한다.
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.getResultList();
}
/*
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
*/
`order`를 조회할 때 OneToOne 관계인 `member`와 `delivery`를 페치 조인으로 한 번에 가져옵니다.
그 결과 연관 엔티티인 `member`와 `delivery`는 프록시 객체가 아닌 엔티티 객체가 채워져서 반환됩니다.
🚨 프로젝션 대상이 entity 일 때만 페치 조인이 가능 합니다. DTO 조회시에는 불가능합니다!
왜? 페치 조인은 엔티티 그래프에서 연관 엔티티를 한 번에 가져오기 위한 키워드이기 때문입니다.
DTO 조회
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
// DTO로 조회 후 바로 반환
return orderSimpleQueryRepository.findOrderDtos();
}
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
}
/*
select
order0_.order_id as col_0_0_,
member1_.name as col_1_0_,
order0_.order_date as col_2_0_,
order0_.status as col_3_0_,
delivery2_.city as col_4_0_,
delivery2_.street as col_4_1_,
delivery2_.zipcode as col_4_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
*/
장점
- 일반적인 SQL을 사용할 때 처럼 원하는 select 절에 원하는 데이터를 선택해서 조회할 수 있습니다.
- JPQL에서 new 명령어를 사용해서 프로젝션 결과로 DTO를 반환합니다.
- select 절에서 원하는 데이터를 직접 선택하므로 DB에서 애플리케이션의 네트워크 용량이 최적화 됩니다.
- 최적화 성능이 생각보다 미비합니다. 😅
단점
- DTO가 API 스펙에 맞춰진 코드가 Repository에 들어가며 Repository 재사용성이 떨어집니다.
- entity 반환 레포지토리(OrderRepository)와 DTO 반환 레포지토리(OrderQueryRepository)로 분류하는 것을 권장!
OneToMany 최적화
entity 조회 후 DTO 변환 (페치 조인 최적화 + distinct)
@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;
}
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" + // distinct를 사용한 것에 유의!
" join fetch o.member m" + // 1:1
" join fetch o.delivery d" + // 1:1
" join fetch o.orderItems oi" + // 1:N
" join fetch oi.item i", Order.class) // N:1
.getResultList();
}
/*
select
distinct order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
orderitems3_.order_item_id as order_it1_5_3_,
item4_.item_id as item_id2_3_4_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_,
orderitems3_.count as count2_5_3_,
orderitems3_.item_id as item_id4_5_3_,
orderitems3_.order_id as order_id5_5_3_,
orderitems3_.order_price as order_pr3_5_3_,
orderitems3_.order_id as order_id5_5_0__,
orderitems3_.order_item_id as order_it1_5_0__,
item4_.name as name3_3_4_,
item4_.price as price4_3_4_,
item4_.stock_quantity as stock_qu5_3_4_,
item4_.artist as artist6_3_4_,
item4_.etc as etc7_3_4_,
item4_.author as author8_3_4_,
item4_.isbn as isbn9_3_4_,
item4_.actor as actor10_3_4_,
item4_.director as directo11_3_4_,
item4_.dtype as dtype1_3_4_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
inner join
order_item orderitems3_
on order0_.order_id=orderitems3_.order_id
inner join
item item4_
on orderitems3_.item_id=item4_.item_id
*/
장점
- 페치 조인으로 SQL이 1번만 실행됩니다.
- 1:N 조인이 있으면 DB row가 N에 맞춰서 데이터가 뻥튀기 돼며 그 결과 중복 조회되는 `order` 엔티티 수가 증가합니다.
- 중복 조회되는 `order` 엔티티에 1:N 관계에 대한 엔티티 데이터를 모두 채워서 반환됩니다. 그러나 중복 조회된 결과가 원하는 값이 아니기 때문에 `distinct` 키워드를 사용해서 `order`가 컬렉션 페치 조인으로 인해 중복 조회되는 것을 막아야 합니다. 따라서, 중복된 데이터가 너무 많이 조회되므로 한 방쿼리도 조회했더라도 오히려 성능이 안좋을 수 있습니다. 🥲
[JPA dstinct의 두 가지 기능]
1. SQL에 distinct 키워드를 추가한다.
2. JPA에서는 같은 엔티티가 조회되면 애플리케이션에서 중복을 걸러준다. (🚨)
-> DB에서는 row가 모두 같아야 중복이 제거되므로, 실제 날라가는 쿼리를 실행해보면 중복 제거 되지 않는다.
단점
- 1:N(컬렉션)을 페치 조인하면 페이징이 불가능 합니다.
- 왜? distinct로 중복이 제거되어도 최초 쿼리에서 중복된 결과를 가져왔기 때문에 페이징이 불가능합니다.
(하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고 메모리에서 페이징합니다. 👻)
- 왜? distinct로 중복이 제거되어도 최초 쿼리에서 중복된 결과를 가져왔기 때문에 페이징이 불가능합니다.
entity 조회 후 DTO 변환 (페치 조인 최적화 + paging)
@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);
// entity를 순회하면서 DTO로 변환하면서 쿼리가 실행된다. in 절이 수행되는 것을 주의깊게 보자!
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
/*
select
orderitems0_.order_id as order_id5_5_1_,
orderitems0_.order_item_id as order_it1_5_1_,
orderitems0_.order_item_id as order_it1_5_0_,
orderitems0_.count as count2_5_0_,
orderitems0_.item_id as item_id4_5_0_,
orderitems0_.order_id as order_id5_5_0_,
orderitems0_.order_price as order_pr3_5_0_
from
order_item orderitems0_
where
orderitems0_.order_id in (
?, ?
)
*/
/*
select
item0_.item_id as item_id2_3_0_,
item0_.name as name3_3_0_,
item0_.price as price4_3_0_,
item0_.stock_quantity as stock_qu5_3_0_,
item0_.artist as artist6_3_0_,
item0_.etc as etc7_3_0_,
item0_.author as author8_3_0_,
item0_.isbn as isbn9_3_0_,
item0_.actor as actor10_3_0_,
item0_.director as directo11_3_0_,
item0_.dtype as dtype1_3_0_
from
item item0_
where
item0_.item_id in (
?, ?, ?, ?
)
*/
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();
}
/*
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id limit ?
*/
- OneToOne, ManyToOne 관계는 모두 페치 조인합니다.
- 예제에서는 `member`와 `delivery`를 페치 조인합니다.
- 컬렉션은 지연 로딩으로 조회합니다.
- 예제에서는 `orderItem`의 fetchType이 `LAZY` 입니다.
- 지연 로딩 성능 최적화를 위해 `hibernate_default_batch_fetch_size(전역 설정)` 또는 `@BatchSize(개별 설정)`를 적용합니다.
- 위 옵션을 적용하지 않으면, entity 리스트를 순회하며 DTO를 변환할 때 1:N 관계의 엔티티 조회시 N+1 문제가 발생합니다.
- 위 옵션을 적용하면, 컬렉션이나 프록시 객체를 한꺼번에 설정한 size 만큼 `in` 절로 조회합니다.
(N+1 문제를 어느정도 해결할 수 있지만 한 방 쿼리로는 만들 수는 없습니다. 🤨)
장점
- 쿼리 호출 수가 `1 + N + N` 에서 `1 + 1 + 1`로 최적화됩니다.
- Order 1번, OrderItem N번, Item N번 -> Order 1번, OrderItem 1번, Item 1번
- 컬렉션 fetch join 한 것보다 DB 데이터 전송량이 최적화됩니다.
- 이전에는 Order와 OrderItem을 1:N 조인을하면 Order가 OrderItem 만큼 중복해서 조회됐지만, 이번 방법은 각각 조회하므로 데이터가 중복해서 조회되지 않습니다.
- 이전과 비교해서 쿼리 호출 수가 조금 증가하지만 DB 데이터 전송량이 감소합니다.
- 왜? 중복된 데이터는 가져오지 않기 때문입니다.
- 컬렉션 fetch join은 페이징이 불가능하지만 이번 방법은 paging이 가능합니다.
- 쿼리 결과를 페이징해야 한다면 이번 방법을 사용해야합니다! 👊
요약
OneToOne, OneToMany 관계는 페치 조인해도 페이징에 영향을 주지 않습니다. 따라서, ToOne 관계는 먼저 fetch join으로 쿼리 수를 줄입니다. 이후 OneToMany 관계의 컬렉션 조인은 `hibernate_default_batch_fetch_size(전역 설정)` 또는 `@BatchSize(개별 설정)`로 최적화합니다.
DTO로 직접 조회
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findAllByDto_optimization();
}
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 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 절 사용
.setParameter("orderIds", orderIds)
.getResultList();
return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}
- 이전과 동일하게 OneToOne, ManyToOne 관계들은 먼저 `Order`와 함께 join하여 조회하고, 조회된 Order의 orderId를 추출하여 OneToMany 관계인 `OrderItem`의 조회시 in 절에 사용하여 OrderItem 을 쿼리 한 번에 조회합니다. 이때, orderItem의 ToOne 관계인 `Item`을 함께 조회합니다.
- 그리고 여기서 눈 여겨 보아야할 것 은 orderItemMap을 만들어서 애플리케이션에서 Order의 OrderItem을 셋팅해 주었다는 것 입니다. 이 방법을 사용해서 Order의 OrderItem을 매핑하는 성능을 향상 시킬 수 있습니다.
컬렉션 조회 최적화 정리
entity 조회
- OneToOne, ManyToOne 관계는 fetch join으로 쿼리 수를 최적화합니다.
- ToOne 관계는 fetch join 하더라도 대상이 1이기 때문에 데이터가 뻥튀기 돼지 않습니다.
- 컬렉션 fetch join 시 페이징이 불가능하므로 다음 과정을 진행하여 최적화합니다.
- 컬렉션은 fetch join 대신에 Lazy Loading 설정을 유지합니다.
- `hibernate.default_batch_fetch_size` 또는 `@BatchSize` 설정으로 지연 로딩 최적화합니다.
DTO 직접 조회
- entity 조회와 동일하게 OneToOne, ManyToOne 관계는 fetch join으로 쿼리 수를 최적화합니다.
- OneToMany 관계인 컬렉션은 `in` 절을 사용해서 메모리에 미리 조회하고 이후 값을 셋팅 해주는 방법으로 최적화합니다.
- 즉, 1:N 관계를 메모리에 다 로딩하고 1에 해당하는 컬렉션을 애플리케이션에서 매핑합니다.
최적화 권장 순서
- entity 조회 방식 사용
- 모든 연관관계는 지연 로딩으로 변경합니다.
- 왜? 즉시 로딩은 예상치 못한 쿼리가 실행될 수 있습니다.
- ToOne 관계
- fetch join 으로 쿼리 수를 최적화합니다.
- ToOne 관계는 fetch join 하더라도 대상이 1이기 때문에 데이터가 뻥튀기 돼지 않습니다.
- ToMany 관계
- 페이징 불필요시: `fetch join` + `distinct`
- 페이징 필요시: `hibernate.default_batch_fetch_size` 또는 `@BatchSize` 설정으로 지연 로딩 최적화
- 모든 연관관계는 지연 로딩으로 변경합니다.
- entity 조회 방식으로 해결이 안되면?
- DTO 조회 방식 사용해서 네트워크 전송량을 줄입니다.
- DTO 조회 방식으로도 해결이 안되면 NativeSQL 또는 스프링 JdbcTemplate을 사용해서 최적화합니다.
- 최후의 수단
참고
728x90
반응형
'Server > Spring' 카테고리의 다른 글
[Spring] @PostPersist와 TransactionPhase.AFTER_COMMIT (0) | 2024.06.30 |
---|---|
[Spring] SSE란? (feat. SseEmitter) (0) | 2024.03.03 |
[Spring] 그라파나와 로키로 애플리케이션 로그 조회하기 (0) | 2023.04.22 |
[Spring] 그라파나와 프로메테우스로 애플리케이션 모니터링하기 (0) | 2023.04.09 |
[Spring] EventListener vs TransactionalEventListener (0) | 2023.03.08 |
Comments