본문 바로가기

개발/BACK

Springboot 스케줄러 개발 예제 @Scheduled

728x90

Spring에서 스케줄러를 구현하는 방법을 알아보겠습니다. @Scheduled 어노테이션을 사용한 간단한 방법부터, Cron 표현식을 활용한 복잡한 스케줄링, 그리고 동적 스케줄러 구현까지 다양한 예제와 함께 설명합니다.


1. 기본 설정

1.1 @EnableScheduling 활성화

Spring에서 스케줄러를 사용하려면 메인 애플리케이션 클래스나 설정 클래스에 @EnableScheduling 어노테이션을 추가해야 합니다.

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class Application {
    
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

1.2 build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.0'
    id 'io.spring.dependency-management' version '1.1.0'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

dependencies {
    // Spring Boot
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
    // 스케줄러는 Spring Boot에 기본 포함되어 있음
    // 별도 의존성 추가 불필요
    
    // Database
    runtimeOnly 'com.h2database:h2'
    
    // Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}
💡 팁: Spring Boot는 스케줄러 기능을 기본 제공하므로 별도의 의존성을 추가할 필요가 없습니다. @EnableScheduling만 추가하면 됩니다.

2. @Scheduled 어노테이션 사용

@Scheduled 어노테이션을 메서드에 추가하면 해당 메서드가 정해진 시간에 자동으로 실행됩니다.

2.1 기본 스케줄러 예제

package com.example.scheduler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Slf4j
@Component
public class BasicScheduler {
    
    /**
     * 고정 지연 (Fixed Delay)
     * 이전 작업이 완료된 후 지정된 시간만큼 대기 후 실행
     * 단위: 밀리초
     */
    @Scheduled(fixedDelay = 5000) // 5초마다 실행
    public void fixedDelayTask() {
        log.info("Fixed Delay Task 실행: {}", 
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
    }
    
    /**
     * 고정 주기 (Fixed Rate)
     * 이전 작업 완료 여부와 관계없이 지정된 주기로 실행
     * 단위: 밀리초
     */
    @Scheduled(fixedRate = 3000) // 3초마다 실행
    public void fixedRateTask() {
        log.info("Fixed Rate Task 실행: {}", 
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
    }
    
    /**
     * 초기 지연 (Initial Delay)
     * 애플리케이션 시작 후 지정된 시간만큼 대기 후 첫 실행
     */
    @Scheduled(fixedDelay = 5000, initialDelay = 10000)
    public void initialDelayTask() {
        log.info("Initial Delay Task 실행: {}", 
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
    }
    
    /**
     * Cron 표현식 사용
     * 매일 오전 9시에 실행
     */
    @Scheduled(cron = "0 0 9 * * ?")
    public void cronTask() {
        log.info("Cron Task 실행: {}", 
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
    }
}

2.2 @Scheduled 속성 설명

속성 설명 단위
fixedDelay 이전 작업 완료 후 대기 시간 밀리초
fixedRate 고정된 주기로 실행 밀리초
initialDelay 첫 실행 전 대기 시간 밀리초
cron Cron 표현식으로 실행 시간 지정 Cron 표현식

3. Cron 표현식

Cron 표현식은 복잡한 스케줄링을 위해 사용됩니다. 6개 또는 7개의 필드로 구성됩니다.

3.1 Cron 표현식 구조

초 분 시 일 월 요일 [년도]

┌───────────── 초 (0-59)
│ ┌─────────── 분 (0-59)
│ │ ┌───────── 시 (0-23)
│ │ │ ┌─────── 일 (1-31)
│ │ │ │ ┌───── 월 (1-12 또는 JAN-DEC)
│ │ │ │ │ ┌─── 요일 (0-7 또는 SUN-SAT, 0과 7은 일요일)
│ │ │ │ │ │
* * * * * ?

3.2 Cron 표현식 예제

Cron 표현식 설명
0 0 * * * ? 매시간 정각 (0분 0초)
0 0 9 * * ? 매일 오전 9시
0 0 9 * * MON-FRI 평일 오전 9시
0 0 0 1 * ? 매월 1일 자정
0 */5 * * * ? 5분마다
0 0 12 ? * WED 매주 수요일 정오
0 0 0 ? * MON 매주 월요일 자정

3.3 Cron 표현식 특수 문자

문자 설명 예제
* 모든 값 0 * * * * ? (매분 0초)
? 특정 값 없음 (일/요일에서만 사용) 0 0 9 ? * MON
- 범위 0 0 9-17 * * ? (9시~17시)
, 값 목록 0 0 9,12,15 * * ? (9시, 12시, 15시)
/ 증가값 0 */5 * * * ? (5분마다)

3.4 Cron 표현식 사용 예제

package com.example.scheduler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class CronScheduler {
    
    /**
     * 매일 오전 9시에 실행
     */
    @Scheduled(cron = "0 0 9 * * ?")
    public void dailyMorningTask() {
        log.info("매일 오전 9시 작업 실행");
    }
    
    /**
     * 평일 오전 9시에 실행
     */
    @Scheduled(cron = "0 0 9 * * MON-FRI")
    public void weekdayTask() {
        log.info("평일 오전 9시 작업 실행");
    }
    
    /**
     * 매시간 정각에 실행
     */
    @Scheduled(cron = "0 0 * * * ?")
    public void hourlyTask() {
        log.info("매시간 정각 작업 실행");
    }
    
    /**
     * 5분마다 실행
     */
    @Scheduled(cron = "0 */5 * * * ?")
    public void everyFiveMinutesTask() {
        log.info("5분마다 작업 실행");
    }
    
    /**
     * 매월 1일 자정에 실행
     */
    @Scheduled(cron = "0 0 0 1 * ?")
    public void monthlyTask() {
        log.info("매월 1일 자정 작업 실행");
    }
}

4. 고정 지연/고정 주기

4.1 fixedDelay vs fixedRate 차이

⚠️ 중요: fixedDelay는 이전 작업이 완료된 후 지정된 시간만큼 대기하고, fixedRate는 이전 작업 완료 여부와 관계없이 지정된 주기로 실행됩니다.
package com.example.scheduler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class DelayAndRateScheduler {
    
    /**
     * fixedDelay: 이전 작업 완료 후 5초 대기
     * 작업 시간이 3초 걸리면: 실행 → 3초 작업 → 5초 대기 → 다음 실행
     * 총 간격: 8초
     */
    @Scheduled(fixedDelay = 5000)
    public void fixedDelayExample() {
        log.info("Fixed Delay 작업 시작");
        try {
            Thread.sleep(3000); // 3초 작업 시뮬레이션
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        log.info("Fixed Delay 작업 완료");
    }
    
    /**
     * fixedRate: 5초마다 실행 (이전 작업 완료 여부 무관)
     * 작업 시간이 3초 걸리면: 실행 → 3초 작업 → 2초 후 다음 실행 (총 5초)
     * 작업 시간이 7초 걸리면: 실행 → 7초 작업 → 즉시 다음 실행 (중복 실행 가능)
     */
    @Scheduled(fixedRate = 5000)
    public void fixedRateExample() {
        log.info("Fixed Rate 작업 시작");
        try {
            Thread.sleep(3000); // 3초 작업 시뮬레이션
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        log.info("Fixed Rate 작업 완료");
    }
    
    /**
     * 초기 지연과 함께 사용
     * 애플리케이션 시작 후 10초 대기, 이후 5초마다 실행
     */
    @Scheduled(fixedDelay = 5000, initialDelay = 10000)
    public void withInitialDelay() {
        log.info("Initial Delay 작업 실행");
    }
}

5. 비동기 스케줄러

스케줄러 작업이 오래 걸리는 경우, 비동기로 실행하여 다른 작업에 영향을 주지 않도록 할 수 있습니다.

5.1 비동기 설정

package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {
    
    /**
     * 비동기 작업을 위한 ThreadPool 설정
     */
    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);           // 기본 스레드 수
        executor.setMaxPoolSize(10);           // 최대 스레드 수
        executor.setQueueCapacity(100);        // 대기 큐 크기
        executor.setThreadNamePrefix("async-"); // 스레드 이름 접두사
        executor.initialize();
        return executor;
    }
}

5.2 비동기 스케줄러 예제

package com.example.scheduler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class AsyncScheduler {
    
    /**
     * 비동기 스케줄러
     * @Async를 사용하여 별도 스레드에서 실행
     */
    @Async("taskExecutor")
    @Scheduled(fixedDelay = 5000)
    public void asyncScheduledTask() {
        log.info("비동기 스케줄러 실행 - 스레드: {}", 
            Thread.currentThread().getName());
        
        try {
            Thread.sleep(3000); // 긴 작업 시뮬레이션
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        
        log.info("비동기 스케줄러 완료 - 스레드: {}", 
            Thread.currentThread().getName());
    }
    
    /**
     * 여러 비동기 작업 동시 실행
     */
    @Async("taskExecutor")
    @Scheduled(fixedRate = 3000)
    public void concurrentAsyncTask() {
        log.info("동시 실행 가능한 비동기 작업 - 스레드: {}", 
            Thread.currentThread().getName());
    }
}

6. 동적 스케줄러

동적 스케줄러는 런타임에 스케줄을 추가, 수정, 삭제할 수 있습니다. TaskScheduler를 사용하여 구현합니다.

6.1 동적 스케줄러 설정

package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@Configuration
public class DynamicSchedulerConfig {
    
    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(10);
        scheduler.setThreadNamePrefix("scheduled-task-");
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(60);
        scheduler.initialize();
        return scheduler;
    }
}

6.2 동적 스케줄러 Service

package com.example.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;

@Slf4j
@Service
@RequiredArgsConstructor
public class DynamicSchedulerService {
    
    private final TaskScheduler taskScheduler;
    
    // 실행 중인 스케줄을 저장
    private final Map<String, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
    
    /**
     * 고정 지연으로 동적 스케줄 추가
     */
    public void scheduleFixedDelay(String taskId, Runnable task, long delayMs) {
        ScheduledFuture<?> scheduledTask = taskScheduler.scheduleWithFixedDelay(
            task, 
            Instant.now().plusMillis(1000), // 1초 후 시작
            delayMs
        );
        scheduledTasks.put(taskId, scheduledTask);
        log.info("고정 지연 스케줄 추가: taskId={}, delay={}ms", taskId, delayMs);
    }
    
    /**
     * 고정 주기로 동적 스케줄 추가
     */
    public void scheduleFixedRate(String taskId, Runnable task, long periodMs) {
        ScheduledFuture<?> scheduledTask = taskScheduler.scheduleAtFixedRate(
            task,
            Instant.now().plusMillis(1000), // 1초 후 시작
            periodMs
        );
        scheduledTasks.put(taskId, scheduledTask);
        log.info("고정 주기 스케줄 추가: taskId={}, period={}ms", taskId, periodMs);
    }
    
    /**
     * Cron 표현식으로 동적 스케줄 추가
     */
    public void scheduleCron(String taskId, Runnable task, String cronExpression) {
        CronTrigger cronTrigger = new CronTrigger(cronExpression);
        ScheduledFuture<?> scheduledTask = taskScheduler.schedule(
            task,
            cronTrigger
        );
        scheduledTasks.put(taskId, scheduledTask);
        log.info("Cron 스케줄 추가: taskId={}, cron={}", taskId, cronExpression);
    }
    
    /**
     * 스케줄 취소
     */
    public void cancelSchedule(String taskId) {
        ScheduledFuture<?> scheduledTask = scheduledTasks.remove(taskId);
        if (scheduledTask != null) {
            scheduledTask.cancel(false);
            log.info("스케줄 취소: taskId={}", taskId);
        } else {
            log.warn("스케줄을 찾을 수 없음: taskId={}", taskId);
        }
    }
    
    /**
     * 모든 스케줄 취소
     */
    public void cancelAllSchedules() {
        scheduledTasks.forEach((taskId, task) -> {
            task.cancel(false);
            log.info("스케줄 취소: taskId={}", taskId);
        });
        scheduledTasks.clear();
    }
    
    /**
     * 실행 중인 스케줄 목록 조회
     */
    public Map<String, Boolean> getActiveSchedules() {
        Map<String, Boolean> activeSchedules = new ConcurrentHashMap<>();
        scheduledTasks.forEach((taskId, task) -> {
            activeSchedules.put(taskId, !task.isCancelled() && !task.isDone());
        });
        return activeSchedules;
    }
}

6.3 동적 스케줄러 Controller

package com.example.controller;

import com.example.service.DynamicSchedulerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/api/scheduler")
@RequiredArgsConstructor
public class SchedulerController {
    
    private final DynamicSchedulerService schedulerService;
    
    /**
     * 고정 지연 스케줄 추가
     */
    @PostMapping("/fixed-delay")
    public ResponseEntity<String> addFixedDelaySchedule(
            @RequestParam String taskId,
            @RequestParam long delayMs) {
        
        schedulerService.scheduleFixedDelay(taskId, () -> {
            log.info("동적 스케줄 실행: taskId={}", taskId);
        }, delayMs);
        
        return ResponseEntity.ok("스케줄 추가됨: " + taskId);
    }
    
    /**
     * Cron 스케줄 추가
     */
    @PostMapping("/cron")
    public ResponseEntity<String> addCronSchedule(
            @RequestParam String taskId,
            @RequestParam String cronExpression) {
        
        schedulerService.scheduleCron(taskId, () -> {
            log.info("Cron 스케줄 실행: taskId={}", taskId);
        }, cronExpression);
        
        return ResponseEntity.ok("Cron 스케줄 추가됨: " + taskId);
    }
    
    /**
     * 스케줄 취소
     */
    @DeleteMapping("/{taskId}")
    public ResponseEntity<String> cancelSchedule(@PathVariable String taskId) {
        schedulerService.cancelSchedule(taskId);
        return ResponseEntity.ok("스케줄 취소됨: " + taskId);
    }
    
    /**
     * 실행 중인 스케줄 목록
     */
    @GetMapping("/active")
    public ResponseEntity<Map<String, Boolean>> getActiveSchedules() {
        return ResponseEntity.ok(schedulerService.getActiveSchedules());
    }
}

7. 실제 사용 예제

7.1 데이터베이스 정리 스케줄러

package com.example.scheduler;

import com.example.repository.LogRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Slf4j
@Component
@RequiredArgsConstructor
public class DatabaseCleanupScheduler {
    
    private final LogRepository logRepository;
    
    /**
     * 매일 자정에 30일 이상 된 로그 삭제
     */
    @Scheduled(cron = "0 0 0 * * ?")
    @Transactional
    public void cleanupOldLogs() {
        log.info("오래된 로그 정리 시작");
        
        LocalDateTime cutoffDate = LocalDateTime.now().minusDays(30);
        int deletedCount = logRepository.deleteByCreatedAtBefore(cutoffDate);
        
        log.info("오래된 로그 {}개 삭제 완료", deletedCount);
    }
}

7.2 캐시 갱신 스케줄러

package com.example.scheduler;

import com.example.service.CacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class CacheRefreshScheduler {
    
    private final CacheService cacheService;
    
    /**
     * 매시간 정각에 캐시 갱신
     */
    @Scheduled(cron = "0 0 * * * ?")
    public void refreshCache() {
        log.info("캐시 갱신 시작");
        cacheService.refreshAllCaches();
        log.info("캐시 갱신 완료");
    }
    
    /**
     * 매일 오전 3시에 모든 캐시 초기화
     */
    @Scheduled(cron = "0 0 3 * * ?")
    @CacheEvict(allEntries = true, value = {"users", "products", "categories"})
    public void clearAllCaches() {
        log.info("모든 캐시 초기화 완료");
    }
}

7.3 통계 수집 스케줄러

package com.example.scheduler;

import com.example.service.StatisticsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class StatisticsScheduler {
    
    private final StatisticsService statisticsService;
    
    /**
     * 매일 오전 1시에 전날 통계 수집 (비동기)
     */
    @Async("taskExecutor")
    @Scheduled(cron = "0 0 1 * * ?")
    public void collectDailyStatistics() {
        log.info("일일 통계 수집 시작");
        try {
            statisticsService.collectDailyStatistics();
            log.info("일일 통계 수집 완료");
        } catch (Exception e) {
            log.error("일일 통계 수집 실패", e);
        }
    }
    
    /**
     * 매주 월요일 오전 2시에 주간 통계 수집
     */
    @Async("taskExecutor")
    @Scheduled(cron = "0 0 2 ? * MON")
    public void collectWeeklyStatistics() {
        log.info("주간 통계 수집 시작");
        try {
            statisticsService.collectWeeklyStatistics();
            log.info("주간 통계 수집 완료");
        } catch (Exception e) {
            log.error("주간 통계 수집 실패", e);
        }
    }
}

8. 주의사항 및 Best Practice

8.1 주의사항

  • 중복 실행 방지: fixedRate 사용 시 작업 시간이 주기보다 길면 중복 실행될 수 있음
  • 예외 처리: 스케줄러 내부에서 예외가 발생해도 다음 실행은 계속됨. 반드시 try-catch로 처리
  • 트랜잭션: DB 작업이 있는 경우 @Transactional 사용
  • 클러스터 환경: 여러 서버에서 동일한 스케줄러가 중복 실행되지 않도록 주의 (Redis Lock 등 사용)

8.2 Best Practice

  • 로깅: 스케줄러 시작/완료/실패를 모두 로깅
  • 모니터링: 스케줄러 실행 시간과 빈도를 모니터링
  • 리소스 관리: 오래 걸리는 작업은 비동기로 처리
  • 설정 외부화: Cron 표현식이나 주기를 application.yml에 설정
  • 테스트: 스케줄러 로직은 별도 메서드로 분리하여 단위 테스트 가능하게

8.3 설정 외부화 예제

scheduler:
  cleanup:
    cron: "0 0 0 * * ?"  # 매일 자정
  cache-refresh:
    cron: "0 0 * * * ?"   # 매시간 정각
  statistics:
    cron: "0 0 1 * * ?"   # 매일 오전 1시
package com.example.scheduler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class ConfigurableScheduler {
    
    @Value("${scheduler.cleanup.cron}")
    private String cleanupCron;
    
    @Scheduled(cron = "${scheduler.cleanup.cron}")
    public void cleanupTask() {
        log.info("정리 작업 실행");
    }
}
✅ 핵심 요약:
  • @EnableScheduling으로 스케줄러 활성화
  • @Scheduled로 간단한 스케줄링 구현
  • Cron 표현식으로 복잡한 스케줄링 가능
  • fixedDelayfixedRate의 차이 이해
  • 비동기 스케줄러로 긴 작업 처리
  • 동적 스케줄러로 런타임에 스케줄 관리
  • 예외 처리와 로깅 필수

 

카카오톡 오픈채팅 링크

https://open.kakao.com/o/seCteX7h

 

추가로 궁금한 점이 있으시면 댓글로 남겨주세요! 🚀


참고 자료

 

728x90