본문으로 건너뛰기
김신건의 로그

[Spring] @Scheduled와 @Async

· 수정 · 📖 약 1분 · 438자/단어 #spring #scheduling #async #task
spring scheduled, spring async, task scheduler, TaskExecutor, cron expression

정의

Spring은 @Scheduled (정기 작업)와 @Async (비동기 메서드)를 어노테이션으로 제공. ThreadPool, TaskScheduler 설정 가능. Quartz 같은 본격 스케줄러는 별도.

@Scheduled

@SpringBootApplication
@EnableScheduling
public class App { }
@Component
public class ScheduledTasks {

    // 5초마다 (fixedRate: 시작 기준)
    @Scheduled(fixedRate = 5000)
    public void everyFiveSeconds() {
        log.info("tick");
    }

    // 이전 실행 종료 5초 후 (fixedDelay)
    @Scheduled(fixedDelay = 5000)
    public void afterFiveSecondsDelay() { ... }

    // 시작 후 10초 대기, 그 후 5초마다
    @Scheduled(initialDelay = 10000, fixedRate = 5000)
    public void delayedStart() { ... }

    // Cron 표현식
    @Scheduled(cron = "0 0 3 * * *")    // 매일 03:00
    public void dailyAt3am() { ... }

    @Scheduled(cron = "0 */15 * * * *")  // 15분마다
    public void every15Min() { ... }

    @Scheduled(cron = "0 0 9 * * MON")   // 매주 월요일 09:00
    public void mondayMorning() { ... }
}

Cron 표현식

Spring Cron: 초 분 시 일 월 요일

0 0 12 * * ?         매일 정오
0 15 10 ? * *        매일 10:15
0 0 18 ? * MON-FRI   평일 18:00
0 0 0 1 * ?          매월 1일 0시
0 0 0 1 1 ?          매년 1월 1일 0시
*/5 * * * * ?        5초마다

?는 day 또는 dow 중 하나만 사용. 모던하게는 * 또는 명시.

표현식 외부화

app:
  cron:
    cleanup: "0 0 3 * * *"
    report: "0 0 9 * * MON"
@Scheduled(cron = "${app.cron.cleanup}")
public void cleanup() { ... }

TaskScheduler 설정

기본은 단일 스레드. 여러 작업 동시 실행 필요하면 풀.

@Configuration
@EnableScheduling
public class SchedulingConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        registrar.setScheduler(taskScheduler());
    }

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

분산 환경

@Scheduled(fixedRate = 60000)
public void everyMinute() { ... }

여러 인스턴스 모두 실행 → 중복. 해결:

ShedLock

implementation("net.javacrumbs.shedlock:shedlock-spring:5.16.0")
implementation("net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.16.0")
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfig {
    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(dataSource);
    }
}

@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "cleanupTask", lockAtMostFor = "1h", lockAtLeastFor = "5m")
public void cleanup() { ... }

DB lock으로 한 노드만 실행 보장.

대안: Quartz cluster, Redis lock (Redlock 패턴).

@Async

@SpringBootApplication
@EnableAsync
public class App { }
@Service
public class NotificationService {

    @Async
    public void sendEmail(String to, String subject) {
        // 별도 스레드에서 실행
        emailClient.send(to, subject);
    }

    @Async
    public CompletableFuture<String> fetchData(String url) {
        String result = restClient.get(url);
        return CompletableFuture.completedFuture(result);
    }
}

반환 타입

  • void: fire-and-forget
  • Future<T> / CompletableFuture<T>: 결과 받음
  • ListenableFuture<T> (deprecated)
CompletableFuture<String> future = notificationService.fetchData(url);
future.thenAccept(result -> ...);

TaskExecutor 설정

@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> log.error("Async error in {}", method, ex);
    }
}

다중 executor

@Bean(name = "emailExecutor")
public Executor emailExecutor() { ... }

@Bean(name = "reportExecutor")
public Executor reportExecutor() { ... }

@Async("emailExecutor")
public void sendEmail(...) { ... }

@Async("reportExecutor")
public void generateReport(...) { ... }

자주 보는 패턴

외부 API fire-and-forget

@Async
public void trackEvent(String event, User user) {
    analyticsClient.track(event, user.id());
}

// controller
analytics.trackEvent("signup", user);    // 즉시 반환, 별 스레드에서 호출
return ResponseEntity.ok();

병렬 데이터 fetch

@Async public CompletableFuture<User> fetchUser(Long id) { ... }
@Async public CompletableFuture<List<Order>> fetchOrders(Long userId) { ... }
@Async public CompletableFuture<Stats> fetchStats(Long userId) { ... }

CompletableFuture<Dashboard> dashboard = fetchUser(id)
    .thenCombine(fetchOrders(id), (u, o) -> ...)
    .thenCombine(fetchStats(id), (acc, s) -> ...);

return dashboard.get();

WebFlux의 Mono.zip과 동등.

정기 cleanup

@Component
public class CleanupTasks {

    @Scheduled(cron = "0 0 3 * * *")
    @Transactional
    public void deleteOldSessions() {
        LocalDateTime cutoff = LocalDateTime.now().minusDays(30);
        sessionRepository.deleteByExpiresAtBefore(cutoff);
    }

    @Scheduled(cron = "0 0 4 * * *")
    public void purgeDeletedUsers() {
        // soft-delete 30일 지난 사용자 영구 삭제
    }
}

리포트 발송

@Scheduled(cron = "0 0 9 * * MON")
public void weeklyReport() {
    List<User> subscribers = userRepository.findByWeeklyReportTrue();
    for (User user : subscribers) {
        reportService.sendWeeklyReport(user);    // @Async 메서드
    }
}

함정

1. @Async 자기 호출

@Service
public class MyService {
    public void caller() {
        asyncMethod();    // proxy 거치지 않음 → 동기 실행!
    }

    @Async
    public void asyncMethod() { ... }
}

다른 Bean으로 분리 또는 @Lazy self-injection.

2. @Async void 예외 처리

@Async
public void doIt() {
    throw new RuntimeException();    // 어디로 가나?
}

AsyncUncaughtExceptionHandler로 처리. 안 그러면 silent fail.

3. 트랜잭션 + @Async

@Transactional
@Async
public void doIt() { ... }

@Async로 새 스레드 → 호출자 트랜잭션 컨텍스트 없음. 새 트랜잭션 시작.

4. @Scheduled 메서드는 인자 X, void

@Scheduled(cron = "...")
public void doIt(String arg) { ... }    // 에러

파라미터 없는 void 메서드만.

5. 분산 환경의 중복

@Scheduled(cron = "...")
public void run() { ... }

3 인스턴스 = 3번 실행. ShedLock 또는 leader election.

Quartz Scheduler

복잡한 스케줄링 (cron 외에 calendar, listener, persistence) 필요 시:

implementation("org.springframework.boot:spring-boot-starter-quartz")

DB에 작업 영속화 → 인스턴스 재시작 후에도 재개. Cluster 지원.

Spring Scheduling vs Quartz

@ScheduledQuartz
설정간단 (어노테이션)복잡
영속성XO
클러스터ShedLock내장
동적 스케줄일부풍부

대부분 앱은 @Scheduled로 충분. 본격 batch는 Spring Batch + Quartz.

모니터링

  • TaskScheduler/TaskExecutor 메트릭 (Micrometer 자동)
  • 실행 시간, 실패 횟수
  • Actuator /actuator/scheduledtasks (활성 작업)

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기