이전에 엔티티에서 fetch join이 무엇인지, 그리고 이를 이용한 성능 최적화에 대해 알아 보았다.
이번에는 컬렉션 페치 조인을 알아보자.
예제 소개
먼저, 예제에서 쓰일 엔티티들에 대해서 가볍게 설명하고 넘어가겠다.
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
@JsonIgnore
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@JsonIgnore
@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<>();
}
order를 기준으로 orderItem과 Item은 컬렉션이다.
앞선 포스팅에서는 OneToOne 과, ManyToOne 관계에서 성능 최적화를 하는 방법을 보여줬지만, 이번에는 OneToMany 관계인 컬렉션에서 성능 최적화 하는 것을 써보려 한다.
Version 1) 엔티티 직접 노출
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
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;
}
엔티티를 직접 노출하고 있는 version 1 컨트롤러 코드이다.
강제로 지연로딩을 초기화 해주며, 만일 양방향 연관관계로 사용중이라면, 한쪽엔 @JsonIgnore를 달아줘야 무한루프에 빠지지 않는다.
엔티티를 직접 노출하고 있으므로 딱히 좋아보이진 않는다.
version 2) 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.getMember().getAddress();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(Collectors.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();
}
}
OrderDto를 생성해주었다.
그리고 안에서 OrderItem과 1대다 연관관계를 맺고있으므로 이에 대한 OrderItemDto도 같이 생성해주었다.
OrderItem 생성자에서 OrderItemDto를 호출하여 사용해준다.
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2(){
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
해당 컨트롤러 코드에서는 Dto로 잘 변환해서 반환해주고 있다.
허나, 앞선 포스팅에서 이렇게 사용했을 때처럼, 너무 많은 지연로딩이 걸린다.
order 가 1번 호출되고, 그에 따른 member,address orderItem이 여러번 호출되게 된다. 이를 N+1 문제라고 하였다. 이러한 쿼리문의 남용은 성능 문제로 이어지고, 이제부터 페치 조인으로 풀어보도록 하겠다.
version 3) 페치 조인 사용
@GetMapping
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" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
위처럼 컬렉션에서도 페치 조인을 적용해주면 된다. 허나 달라진 점은 distinct라는 구문이 들어간 것이다.
distinct 구문을 쓰지 않으면 1대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은 order 엔티티의 조회 수도 증가하게 된다. JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애 플리케이션에서 중복을 걸러준다. 이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.
하지만, 컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버리고 이는 매우 위험한 현상이다. 또한, 컬렉션 엔티티가 중복 조회된다. 이말은 즉슨, 데이터가 중복되어 조회되며, 데이터가 뻥튀기되어 도출되어 버린다는 것이다. 이 문제점을 이제 고치고 성능 최적화도 하며 두마리 토끼를 잡아보자!
version 3.1) 문제점 해결 및 성능 최적화
1. XXToOne 관계는 페치조인을 해주자.
ToOne 관계는 앞서 말했던 것처럼 문제 없이 페치조인을 이용해 성능 최적화가 된다.
2.컬렉션은 지연 로딩으로 조회하고, 지연 로딩 성능 최적화를 위해 `hibernate.default_batch_fetch_size` (글로벌), `@BatchSize` (세부설정)를 적용한다.
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();
}
그리고 해당 코드를 레포지토리에 추가해준다.
아까 코드와 달라진 점은 distinct 구문을 제거해주고, offset,limit을 적용해준다는 점이다.
setFirstResult로 결과값이 도출될 첫번째 번호를, setMaxResults로 페이징 최대치를 적어준다.
@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;
}
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
default_batch_fetch_size를 설정파일에 추가하여 글로벌 옵션을 적용해준다.
개별로 설정하려면 `@BatchSize` 를 적용하면 된다. -> 컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용
이렇게 되면, N+1문제도 해결되고, 조인보다 DB 데이터 전송량이 최적화 된다. (Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.) 이렇게 하여 페이징을 가능하게 만들어 주었다.
위 코드를 사용시 발생하는 쿼리문
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 ?
offset ?
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_
-- in 절로 땡겨온다.
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_
-- in 절로 땡겨온다.
where item0_.item_id in (
?, ?
)
컬렉션 관계는 Batch_size로 IN절로 SIZE만큼 한꺼번에 몰아서 가져온다
default_batch_fetch_size 사이즈 정하기
default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를선택하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.
'Spring' 카테고리의 다른 글
[Spring JPA] JPA에서 OSIV와 커맨드,쿼리의 분리 (2) | 2024.07.18 |
---|---|
[Spring] 인증, 인가 그리고 JWT (0) | 2024.07.17 |
[Spring JPA] JPA 에서 fetch join(페치 조인)이란 (0) | 2024.07.14 |
컴포넌트 스캔 (0) | 2023.08.05 |
싱글톤 컨테이너 (2) | 2023.07.09 |