팀 프로젝트에서 정기 결제 구현( Spring Schedule). v1

Choizz·2023년 1월 12일
0

회고

목록 보기
3/4
post-thumbnail

팀 프로젝트에서 정기배송을 구현하기로 결정했다.

사용자가 주기를 설정하면, 예를 들어 30일 주기로 배송을 정하면 30일마다 결제가 되게 해야 했다(프로젝트에서 결제가 되면 배송이 된다고 가정). 그리고 사용자는 배송 주기를 변경할 수 있고, 취소도 할 수 있다.

처음에는 어떻게 구현해야 할 지 막막했지만 자바로 구현할 수 있는 스케쥴러를 찾아보다 처음에는 Spring에서 제공하는 Scheduling Tasks(이하 스프링 스케쥴러)를 사용하게 되었다. 그리고 후에 문제점을 발견하여 Quartz를 사용하여 구현하게 되었다. Spring Batch까지 사용했으면 좋았겠지만 시간과 능력(?) 문제로 Batch 스케쥴링 까지는 구현하지 못 했다.

이 포스트는 처음에 스프링 스케쥴러를 사용하여 정기 결제를 구현한 내용을 담았다.
스케쥴러에 초점을 맞췄기 때문에 결제 관련 코드는 없다.

먼저 프로젝트에서는 SpringBoot 2.7.5 버전을 사용했다.

스프링 스케쥴러 사용

1. 스케쥴러 설정

  • 먼저 스프링 스케쥴러의 설정을 해주었다.
  • 이것을 DI하여 스케쥴링을 시도한다.
  • 스케쥴러 설정을 스프링에 등록해 준다.

@Configuration
public class Config {

    @Bean
    public ThreadPoolTaskScheduler schedulerExecutor() {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(4); // 스레드 풀 사이즈
		// 수행할 수 없는 task가 생길 경우 RejectedExecutionException을 던진다.
        taskScheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
			
        return taskScheduler;

    }

2. 스케쥴링 구현

SchedulingService라는 클래스를 만들어 정기 배송 기능을 구현했다.

1) 스케쥴링을 할 ThreadPoolTaskScheduler DI

  • 위에서 설정한 스케쥴러를 DI 해준다.
private final ThreadPoolTaskScheduler scheduler; //위에서 설정한 스케쥴러

2) 스케쥴러가 시작되는 메서드 구현

  • PeriodicTrigger 객체를 생성하여 결제의 주기를 설정한다(여기서는 초 단위로 설정).
  • 주입받은 scheduler에서 schedule 메서드를 실행하여 스케쥴링을 시작한다.
    • schedule 메서드에는 Runnable 타입Trigger 타입이 파라미터로 들어간다. 그래서 주기마다 수행돼야할 메서드는 Runnable 타입(함수형 인터페이스)으로 만들어야 한다.
public ScheduledFuture<?> schedule(Runnable task,Trigger trigger){
    ScheduledExecutorService executor=getScheduledExecutor();
    //생략
}
  • schedule 메서드에 파라미터로 수행돼야 할 자동 결제 메서드를 넣고, 생성한 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);
}

3) 스케쥴 취소

  • 스케쥴을 취소할 경우
    • 스케쥴을 저장한 저장소에서 그 스케쥴을 가지고와 cancel(true)메서드를 사용해서 취소한다.
public void stopScheduler(Long orderId,Long itemOrderId){
    ScheduledFuture<?> scheduledFuture=scheduledFutureMap.get(orderId+itemOrderId);
    scheduledFuture.cancel(true);
}

4) 스케쥴을 변경(주기 변경)

  • 주기를 변경해야 할 스케쥴을 저장소에서 가지고 온 후 스케쥴을 취소하고 스케쥴을 null로 만든 뒤 새로운 주기객체를 생성한 후 다시 스케쥴을 만들어 실행시킨다.
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

profile
집중

0개의 댓글