1. 스케줄러의 비동기 실행과 트랜잭션 관리의 충돌

@Scheduled 메서드는 별도의 스레드에서 비동기로 실행된다. 즉, 동시에 실행할 수 없다 !
Spring의 @Transactional은 기본적으로 현재 실행 중인 스레드의 컨텍스트에 트랜잭션 정보를 저장하여 관리한다.

그러나 스케줄러의 경우 비동기적으로 동작하기 때문에 트랜잭션 컨텍스트가 제대로 전파되지 않을 가능성이 있다는 것

문제 예시

  • 트랜잭션 내에서 조회한 엔티티가 영속성 컨텍스트에서 관리되지 않을 수 있음
  • 비동기로 실행되면서 트랜잭션이 제대로 커밋되지 않거나 롤백되지 않는 상황이 발생할 수 있음.

2. 트랜잭션 경합

스케줄러가 실행 중일 때 데이터베이스에서 읽거나 업데이트를 수행하면 다른 트랜잭션과 경합할 가능성이 있따.

특히, 스케줄러가 실행되는 동안 또 다른 트랜잭션이 동일한 데이터를 처리하면 Deadlock이나 Optimistic Locking Failure와 같은 문제가 발생할 수 있다.

예시

  1. 스케줄러가 PaymentEntity의 상태를 업데이트하려고 할 때, 다른 트랜잭션이 동일한 엔티티를 변경 중이라면 데이터베이스 락 경합 발생.
  2. 트랜잭션이 롤백되거나 불완전하게 저장되는 문제가 생길 수 있음.

3. 트랜잭션 범위와 데이터 무결성

스케줄러는 일반적으로 많은 데이터를 일괄 처리하도록 설계된다. 트랜잭션 범위가 커지면 아래와 같은 문제가 발생할 수 있다:

  1. 트랜잭션 타임아웃: 스케줄러가 처리해야 할 데이터 양이 많고 작업 시간이 길어지면 트랜잭션 타임아웃이 발생할 수 있음.
  2. 부분 실패 처리 문제: 한 엔티티의 업데이트가 실패했을 경우, 전체 트랜잭션이 롤백됩니다. 이로 인해 다른 엔티티의 상태도 저장되지 않을 수 있음.

 


4. Spring의 프록시와 내부 메서드 호출

Spring의 @Transactional은 AOP(Aspect-Oriented Programming) 방식으로 동작하며, 프록시 객체가 트랜잭션을 관리한다.
스케줄러 내에서 동일한 클래스의 private 메서드나 내부 메서드를 호출하면 트랜잭션이 적용되지 않을 수 있다.

 


결론

  • 스케줄러는 트랜잭션과 함께 사용될 때 비동기 실행 및 데이터 경합 문제로 인해 특별한 주의가 필요
  • 트랜잭션 범위와 전파 설정, 데이터 분리 처리 등을 통해 문제를 완화할 수 있다.
  • 특히 작은 단위로 트랜잭션을 나누어 관리하는 방식이 가장 안전하고 효율적인 해결책이다.

 

변경 전

package com.example.demo.payment.service.impl;

import java.time.LocalDateTime;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.demo.coupon.service.UserCouponService;
import com.example.demo.payment.entity.PaymentEntity;
import com.example.demo.payment.entity.PaymentEntity.DeliveryStatus;
import com.example.demo.payment.repository.PaymentRepository;
import com.example.demo.user.entity.UserEntity;
import com.example.demo.user.repository.UserRepository;

@Service
public class PaymentScheduler {

    @Autowired
    private PaymentRepository paymentRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PaymentServiceImpl paymentService; // 기존 PaymentServiceImpl 주입

    @Autowired
    private UserCouponService userCouponService;

    
    /**
     * 스케줄러는 5분마다 실행된다. DeliveryStatus가 CONFIRMED가 아닌 데이터 중
     * 결제 후 30분이 지난 데이터를 업데이트하기
     */
    @Transactional
    @Scheduled(fixedRate = 300000) // 5분마다 실행
    public void updateDeliveryStatus() {
        // 현재 시간 기준으로 30분 이상 경과한 데이터만 조회
        LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(30);

        List<PaymentEntity> payments = paymentRepository.findByDeliveryStatusNotAndPaymentDateBefore(
            DeliveryStatus.CONFIRMED, cutoffTime
        );

        // 상태 변경하고 저장하기
        for (PaymentEntity payment : payments) {
            DeliveryStatus currentStatus = payment.getDeliveryStatus();
            DeliveryStatus newStatus = getNextDeliveryStatus(currentStatus);
            System.out.println("현재 배송상태 : " + currentStatus);
            System.out.println("다음 배송상태 : " + newStatus);

            if (newStatus != null && newStatus != currentStatus) {
                payment.setDeliveryStatus(newStatus);
                paymentRepository.save(payment); // 상태가 변경된 경우만 저장
                System.out.println("Payment ID " + payment.getId() + " 상태 변경: " 
                    + currentStatus + " → " + newStatus);

                // CONFIRMED 상태로 변경되면 등급 업데이트 및 쿠폰 발급 로직 호출
                if (newStatus == DeliveryStatus.CONFIRMED) {
                    updateUserGradeAndIssueCoupon(payment.getUserEntity().getId());
                }
            }
        }
    }

    // 현재 상태에 따라 다음 상태 반환
    private DeliveryStatus getNextDeliveryStatus(DeliveryStatus currentStatus) {
        switch (currentStatus) {
            case PAYMENT_COMPLETED:
                return DeliveryStatus.READY;
            case READY:
                return DeliveryStatus.DELIVERY;
            case DELIVERY:
                return DeliveryStatus.CONFIRMED;
            default:
                return null; // CONFIRMED 상태는 더 이상 변경하지 않음
        }
    }

    // 등급 업데이트 및 쿠폰 발급 로직
    private void updateUserGradeAndIssueCoupon(int userId) {
        // 등급 업데이트
        paymentService.updateUserGradeBasedOnPurchase(userId);

        // 사용자 정보 조회
        UserEntity userEntity = userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));

        // 등급에 따라 쿠폰 발급
        String grade = userEntity.getGrade().name(); // 현재 사용자의 등급
        userCouponService.updateCouponByUserAndGrade(userId, grade);

        System.out.println("사용자 ID " + userId + "의 등급이 업데이트되고 쿠폰이 발급되었습니다.");
    }
}

 

변경 후

package com.example.demo.payment.service.impl;

import java.time.LocalDateTime;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import com.example.demo.coupon.service.UserCouponService;
import com.example.demo.payment.entity.PaymentEntity;
import com.example.demo.payment.entity.PaymentEntity.DeliveryStatus;
import com.example.demo.payment.repository.PaymentRepository;
import com.example.demo.user.entity.UserEntity;
import com.example.demo.user.repository.UserRepository;

import jakarta.transaction.Transactional;

@Service
public class PaymentScheduler {

    @Autowired
    private PaymentRepository paymentRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PaymentServiceImpl paymentService; // 기존 PaymentServiceImpl 주입

    @Autowired
    private UserCouponService userCouponService;

    /**
     * 스케줄러는 5분마다 실행된다. DeliveryStatus가 CONFIRMED가 아닌 데이터 중
     * 결제 후 30분이 지난 데이터를 업데이트하기
     */
    @Scheduled(fixedRate = 300000) // 5분마다 실행 (300000ms)
    public void updateDeliveryStatus() {
        // 현재 시간 기준으로 30분 이상 경과한 데이터만 조회
        LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(30);

        List<PaymentEntity> payments = paymentRepository.findByDeliveryStatusNotAndPaymentDateBefore(
            DeliveryStatus.CONFIRMED, cutoffTime
        );

        // 상태 변경 처리
        for (PaymentEntity payment : payments) {
            processPaymentStatus(payment);
        }
    }

    /**
     * 개별 PaymentEntity에 대해 상태를 처리하는 메서드
     */
    @Transactional
    public void processPaymentStatus(PaymentEntity payment) {
        DeliveryStatus currentStatus = payment.getDeliveryStatus();
        DeliveryStatus newStatus = getNextDeliveryStatus(currentStatus);

        if (newStatus != null && newStatus != currentStatus) {
            payment.setDeliveryStatus(newStatus);
            paymentRepository.save(payment); // 상태가 변경된 경우만 저장

            System.out.println("Payment ID " + payment.getId() + " 상태 변경: " 
                + currentStatus + " → " + newStatus);

            // CONFIRMED 상태로 변경되면 등급 업데이트 및 쿠폰 발급 로직 호출
            if (newStatus == DeliveryStatus.CONFIRMED) {
                updateUserGradeAndIssueCoupon(payment.getUserEntity().getId());
            }
        }
    }

    /**
     * 현재 상태에 따라 다음 상태 반환
     */
    private DeliveryStatus getNextDeliveryStatus(DeliveryStatus currentStatus) {
        switch (currentStatus) {
            case PAYMENT_COMPLETED:
                return DeliveryStatus.READY;
            case READY:
                return DeliveryStatus.DELIVERY;
            case DELIVERY:
                return DeliveryStatus.CONFIRMED;
            default:
                return null; // CONFIRMED 상태는 더 이상 변경하지 않음
        }
    }

    /**
     * 등급 업데이트 및 쿠폰 발급 로직
     */
    private void updateUserGradeAndIssueCoupon(int userId) {
        // 등급 업데이트
        paymentService.updateUserGradeBasedOnPurchase(userId);

        // 사용자 정보 조회
        UserEntity userEntity = userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));

        // 등급에 따라 쿠폰 발급
        String grade = userEntity.getGrade().name(); // 현재 사용자의 등급
        userCouponService.updateCouponByUserAndGrade(userId, grade);

        System.out.println("사용자 ID " + userId + "의 등급이 업데이트되고 쿠폰이 발급되었습니다.");
    }
}

 

summ.n