JPA 스케줄러 최적화: 벌크 업데이트로 쿼리 폭탄 해제하기

Soyun_p·2025년 3월 20일

💣🔧트러블슈팅

목록 보기
4/5
post-thumbnail

기존 코드

@Service
@EnableScheduling
@RequiredArgsConstructor
public class OrderStatusScheduler {

    private final OrderRepository orderRepository;

    @Scheduled(fixedRate = 60000) //1분마다 실행
    public void updateOrderStatus(){
        LocalDateTime now = LocalDateTime.now();

        //ORDER_RECEIVED 상태에서 1일 지난 주문 -> PAYMENT_COMPLETED
        updateStatusForOrders(OrderStatus.ORDER_RECEIVED, OrderStatus.PAYMENT_COMPLETED, now.minusDays(1));

        // PAYMENT_COMPLETED 상태에서 1일 지난 주문들 -> PRODUCT_PREPARING
        updateStatusForOrders(OrderStatus.PAYMENT_COMPLETED, OrderStatus.PRODUCT_PREPARING, now.minusDays(1));

        //PRODUCT_PREPARING 상태에서 1일 지난 주문들 -> SHIPMENT_START
        updateStatusForOrders(OrderStatus.PRODUCT_PREPARING, OrderStatus.SHIPMENT_STARTED, now.minusDays(1));

        //SHIPMENT_STARTED 상태에서 1일 지난 주문들 -> SHIPMENT_PROCESSING
        updateStatusForOrders(OrderStatus.SHIPMENT_STARTED, OrderStatus.SHIPMENT_PROCESSING, now.minusDays(1));

        //SHIPMENT_PROCESSING 상태에서 1일 지난 주문들 -> DELIVERED
        updateStatusForOrders(OrderStatus.SHIPMENT_PROCESSING, OrderStatus.DELIVERED, now.minusDays(1));
    }

    private void updateStatusForOrders(OrderStatus currentOrderStatus, OrderStatus newOrderStatus, LocalDateTime beforeDate){
        List<Order> orders = orderRepository.findByStatusAndCreatedAtBefore(OrderStatus.PAYMENT_COMPLETED, beforeDate);

        for(Order order : orders){
            order.updateStatus(newOrderStatus);
            orderRepository.save(order);
        }
    }
}

OrderStatusScheduler.java

    List<Order> findByStatusAndCreatedAtBefore(OrderStatus status, LocalDateTime createdAt);

OrderRepository.java

Hibernate: select o1_0.id,o1_0.address,o1_0.created_at,o1_0.delete_status,o1_0.detail_address,o1_0.order_number,o1_0.payment_id,o1_0.phone,o1_0.recipient,o1_0.status,o1_0.total_price,o1_0.updated_at,o1_0.user_id,o1_0.zonecode from orders o1_0 where o1_0.status=? and o1_0.created_at<?
Hibernate: select o1_0.id,o1_0.address,o1_0.created_at,o1_0.delete_status,o1_0.detail_address,o1_0.order_number,o1_0.payment_id,o1_0.phone,o1_0.recipient,o1_0.status,o1_0.total_price,o1_0.updated_at,o1_0.user_id,o1_0.zonecode from orders o1_0 where o1_0.status=? and o1_0.created_at<?
Hibernate: select o1_0.id,o1_0.address,o1_0.created_at,o1_0.delete_status,o1_0.detail_address,o1_0.order_number,o1_0.payment_id,o1_0.phone,o1_0.recipient,o1_0.status,o1_0.total_price,o1_0.updated_at,o1_0.user_id,o1_0.zonecode from orders o1_0 where o1_0.status=? and o1_0.created_at<?
Hibernate: select o1_0.id,o1_0.address,o1_0.created_at,o1_0.delete_status,o1_0.detail_address,o1_0.order_number,o1_0.payment_id,o1_0.phone,o1_0.recipient,o1_0.status,o1_0.total_price,o1_0.updated_at,o1_0.user_id,o1_0.zonecode from orders o1_0 where o1_0.status=? and o1_0.created_at<?
Hibernate: select o1_0.id,o1_0.address,o1_0.created_at,o1_0.delete_status,o1_0.detail_address,o1_0.order_number,o1_0.payment_id,o1_0.phone,o1_0.recipient,o1_0.status,o1_0.total_price,o1_0.updated_at,o1_0.user_id,o1_0.zonecode from orders o1_0 where o1_0.status=? and o1_0.created_at<?

🔹 1분에 300회 발생하던 쿼리 오노우,,,
🔹 5개 SELECT 쿼리 + N개 UPDATE 쿼리

변경한 코드

@Service
@EnableScheduling
@RequiredArgsConstructor
public class OrderStatusScheduler {

    private final OrderRepository orderRepository;

    @Scheduled(fixedRate = 60000) //1분마다 실행
    public void updateOrderStatus(){
        LocalDateTime now = LocalDateTime.now();

        //ORDER_RECEIVED 상태에서 1일 지난 주문 -> PAYMENT_COMPLETED
        updateStatusForOrders(OrderStatus.ORDER_RECEIVED, OrderStatus.PAYMENT_COMPLETED, now.minusDays(1));

        // PAYMENT_COMPLETED 상태에서 1일 지난 주문들 -> PRODUCT_PREPARING
        updateStatusForOrders(OrderStatus.PAYMENT_COMPLETED, OrderStatus.PRODUCT_PREPARING, now.minusDays(1));

        //PRODUCT_PREPARING 상태에서 1일 지난 주문들 -> SHIPMENT_START
        updateStatusForOrders(OrderStatus.PRODUCT_PREPARING, OrderStatus.SHIPMENT_STARTED, now.minusDays(1));

        //SHIPMENT_STARTED 상태에서 1일 지난 주문들 -> SHIPMENT_PROCESSING
        updateStatusForOrders(OrderStatus.SHIPMENT_STARTED, OrderStatus.SHIPMENT_PROCESSING, now.minusDays(1));

        //SHIPMENT_PROCESSING 상태에서 1일 지난 주문들 -> DELIVERED
        updateStatusForOrders(OrderStatus.SHIPMENT_PROCESSING, OrderStatus.DELIVERED, now.minusDays(1));
    }

    private void updateStatusForOrders(OrderStatus currentStatus,
                                       OrderStatus newStatus,
                                       LocalDateTime cutoff) {
        orderRepository.bulkUpdateStatus(
                currentStatus,
                newStatus,
                cutoff
        );
    }
}

OrderStatusScheduler.java

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Modifying
    @Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :currentStatus AND o.createdAt <= :cutoff")
    int bulkUpdateStatus(@Param("currentStatus") OrderStatus currentStatus, 
                        @Param("newStatus") OrderStatus newStatus,
                        @Param("cutoff") LocalDateTime cutoff);
}

OrderRepository.java

Hibernate: update orders o1_0 set status=? where o1_0.status=? and o1_0.created_at<=?
Hibernate: update orders o1_0 set status=? where o1_0.status=? and o1_0.created_at<=?
Hibernate: update orders o1_0 set status=? where o1_0.status=? and o1_0.created_at<=?
Hibernate: update orders o1_0 set status=? where o1_0.status=? and o1_0.created_at<=?
Hibernate: update orders o1_0 set status=? where o1_0.status=? and o1_0.created_at<=?

🔹 5개 UPDATE 쿼리만 실행 (SELECT 없이 바로 상태 변경)
🔹 하루 1회로 감소
🔹 데이터베이스 부하가 99.6% 이상 줄어드는 효과
🔹 테스트 환경에서 10만 건 데이터 기준 처리 시간이 8.2초 → 0.3초로 개선된 사례

0개의 댓글