정상혁정상혁
  • 변경 이력

    • 2024.06.05 스프링 프레임워크 버전 5.3 이상에 맞는 코드로 수정


스프링에서는 @Schedule 애너테이션을 통해 크론 표현식으로 특정 메서드를 예약 실행할 수 있습니다. 그런데 실제로 잘 실행될지는 해당 시간이 되어야 알 수 있으므로 실수가 있어도 늦게 발견될 가능성이 높습니다. 예를 들면 매일 5시 20분, 40분, 60분에 실행될 일정을 지정하고자 했는데 "* 0/20 5 * * * ?"로 써야 할 표현식을 "* 0,20 5 * * * ?"으로 써놓고는 실운영 서버에 배포 한 후 하루가 지난 다음에 실수를 발견하는 경우입니다. 크론 표현식이 기대대로 해석되는지도 이 글을 참고하여 테스트 코드로 검증을 한다면 이런 사고를 막을 수 있습니다.

예제는 스프링 프레임워크 6.1.8 버전에 의존하여 작성되었으나 버전 5.3 이상에서는 동일한 방식으로 사용할 수 있을 것으로 예상합니다. 5.3버전을 기준으로 날짜 관련 클래스가 JDK 8의 API를 활용하는 방식으로 바뀌었습니다. 스프링 3.0 에서는 java.util.Date 등의 과거 API를 활용하는 방식으로 응용하실 수도 있습니다.

크론 표현식으로 예약 실행할 메서드 지정

대상이 되는 메소드에 @Schedule 애너테이션의 cron, zone 속성을 지정합니다. zone 속성은 필수는 아니지만 지정하면 명확성이 높아지고 시스템 기본값에 영향받지 않는다는 장점이 있습니다.

JobSchedule.java
import java.time.Instant;
import java.util.Properties;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@EnableScheduling
public class JobSchedule {

  @Scheduled(cron = "0 0,10 0 * * ?", zone = "Asia/Seoul")
  public void startHelloJob() {
    // 실행할 코드
  }
}

테스트 코드

크론 표현식을 추출하고 검사하는 코드는 ScheduleTestUtils 클래스로 분리했습니다.

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import org.junit.jupiter.api.Test;

class JobScheduleTest {
  @Test
  void scheduleHelloJob() {
    var initialTime = LocalDateTime.of(2024, 6, 10, 0, 5);
    List<LocalDateTime> expectedTimes = List.of(
        LocalDateTime.of(2024, 6, 10, 0, 10),
        LocalDateTime.of(2024, 6, 11, 0, 0),
        LocalDateTime.of(2024, 6, 11, 0, 10)
    );
    ScheduleTestUtils.assertCronExpression(
        JobSchedule.class, "startHelloJob",
        toInstant(initialTime),
        expectedTimes.stream().map(this::toInstant).toList()
    );
  }

  private Instant toInstant(LocalDateTime time) {
    return time.atZone(ZoneId.of("Asia/Seoul")).toInstant();
  }
}
ScheduleTestUtils.java
import static org.assertj.core.api.Assertions.assertThat;

import java.lang.reflect.Method;
import java.time.Instant;
import java.util.List;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.scheduling.support.SimpleTriggerContext;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;

public class ScheduleTestUtils {
  public static void assertCronExpression(
      Class<?> targetClass, String methodName,
      Instant initialTime, List<Instant> expectedTimes
  ) {
    Method method = ReflectionUtils.findMethod(targetClass, methodName);
    assertThat(method).isNotNull();

    Scheduled scheduled = method.getAnnotation(Scheduled.class);
    CronTrigger trigger = getTrigger(scheduled);
    var context = new SimpleTriggerContext(initialTime, initialTime, initialTime);

    for (Instant expected : expectedTimes) {
      Instant actual = trigger.nextExecution(context);
      assertThat(actual).isEqualTo(expected);
      context.update(actual, actual, actual);
    }
  }

  private static CronTrigger getTrigger(Scheduled scheduled) {
    // 스프링의 ScheduledAnnotationBeanPostProcessor 코드를 참고함
    if (StringUtils.hasText(scheduled.zone())) {
      return new CronTrigger(scheduled.cron(), StringUtils.parseTimeZoneString(scheduled.zone()));
    } else {
      return new CronTrigger(scheduled.cron());
    }
  }
}