1. 스케줄러의 비동기 실행과 트랜잭션 관리의 충돌
@Scheduled 메서드는 별도의 스레드에서 비동기로 실행된다. 즉, 동시에 실행할 수 없다 !
Spring의 @Transactional은 기본적으로 현재 실행 중인 스레드의 컨텍스트에 트랜잭션 정보를 저장하여 관리한다.
그러나 스케줄러의 경우 비동기적으로 동작하기 때문에 트랜잭션 컨텍스트가 제대로 전파되지 않을 가능성이 있다는 것
문제 예시
- 트랜잭션 내에서 조회한 엔티티가 영속성 컨텍스트에서 관리되지 않을 수 있음
- 비동기로 실행되면서 트랜잭션이 제대로 커밋되지 않거나 롤백되지 않는 상황이 발생할 수 있음.
2. 트랜잭션 경합
스케줄러가 실행 중일 때 데이터베이스에서 읽거나 업데이트를 수행하면 다른 트랜잭션과 경합할 가능성이 있따.
특히, 스케줄러가 실행되는 동안 또 다른 트랜잭션이 동일한 데이터를 처리하면 Deadlock이나 Optimistic Locking Failure와 같은 문제가 발생할 수 있다.
예시
- 스케줄러가 PaymentEntity의 상태를 업데이트하려고 할 때, 다른 트랜잭션이 동일한 엔티티를 변경 중이라면 데이터베이스 락 경합 발생.
- 트랜잭션이 롤백되거나 불완전하게 저장되는 문제가 생길 수 있음.
3. 트랜잭션 범위와 데이터 무결성
스케줄러는 일반적으로 많은 데이터를 일괄 처리하도록 설계된다. 트랜잭션 범위가 커지면 아래와 같은 문제가 발생할 수 있다:
- 트랜잭션 타임아웃: 스케줄러가 처리해야 할 데이터 양이 많고 작업 시간이 길어지면 트랜잭션 타임아웃이 발생할 수 있음.
- 부분 실패 처리 문제: 한 엔티티의 업데이트가 실패했을 경우, 전체 트랜잭션이 롤백됩니다. 이로 인해 다른 엔티티의 상태도 저장되지 않을 수 있음.
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 + "의 등급이 업데이트되고 쿠폰이 발급되었습니다.");
}
}
'Spring' 카테고리의 다른 글
DAY 68 - 스프링 프레임워크 HOMEWORK - NCP 파일 삭제 (2024.10.14) (0) | 2024.10.15 |
---|---|
DAY 68 - 스프링 프레임워크 - NCP 파일 업로드 / 수정 (2024.10.14) (4) | 2024.10.15 |
DAY 67 - 스프링 프레임워크 HOMEWORK - 파일 DB 저장 / 이미지 출력 (2024.10.11) (0) | 2024.10.14 |
DAY 67 - 스프링 프레임워크 - 파일업로드 (2024.10.11) (0) | 2024.10.11 |
DAY 66 - 스프링 프레임워크 MVC HOMEWORK - 회원탈퇴 (2024.10.10) (1) | 2024.10.11 |