팀 프로젝트에서 정기배송을 구현하기로 결정했다.
사용자가 주기를 설정하면, 예를 들어 30일 주기로 배송을 정하면 30일마다 결제가 되게 해야 했다(프로젝트에서 결제가 되면 배송이 된다고 가정). 그리고 사용자는 배송 주기를 변경할 수 있고, 취소도 할 수 있다.
처음에는 어떻게 구현해야 할 지 막막했지만 자바로 구현할 수 있는 스케쥴러를 찾아보다 처음에는 Spring에서 제공하는 Scheduling Tasks(이하 스프링 스케쥴러)를 사용하게 되었다. 그리고 후에 문제점을 발견하여 Quartz를 사용하여 구현하게 되었다. Spring Batch까지 사용했으면 좋았겠지만 시간과 능력(?) 문제로 Batch 스케쥴링 까지는 구현하지 못 했다.
이 포스트는 처음에 스프링 스케쥴러를 사용하여 정기 결제를 구현한 내용을 담았다.
스케쥴러에 초점을 맞췄기 때문에 결제 관련 코드는 없다.
먼저 프로젝트에서는 SpringBoot 2.7.5 버전을 사용했다.
@Configuration
public class Config {
@Bean
public ThreadPoolTaskScheduler schedulerExecutor() {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(4); // 스레드 풀 사이즈
// 수행할 수 없는 task가 생길 경우 RejectedExecutionException을 던진다.
taskScheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
return taskScheduler;
}
SchedulingService라는 클래스를 만들어 정기 배송 기능을 구현했다.
private final ThreadPoolTaskScheduler scheduler; //위에서 설정한 스케쥴러
PeriodicTrigger
객체를 생성하여 결제의 주기를 설정한다(여기서는 초 단위로 설정).schedule 메서드
를 실행하여 스케쥴링을 시작한다.함수형 인터페이스
)으로 만들어야 한다.public ScheduledFuture<?> schedule(Runnable task,Trigger trigger){
ScheduledExecutorService executor=getScheduledExecutor();
//생략
}
PeriodicTrigger
객체를 넣는다.private final ConcurrentMap<Long, ScheduledFuture<?>>scheduledFutureMap = new ConcurrentHashMap<>();
...
public void startScheduler(Long orderId,ItemOrder itemOrder){
trigger=new PeriodicTrigger(itemOrder.getPeriod(),TimeUnit.SECONDS);
ScheduledFuture<?> schedule=scheduler.schedule(autoPay(itemOrder),trigger);
scheduledFutureMap.put(orderId+itemOrder.getItemOrderId(),schedule);
}
cancel(true)
메서드를 사용해서 취소한다.public void stopScheduler(Long orderId,Long itemOrderId){
ScheduledFuture<?> scheduledFuture=scheduledFutureMap.get(orderId+itemOrderId);
scheduledFuture.cancel(true);
}
public void changePeriod(Long orderId, ItemOrder itemOrder, Integer period) {
itemOrder.setPeriod(period);
log.error("perid = {}", itemOrder.getPeriod());
ScheduledFuture<?> scheduledFuture = scheduledFutureMap.get(
orderId + itemOrder.getItemOrderId()); //스케쥴을 가지고 온다.
if (scheduledFuture != null)
scheduledFuture.cancel(true); //스케쥴을 취소한다.
log.info("스케쥴 취소");
scheduledFuture = null; //null로 만든다.
log.info("schedule {}", scheduledFuture);
// 스케쥴을 재설정한다.
trigger = new PeriodicTrigger(period, TimeUnit.SECONDS);
scheduledFuture = scheduler.schedule(change(itemOrder), trigger);
scheduledFutureMap.put(orderId + itemOrder.getItemOrderId(), scheduledFuture);
}
@Slf4j
@RequiredArgsConstructor
@Service
public class SchedulingService {
private final ThreadPoolTaskScheduler scheduler; //위에서 설정한 스케쥴러
private final SubscriptionService service;
private PeriodicTrigger trigger; //스케쥴링을 주기를 설정할 수 있는 클래스
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFutureMap = new ConcurrentHashMap<>();
public void stopScheduler(Long orderId, Long itemOrderId) {
ScheduledFuture<?> scheduledFuture = scheduledFutureMap.get(orderId + itemOrderId);
scheduledFuture.cancel(true);
}
public void startScheduler(Long orderId, ItemOrder itemOrder) {
trigger = new PeriodicTrigger(itemOrder.getPeriod(), TimeUnit.SECONDS);
ScheduledFuture<?> schedule = scheduler.schedule(autoPay(itemOrder), trigger);
scheduledFutureMap.put(orderId + itemOrder.getItemOrderId(), schedule);
}
public void changePeriod(Long orderId, ItemOrder itemOrder, Integer period) {
itemOrder.setPeriod(period);
log.error("perid = {}", itemOrder.getPeriod());
makeScheduleNull(orderId, itemOrder);
ScheduledFuture<?> scheduledFuture;
trigger = new PeriodicTrigger(period, TimeUnit.SECONDS);
scheduledFuture = scheduler.schedule(change(itemOrder), trigger);
scheduledFutureMap.put(orderId + itemOrder.getItemOrderId(), scheduledFuture);
}
public void delayDelivery(Long orderId, ItemOrder itemOrder, String delay) {
itemOrder.setNextDelivery(itemOrder.getNextDelivery().plusDays(Long.parseLong(delay)));
log.info("next delayDelivery = {}", itemOrder.getNextDelivery());
makeScheduleNull(orderId, itemOrder);
ScheduledFuture<?> scheduledFuture;
trigger = new PeriodicTrigger(itemOrder.getPeriod(), TimeUnit.SECONDS);
scheduledFuture = scheduler.schedule(delay(itemOrder), trigger);
scheduledFutureMap.put(orderId + itemOrder.getItemOrderId(), scheduledFuture);
}
private Runnable change(ItemOrder itemOrder) {
return () -> {
try {
service.changePaymentDay(itemOrder);
} catch (IOException e) {
throw new RuntimeException(e);
}
};
}
private Runnable delay(ItemOrder itemOrder) {
return () -> {
try {
service.delayPaymentDay(itemOrder);
} catch (IOException e) {
throw new BusinessLogicException(ExceptionCode.ORDER_NOT_FOUND);
}
};
}
public Runnable autoPay(ItemOrder itemOrder) {
return () -> {
try {
service.getPaymentDay(itemOrder);
} catch (IOException e) {
throw new BusinessLogicException(ExceptionCode.ORDER_NOT_FOUND);
}
};
}
private void makeScheduleNull(Long orderId, ItemOrder itemOrder) {
ScheduledFuture<?> scheduledFuture = scheduledFutureMap.get(
orderId + itemOrder.getItemOrderId());
if (scheduledFuture != null)
scheduledFuture.cancel(true);
log.info("스케쥴 취소");
scheduledFuture = null;
log.info("schedule {}", scheduledFuture);
}
}
이렇게 스프링 스케쥴러를 이용하여 일정 주기로 Job(수행해야 할 일)을 실행시킬 수 있다. 이 외에 @Scheduled
를 이용하여다른 방식으로 사용할 수 있는데 공식 문서를 참고해
보면 좋을 것 같다.
하앞서 언급했듯이 스프링 스케쥴러는 우리가 만든 정기 결제의 비지니스 로직 상의 문제가 있었다. 처음 정기 결제를 신청하고 주기를 바꿨을 때 처음 결제일을 기준으로 주기가 변경돼야 하는 걸로 정했었다. 하지만 스케쥴이 시작되고 주기를 바꿀때 스케쥴러는 그 스케쥴을 삭제하고 다시 생성하는 구조이기 때문에 변경 시점이 처음 결제일이 아닌 주기를 변경한 시점을 기준으로 주기를 변경한다. 즉, 스프링 스케쥴러는 런 타임 환경에서 동적으로 주기를 변경하기에는 약간의 한계가 있었다.
그래서 Quartz 스케쥴러로 스케쥴링을 다시 구현하게 되었다. 이것은 다음에 포스팅하려 한다.
Reference