코딩딩/Spring

JPA 엔티티 컬렉션 성능 최적화

전낙타 2023. 12. 30. 12:44

JPA를 사용하며 OneToMany 관계를 DTO로 변경하는 과정에서 N+1 문제가 심심치 않게 발생하는 것을 발견할 수 있다.

기존엔 방법을 몰라 모든 연관관계를 fetch join으로 처리하면 되겠다고 생각했지만 해당 방법은 페이징 처리가 불가능하다는 단점이 있다는 사실을 알게되었다.

 

그렇다면 해결 방법으로는 어떤것들이 있을까?

 

실습에 사용될 Entity

 

OrderEntity

@Entity
@Getter @Setter
@Table(name = "orders")
// 이렇게 선언해주면 함부로 기본 생성자로 객체를 생성할 수 없다
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    // 추가 작업 없이 Order만 persist해주면 추가된 객체가 같이 저장됨(삭제도 마찬가지)
//    BatchSize를 사용하는것보단 defult_batch_fetch_size로 최적화하는걸 권장
//    @BatchSize(size = 1000)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate; // 주문시간

    @Enumerated(EnumType.STRING)
    private OrderStatus status; // 주문상태

 

OrderItem Entity

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;
    private int count;

 

Item Entity

//@BatchSize(size = 100)
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter
public abstract class Item {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    // ManyToOne이 아닌 ManyToMany의 관계여서 컬럼 위쪽이 아닌 엔티티 최상단에 BatchSize를 작성해줘야 한다.
    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();

}

 

 

Controller와 Repository

 

Controller

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v3.1/orders")
    public List<OrderDto> ordersV3_page(
            @RequestParam(value = "offset", defaultValue = "0") int offset,
            @RequestParam(value = "limit", defaultValue = "100") int limit
    ) {
        // OrderItems 때문에 N+1 문제가 발생한다.
        // 하지만 페이징 쿼리를 적용 가능하긴 하다
        // 이 문제를 해결할 수 있는 방법이 default_batch_fetch_size를 yml에 작성해주는것
        // where문의 in 쿼리를 줘서 설정한 값만큼 결과를 한번에 땡겨온다
        // 해당 방법으로 웬만한 최적화는 모두 가능하다
        List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
        return orders.stream()
                .map(OrderDto::new)
                .collect(Collectors.toList());
    }
    
    @Getter
    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();
            order.getOrderItems().forEach(o -> o.getItem().getName());
            orderItems = order.getOrderItems().stream()
                    .map(OrderItemDto::new)
                    .collect(Collectors.toList());
        }
    }

    @Getter
    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();

        }
    }
}

 

Repository

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

	public List<Order> findAllWithMemberDelivery() {
        // EAGER 대신 Fetch join을 사용하자
        // ToOne 관계는 페이징에 영향을 주지 않기 때문에 fetch join으로 최적화 하자
        return em.createQuery(
                        "select o from Order o" +
                                " join fetch o.member m" +
                                " join fetch o.delivery", Order.class
                )
                .getResultList();
    }

 

 

Batch size를 설정해주는 yml

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/jpashop
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
#		여기서 batch사이즈를 설정해주면 된다
        default_batch_fetch_size: 100
logging.level:
  org.hibernate.SQL: debug

 

다음과 같은 방법으로 OneToOne, ManyToOne 관계는 모두 페치조인 해준다 (결과를 조회해왔을때 row수가 증가되지 않기 때문에 페이징 쿼리에 영향을 주지 않는다)

컬렉션은 지연로딩으로 조회하고 성능 최적화를 위해 batch size를 적용해준다.

batch size를 적용해주면 설정한 size만큼 in 쿼리를 추가해 N+1 문제를 해결해준다.

 

그 밖에 JPA에서 DTO를 직접 조회하는 방법도 있는데 이는 다음에 서술하겠다.