Spring Boot에서 @Scheduled를 사용한 정기 작업 처리
1. 개요
많은 애플리케이션은 정기적으로 작업을 실행해야 합니다: 만료된 주문 정리, 5분마다 재고 동기화, 아침 8시에 보고서 전송. Spring은 추가 라이브러리 없이 @Scheduled 어노테이션을 통해 이를 지원합니다.
이 문서에서는 스케줄링을 활성화하고 fixedRate, fixedDelay, cron을 구분합니다. 또한 스케줄을 구성 파일로 외부화하고, 스레드 풀을 조정하고, 작업에 대한 테스트를 작성합니다. 마지막으로 @Scheduled를 사용하지 말아야 할 경우들을 살펴봅니다.
2. 스케줄링 활성화
@Scheduled는 spring-context에 이미 포함되어 있습니다. Spring Boot의 기본 스타터만 필요합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
다음으로, 구성 클래스에 @EnableScheduling을 추가합니다:
@SpringBootApplication
@EnableScheduling
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
@EnableScheduling 누락이 가장 흔한 오류입니다. 애플리케이션이 여전히 정상적으로 시작되고 오류가 표시되지 않지만 어떤 작업도 실행되지 않습니다.
주의:
@Scheduled가 붙은 메서드는 매개변수가 없어야 합니다.void를 반환해야 하며, 반환값은 무시됩니다. 메서드를 포함한 클래스는@Component또는@Service와 같은 빈이어야 합니다.
3. 고정된 시간 간격으로 실행
3.1. fixedRate
fixedRate는 이전 실행의 시작 시점부터 주기를 계산합니다:
@Component
public class InventoryJob {
private static final Logger log = LoggerFactory.getLogger(InventoryJob.class);
@Scheduled(fixedRate = 5000)
public void syncInventory() {
log.info("Đồng bộ tồn kho lúc {}", LocalTime.now().withNano(0));
}
}
기본 단위는 밀리초입니다. 작업은 애플리케이션이 준비되면 즉시 실행되고, 그 후 5초마다 실행됩니다:
INFO [ scheduling-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:00
INFO [ scheduling-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:05
INFO [ scheduling-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:10
한 번의 실행이 5초 이상 걸리면, 다음 실행은 병렬로 실행되지 않습니다. 이전 실행이 완료되면 즉시 시작되는데, 기본적으로 하나의 스레드(scheduling-1)만 있기 때문입니다.
3.2. fixedDelay
fixedDelay는 이전 실행의 종료 시점부터 주기를 계산합니다. 이 방식은 외부 API 호출이나 배치 처리와 같이 겹쳐서는 안 되는 작업에 적합합니다:
@Component
public class EmailQueueJob {
private static final Logger log = LoggerFactory.getLogger(EmailQueueJob.class);
@Scheduled(fixedDelay = 3, initialDelay = 10, timeUnit = TimeUnit.SECONDS)
public void sendPendingEmails() throws InterruptedException {
log.info("Bắt đầu gửi email");
Thread.sleep(2000); // giả lập công việc mất 2 giây
log.info("Gửi xong");
}
}
설명:
initialDelay = 10: 시작 시 속도 저하를 피하기 위해 시작 후 10초를 기다린 후 처음 실행합니다.timeUnit = TimeUnit.SECONDS:fixedDelay와initialDelay모두의 시간 단위를 변경합니다.- 각 실행은 2초가 걸리고, 그 후 3초 동안 쉽니다. 연속 두 실행의 시작 시점은 5초 차이가 납니다:
09:00:10 Bắt đầu gửi email
09:00:12 Gửi xong
09:00:15 Bắt đầu gửi email
09:00:17 Gửi xong
4. Cron을 사용한 스케줄 설정
특정 시간에 실행해야 할 때는 cron을 사용합니다. Spring의 cron 표현식은 6개 필드를 가지며, Unix cron보다 초 필드가 앞에 있습니다:
┌─ giây (0-59)
│ ┌─ phút (0-59)
│ │ ┌─ giờ (0-23)
│ │ │ ┌─ ngày trong tháng (1-31)
│ │ │ │ ┌─ tháng (1-12 hoặc JAN-DEC)
│ │ │ │ │ ┌─ thứ trong tuần (0-7 hoặc MON-SUN)
* * * * * *
예를 들어, 월요일부터 금요일까지 매일 아침 8시에 베트남 시간으로 매출 보고서를 보내는 경우:
@Component
public class SalesReportJob {
private final ReportService reportService;
public SalesReportJob(ReportService reportService) {
this.reportService = reportService;
}
@Scheduled(cron = "0 0 8 * * MON-FRI", zone = "Asia/Ho_Chi_Minh")
public void sendDailyReport() {
reportService.sendYesterdaySales();
}
}
zone 속성은 컨테이너에서 흔히 발생하는 UTC 기반 서버에서 매우 중요합니다. 생략하면 보고서가 8시가 아닌 15시에 도착하게 됩니다.
자주 사용되는 몇 가지 표현식:
0 */15 * * * * mỗi 15 phút
0 0 2 * * * 2 giờ sáng mỗi ngày
0 0 0 1 * * 0 giờ ngày đầu mỗi tháng
0 30 9 * * SAT,SUN 9:30 thứ Bảy và Chủ nhật
@daily tương đương 0 0 0 * * *
@hourly tương đương 0 0 * * * *
주의: Linux crontab에서 5개 필드 표현식을 복사하면, 예를 들어
0 8 * * *, Spring은 시작 시IllegalStateException을 던집니다. 앞에 초 필드를 추가해야 합니다.
5. 스케줄을 구성 파일로 외부화
코드에 스케줄을 하드코딩하면 변경할 때마다 다시 빌드해야 합니다. 플레이스홀더를 사용하세요. 숫자 형식의 속성의 경우 ...String 변형을 사용합니다:
@Component
public class OrderCleanupJob {
private final OrderRepository orderRepository;
private final AtomicInteger runCount = new AtomicInteger();
public OrderCleanupJob(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Scheduled(fixedDelayString = "${jobs.order-cleanup.delay-ms:60000}")
public void removeExpiredOrders() {
orderRepository.deleteExpiredBefore(Instant.now().minus(Duration.ofHours(24)));
runCount.incrementAndGet();
}
public int getRunCount() {
return runCount.get();
}
}
jobs.order-cleanup.delay-ms=30000
jobs.sales-report.cron=0 0 8 * * MON-FRI
: 뒤의 값은 속성이 선언되지 않을 때 사용되는 기본값입니다. cron의 경우 @Scheduled(cron = "${jobs.sales-report.cron}")로 작성합니다.
코드를 수정하지 않고 cron 작업을 비활성화하려면 값을 -로 설정합니다:
jobs.sales-report.cron=-
Spring은 -(Scheduled.CRON_DISABLED 상수)를 인식하고 그 작업을 등록하지 않습니다. 이 방법은 개발 또는 테스트 환경에서 작업을 비활성화하려는 경우 편리합니다.
6. 스레드 풀 구성
기본적으로 Spring Boot는 정확히 하나의 스레드를 가진 ThreadPoolTaskScheduler를 생성합니다. 모든 작업이 동일한 스레드를 공유합니다. SalesReportJob이 10분 동안 중단되면 InventoryJob도 10분을 기다려야 합니다.
풀의 크기를 늘릴 수 있습니다:
spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=shop-job-
증가시킨 후 다른 작업들이 병렬로 실행될 수 있습니다:
INFO [ shop-job-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:05
INFO [ shop-job-2] EmailQueueJob : Bắt đầu gửi email
Spring Boot 3.2부터 Java 21을 실행할 때 가상 스레드를 사용할 수 있습니다:
spring.threads.virtual.enabled=true
이 경우 Boot는 SimpleAsyncTaskScheduler를 사용하며, 각 작업 실행은 별도의 가상 스레드에 있습니다. pool.size 속성은 더 이상 적용되지 않습니다.
주의: 풀 크기를 증가시키면 같은 작업이 자신과 겹쳐서 실행되지 않습니다. 각
@Scheduled메서드는 여전히 순차적으로 실행됩니다. 실행이 겹치기를 원하면@Async(@EnableAsync와 함께)를 추가해야 합니다. 데이터 중복 처리가 발생하기 쉽기 때문에 거의 그렇게 하지 않아야 합니다.
7. 예외 처리
작업이 fixedRate 또는 fixedDelay로 예외를 throw하면 Spring은 오류를 로깅한 후 다음 실행에서 여전히 재실행됩니다. Cron도 마찬가지입니다. 작업이 완전히 중지되지 않지만, 로그를 모니터링하지 않으면 오류를 놓치기 쉽습니다.
가장 안전한 방법은 작업 내에서 오류를 잡아 의도적으로 처리하는 것입니다:
@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
public void syncInventory() {
try {
inventoryClient.pullLatestStock();
} catch (RestClientException ex) {
log.warn("Không gọi được kho hàng, sẽ thử lại ở lần sau: {}", ex.getMessage());
alertService.notifyOps("inventory-sync-failed");
}
}
모든 작업에 대한 통합 처리 위치를 원하면 Boot의 customizer를 통해 ErrorHandler를 등록합니다:
@Configuration
public class SchedulingConfig {
@Bean
ThreadPoolTaskSchedulerCustomizer schedulerErrorHandler(AlertService alertService) {
return scheduler -> scheduler.setErrorHandler(ex ->
alertService.notifyOps("scheduled-job-failed: " + ex.getMessage()));
}
}
이 customizer는 ThreadPoolTaskScheduler에만 적용됩니다. 가상 스레드를 활성화하면 효과가 없습니다.
8. 작업 테스트 작성
테스트를 두 계층으로 나눕니다. 첫 번째 계층은 비즈니스 로직을 확인합니다. 두 번째 계층은 스케줄러가 실제로 작업을 호출하는지 확인합니다.
비즈니스 로직은 일반 메서드처럼 테스트하고 직접 호출하며 기다릴 필요가 없습니다:
class OrderCleanupJobUnitTest {
@Test
void shouldDeleteOrdersOlderThan24Hours() {
OrderRepository repo = mock(OrderRepository.class);
OrderCleanupJob job = new OrderCleanupJob(repo);
job.removeExpiredOrders();
ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
verify(repo).deleteExpiredBefore(captor.capture());
assertThat(captor.getValue())
.isBefore(Instant.now().minus(Duration.ofHours(23)));
assertThat(job.getRunCount()).isEqualTo(1);
}
}
스케줄링을 확인하려면 Awaitility를 사용합니다. Thread.sleep을 사용하면 안 되는데, 테스트가 느리고 불안정하기 때문입니다. Awaitility 버전은 Spring Boot에서 관리하므로 버전을 지정할 필요가 없습니다:
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest(properties = "jobs.order-cleanup.delay-ms=100")
class OrderCleanupJobSchedulingTest {
@MockitoBean
OrderRepository orderRepository;
@Autowired
OrderCleanupJob job;
@Test
void shouldRunRepeatedly() {
await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertThat(job.getRunCount()).isGreaterThanOrEqualTo(3));
}
}
이 테스트는 properties를 통해 주기를 100ms로 낮춥니다. 그 후 작업이 최소 3번 실행될 때까지 최대 2초를 기다립니다. Awaitility는 반복적으로 확인하고 조건이 맞을 때 즉시 반환하므로, 테스트는 보통 약 300ms 내에 완료됩니다. @MockitoBean은 Spring Boot 3.4부터 사용할 수 있습니다. 이전 버전에서는 @MockBean을 사용합니다.
다른 통합 테스트에서는 작업이 백그라운드에서 실행되고 데이터를 손상시키지 않도록 스케줄링을 비활성화해야 합니다. 두 가지 방법이 있습니다. 첫 번째 방법은 cron을 -로 설정하는 것입니다. 두 번째 방법은 @EnableScheduling을 조건부 별도 구성 클래스로 이동하는 것입니다:
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig { }
그러면 테스트는 jobs.enabled=false를 선언하기만 하면 됩니다.
9. @Scheduled를 사용하지 말아야 할 때
@Scheduled는 간단하지만 명백한 제한이 있습니다:
- 여러 인스턴스 실행. 3개의 Kubernetes pod를 사용하면 작업이 동시에 3번 실행되고 보고서가 3번 전송됩니다. ShedLock 라이브러리가 데이터베이스의 테이블을 사용하는 분산 잠금이나 작업을 별도의 단일 실행 서비스로 분리해야 합니다.
- 작업 상태 저장 필요. 애플리케이션이 정확히 8시에 종료되면 해당 실행이 손실되고 보정 실행이 없습니다. misfire 처리, 실행 이력, 또는 런타임 동적 스케줄링이 필요할 때는 Quartz를 사용하십시오.
- 매우 무거운 작업 또는 몇 시간 동시 실행. 재시도, 배치 분할 및 진행 상황 추적을 위해 Spring Batch 또는 큐 시스템으로 전환해야 합니다.
- 사용자가 만든 일정, 예: “15:00에 나에게 알림”. 어노테이션은 시작 시 고정 스케줄에만 적합합니다. 동적 스케줄은 데이터베이스에 저장하고 프로그래밍 방식의
TaskScheduler를 사용하거나 Quartz를 사용해야 합니다.
몇 가지 추가 일반적인 오류:
private메서드 또는 빈이 아닌 클래스의 메서드에@Scheduled를 적용합니다.zone을 잊어버려 UTC 서버에서 작업이 시간차로 실행됩니다.@Scheduled와@Transactional을 동일한 메서드에 배치할 수 있습니다. 그러나 로직을@Transactional서비스로 분리하는 것이 더 테스트 가능하고 읽기 쉬울 것입니다.
10. 결론
이 문서에서 당신은:
@EnableScheduling으로 스케줄링을 활성화했고;fixedRate(시작 시점부터 계산)과fixedDelay(종료 시점부터 계산)를 구분했으며;- 타임존이 있는 6개 필드 cron 표현식을 작성했고;
- 스케줄을 구성 파일로 외부화하고
-로 작업을 비활성화했으며; - 스레드 풀을 증가시키거나 가상 스레드를 사용하여 작업이 서로 차단되지 않도록 했고;
- 예외를 처리하고 Awaitility로 테스트를 작성했습니다.
단일 인스턴스에서 실행되고 고정 스케줄을 가진 애플리케이션의 경우 @Scheduled로 충분합니다. 시스템이 여러 인스턴스로 확장되거나 더 높은 신뢰성이 필요한 경우, 프로덕션에서 문제가 발생하기 전에 ShedLock 또는 Quartz를 고려하십시오.