Scheduling

임종혁·2024년 4월 21일

문득 주기적으로 특정 작업이 수행되는 (즉 주기적으로 시간을 체크하여 db에서 알맞은 시간이 있으면 유저에게 알려주는 ) 요구사항이 있을 시 이를 어떻게 처리 해야할지 궁금하여 Scheduling 을 찾아 보게 되었다.

우선 주기적으로 특정 작업이 수행되려면
추측컨데 스레드 하나 이상이 해당 작업을 해당 작업을 지속적으로 수행 되어야 할 것이다.

그렇다면.. 스레드 하나 이상을 열고 거기서 무한 루프로 해당 작업을 수행 할 것이라 추측을 해본다.

과연 스프링 Scheduling은 어떤 식으로 동작 하는 것일 까?

스프링 Scheduling 을 사용하기 위해서는 스프링 부트에서

@SpringBootApplication
@EnableScheduling
public class SchedulingApplication {

    public static void main(String[] args) {
        SpringApplication.run(SchedulingApplication.class, args);
    }

}

다음과 같이 @EnableScheduling이라는 어노 테이션을 추가해야 한다.

이 어노테이션은

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(SchedulingConfiguration.class)
@Documented
public @interface EnableScheduling {

}

SchedulingConfiguration.class를 import하고 있는데

@Configuration(proxyBeanMethods = false)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class SchedulingConfiguration {

	@Bean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
	@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
	public ScheduledAnnotationBeanPostProcessor scheduledAnnotationProcessor() {
		return new ScheduledAnnotationBeanPostProcessor();
	}

}

해당 클래스는 ScheduledAnnotationBeanPostProcessor 을 빈 등록을 하고 있다.

해당 클래스는 bean으로 초기화 전 후 메서드가 존재하는데
후 메서드를 살펴보면

@Override
	public Object postProcessAfterInitialization(Object bean, String beanName) {
		if (bean instanceof AopInfrastructureBean || bean instanceof TaskScheduler ||
				bean instanceof ScheduledExecutorService) {
			// Ignore AOP infrastructure such as scoped proxies.
			return bean;
		}

		Class<?> targetClass = AopProxyUtils.ultimateTargetClass(bean);
		if (!this.nonAnnotatedClasses.contains(targetClass) &&
				AnnotationUtils.isCandidateClass(targetClass, List.of(Scheduled.class, Schedules.class))) {
			Map<Method, Set<Scheduled>> annotatedMethods = MethodIntrospector.selectMethods(targetClass,
					(MethodIntrospector.MetadataLookup<Set<Scheduled>>) method -> {
						Set<Scheduled> scheduledAnnotations = AnnotatedElementUtils.getMergedRepeatableAnnotations(
								method, Scheduled.class, Schedules.class);
						return (!scheduledAnnotations.isEmpty() ? scheduledAnnotations : null);
					});
			if (annotatedMethods.isEmpty()) {
				this.nonAnnotatedClasses.add(targetClass);
				if (logger.isTraceEnabled()) {
					logger.trace("No @Scheduled annotations found on bean class: " + targetClass);
				}
			}
			else {
				// Non-empty set of methods
				annotatedMethods.forEach((method, scheduledAnnotations) ->
						scheduledAnnotations.forEach(scheduled -> processScheduled(scheduled, method, bean)));
				if (logger.isTraceEnabled()) {
					logger.trace(annotatedMethods.size() + " @Scheduled methods processed on bean '" + beanName +
							"': " + annotatedMethods);
				}
			}
		}
		return bean;
	}
Map<Method, Set<Scheduled>> annotatedMethods = MethodIntrospector.selectMethods(targetClass,
					(MethodIntrospector.MetadataLookup<Set<Scheduled>>) method -> {
						Set<Scheduled> scheduledAnnotations = AnnotatedElementUtils.getMergedRepeatableAnnotations(
								method, Scheduled.class, Schedules.class);
						return (!scheduledAnnotations.isEmpty() ? scheduledAnnotations : null);
					});

메서드중 해당 내용을 보면 잘은 모르겠지만 Sceduled 어노테이션 이 달려있는 것을 스캔해서 map 에 저장 하는 느낌이다.

그 후

if (annotatedMethods.isEmpty()) {
				this.nonAnnotatedClasses.add(targetClass);
				if (logger.isTraceEnabled()) {
					logger.trace("No @Scheduled annotations found on bean class: " + targetClass);
				}
			}
			else {
				// Non-empty set of methods
				annotatedMethods.forEach((method, scheduledAnnotations) ->
						scheduledAnnotations.forEach(scheduled -> processScheduled(scheduled, method, bean)));
				if (logger.isTraceEnabled()) {
					logger.trace(annotatedMethods.size() + " @Scheduled methods processed on bean '" + beanName +
							"': " + annotatedMethods);
				}
			}
		}

map 이 비워있지 않음 map을 돌며 processScheduled() 이라는 메서드를 호출 하는 것을 볼 수 있다.

protected void processScheduled(Scheduled scheduled, Method method, Object bean) {
		// Is the method a Kotlin suspending function? Throws if true and the reactor bridge isn't on the classpath.
		// Does the method return a reactive type? Throws if true and it isn't a deferred Publisher type.
		if (reactiveStreamsPresent && ScheduledAnnotationReactiveSupport.isReactive(method)) {
			processScheduledAsync(scheduled, method, bean);
			return;
		}
		processScheduledSync(scheduled, method, bean);
	}

processScheduled 의 설명을 보면

Process the given @Scheduled method declaration on the given bean, attempting to distinguish reactive methods from synchronous methods.
Params:
scheduled – the @Scheduled annotation method – the method that the annotation has been declared on bean – the target bean instance
See Also:
processScheduledSync(Scheduled, Method, Object), processScheduledAsync(Scheduled, Method, Object)

@Scheduled 메서드 에 대해 동기 혹은 비동기 처리를 한다고 하는데
if 문에 걸리면 비동기 처리인것 같고 아니면 동기 처리인것 같다.

일단 동기 처리만 본다면

private void processScheduledSync(Scheduled scheduled, Method method, Object bean) {
		Runnable task;
		try {
			task = createRunnable(bean, method, scheduled.scheduler());
		}
		catch (IllegalArgumentException ex) {
			throw new IllegalStateException("Could not create recurring task for @Scheduled method '" +
					method.getName() + "': " + ex.getMessage());
		}
		processScheduledTask(scheduled, task, method, bean);
	}

Runable이라는 것을 만들고
processScheduledTask 의 인자로 넣어 실행 하는 것인데

private void processScheduledTask(Scheduled scheduled, Runnable runnable, Method method, Object bean) {
		try {
			boolean processedSchedule = false;
			String errorMessage = "Exactly one of the 'cron', 'fixedDelay' or 'fixedRate' attributes is required";

			Set<ScheduledTask> tasks = new LinkedHashSet<>(4);

			// Determine initial delay
			Duration initialDelay = toDuration(scheduled.initialDelay(), scheduled.timeUnit());
			String initialDelayString = scheduled.initialDelayString();
			if (StringUtils.hasText(initialDelayString)) {
				Assert.isTrue(initialDelay.isNegative(), "Specify 'initialDelay' or 'initialDelayString', not both");
				if (this.embeddedValueResolver != null) {
					initialDelayString = this.embeddedValueResolver.resolveStringValue(initialDelayString);
				}
				if (StringUtils.hasLength(initialDelayString)) {
					try {
						initialDelay = toDuration(initialDelayString, scheduled.timeUnit());
					}
					catch (RuntimeException ex) {
						throw new IllegalArgumentException(
								"Invalid initialDelayString value \"" + initialDelayString + "\" - cannot parse into long");
					}
				}
			}

			// Check cron expression
			String cron = scheduled.cron();
			if (StringUtils.hasText(cron)) {
				String zone = scheduled.zone();
				if (this.embeddedValueResolver != null) {
					cron = this.embeddedValueResolver.resolveStringValue(cron);
					zone = this.embeddedValueResolver.resolveStringValue(zone);
				}
				if (StringUtils.hasLength(cron)) {
					Assert.isTrue(initialDelay.isNegative(), "'initialDelay' not supported for cron triggers");
					processedSchedule = true;
					if (!Scheduled.CRON_DISABLED.equals(cron)) {
						CronTrigger trigger;
						if (StringUtils.hasText(zone)) {
							trigger = new CronTrigger(cron, StringUtils.parseTimeZoneString(zone));
						}
						else {
							trigger = new CronTrigger(cron);
						}
						tasks.add(this.registrar.scheduleCronTask(new CronTask(runnable, trigger)));
					}
				}
			}

			// At this point we don't need to differentiate between initial delay set or not anymore
			Duration delayToUse = (initialDelay.isNegative() ? Duration.ZERO : initialDelay);

			// Check fixed delay
			Duration fixedDelay = toDuration(scheduled.fixedDelay(), scheduled.timeUnit());
			if (!fixedDelay.isNegative()) {
				Assert.isTrue(!processedSchedule, errorMessage);
				processedSchedule = true;
				tasks.add(this.registrar.scheduleFixedDelayTask(new FixedDelayTask(runnable, fixedDelay, delayToUse)));
			}
			String fixedDelayString = scheduled.fixedDelayString();
			if (StringUtils.hasText(fixedDelayString)) {
				if (this.embeddedValueResolver != null) {
					fixedDelayString = this.embeddedValueResolver.resolveStringValue(fixedDelayString);
				}
				if (StringUtils.hasLength(fixedDelayString)) {
					Assert.isTrue(!processedSchedule, errorMessage);
					processedSchedule = true;
					try {
						fixedDelay = toDuration(fixedDelayString, scheduled.timeUnit());
					}
					catch (RuntimeException ex) {
						throw new IllegalArgumentException(
								"Invalid fixedDelayString value \"" + fixedDelayString + "\" - cannot parse into long");
					}
					tasks.add(this.registrar.scheduleFixedDelayTask(new FixedDelayTask(runnable, fixedDelay, delayToUse)));
				}
			}

			// Check fixed rate
			Duration fixedRate = toDuration(scheduled.fixedRate(), scheduled.timeUnit());
			if (!fixedRate.isNegative()) {
				Assert.isTrue(!processedSchedule, errorMessage);
				processedSchedule = true;
				tasks.add(this.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, delayToUse)));
			}
			String fixedRateString = scheduled.fixedRateString();
			if (StringUtils.hasText(fixedRateString)) {
				if (this.embeddedValueResolver != null) {
					fixedRateString = this.embeddedValueResolver.resolveStringValue(fixedRateString);
				}
				if (StringUtils.hasLength(fixedRateString)) {
					Assert.isTrue(!processedSchedule, errorMessage);
					processedSchedule = true;
					try {
						fixedRate = toDuration(fixedRateString, scheduled.timeUnit());
					}
					catch (RuntimeException ex) {
						throw new IllegalArgumentException(
								"Invalid fixedRateString value \"" + fixedRateString + "\" - cannot parse into long");
					}
					tasks.add(this.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, delayToUse)));
				}
			}

			if (!processedSchedule) {
				if (initialDelay.isNegative()) {
					throw new IllegalArgumentException("One-time task only supported with specified initial delay");
				}
				tasks.add(this.registrar.scheduleOneTimeTask(new OneTimeTask(runnable, delayToUse)));
			}

			// Finally register the scheduled tasks
			synchronized (this.scheduledTasks) {
				Set<ScheduledTask> regTasks = this.scheduledTasks.computeIfAbsent(bean, key -> new LinkedHashSet<>(4));
				regTasks.addAll(tasks);
			}
		}
		catch (IllegalArgumentException ex) {
			throw new IllegalStateException(
					"Encountered invalid @Scheduled method '" + method.getName() + "': " + ex.getMessage());
		}
	}

해당 메소드 설명으로는

Scheduled 주석을 구문 분석하고 이에 따라 제공된 Runnable을 예약합니다. Runnable은 동기 메서드 호출

즉 주석에도 나와있는 것 처럼 cron 식이나 fixedDelay에 대해 입력 유효성 검사를 한 후
만약 cron 검사시 scheduleCronTask를 볼 수 있는데

	tasks.add(this.registrar.scheduleCronTask(new CronTask(runnable, trigger)));
	@Nullable
	public ScheduledTask scheduleCronTask(CronTask task) {
		ScheduledTask scheduledTask = this.unresolvedTasks.remove(task);
		boolean newTask = false;
		if (scheduledTask == null) {
			scheduledTask = new ScheduledTask(task);
			newTask = true;
		}
		if (this.taskScheduler != null) {
			scheduledTask.future = this.taskScheduler.schedule(task.getRunnable(), task.getTrigger());
		}
		else {
			addCronTask(task);
			this.unresolvedTasks.put(task, scheduledTask);
		}
		return (newTask ? scheduledTask : null);
	}

스케줄을 초기화 하거나 예약한다 설명이 되어있다.

	if (this.taskScheduler != null) {
			scheduledTask.future = this.taskScheduler.schedule(task.getRunnable(), task.getTrigger());

스케줄이 아니면 ScheduledTask.fautre 에 taskScheduler.schedule()을 통해 CronTask를 넘겨 준다.

여기서 taskScheduler 는 TaskScheduler 인터페이스 인데

다양한 종류의 트리거를 기반으로 Runnable의 스케줄링을 추상화하는 작업 스케줄러 인터페이스입니다.
이 인터페이스는 일반적으로 다른 종류의 백엔드, 즉 다른 특성과 기능을 가진 스레드 풀을 나타내기 때문에 SchedulingTaskExecutor와 별개입니다. 구현에서는 두 종류의 실행 특성을 모두 처리할 수 있는 경우 두 인터페이스를 모두 구현할 수 있습니다.
'기본' 구현은 org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler이며, 기본 java.util.concurrent.ScheduledExecutorService를 래핑하고 확장된 트리거 기능을 추가합니다.
이 인터페이스는 Jakarta EE 환경에서 지원되지만 Spring의 TaskExecutor 모델과 일치하는 JSR-236 ManagedScheduledExecutorService와 대략 동일합니다.

다은과 같은 설명으로
여기서 스레드를 지정해 주며 실행 해 주는 것 같다

그럼 스케줄러를 등록 후 실행 한 모습을 보면

@Component
@RequiredArgsConstructor
public class SchedulService {
    private final DynamicScheduler scheduler;



    @Scheduled(fixedDelay = 6000)
    public void check(){
        System.out.println(now());
    }

다음과 같이 빈 등록 후 @Scheduled fixedDelay 나 cron 을 사용하여 시간을 지정 할 수 있는데

이런 설정은
fixedDelay

  • 이전 작업이 종료된 후 설정시간(milliseconds) 이후에 다시 시작
  • 이전 작업이 완료될 때까지 대기

fixedDelayString

  • fixedDelay와 동일 하고 설정시간(milliseconds)을 문자로 입력하는 경우 사용

fixedRate

  • 고정 시간 간격으로 시작
  • 이전 작업이 완료될 때까지 다음 작업이 진행되지 않음
  • 병렬 동작을 사용할 경우 @Async 추가

fixedRateString

  • fixedRate와 동일 하고 설정시간(milliseconds)을 문자로 입력

initialDelay

  • 설정된 initialDelay 시간 후부터 fixedDelay 시간 간격으로 실행

initialDelayString

  • initialDelay와 동일 하고 설정시간(milliseconds)을 문자로 입력

cron

  • Cron 표현식을 사용한 작업 예약
  • cron = "* * * * * *"
  • 첫번째 부터 초(0-59) 분(0-59) 시간(0-23) 일(1-31) 월(1-12) 요일(0-7)

zone

  • zone = "Asia/Seoul"
    미설정시 Local 시간대 사용

다음과 같은 설정이 있다는데 이는 사용법 이므로 더욱 설명이 잘된 블로그들이 많다 찾압도록 하자

다음과 같이 fixedRate를 설정 하고 실행시

다음과 같이 정상적으로 스케줄링이 자동으로 되는 모습을 볼 수있다.

0개의 댓글