@Scheduled 메서드가 예외를 던지면 그 스케줄은 다시 돌지 않습니다. 로그도 남지 않아서, 다음 날 데이터가 안 쌓인 걸 보고서야 알게 됩니다. SafeTask 처럼 메서드 본문 전체를 try-catch(Throwable) 로 감싸고 실패를 로그·메트릭으로 남겨야 합니다.
Spring 기본 풀은 스레드 1개입니다. 배치를 하나씩 추가하다 보면 어느 순간 서로 밀어내기 시작합니다. spring.task.scheduling.pool.size 를 배치 개수에 맞춰 늘려야 합니다.
작업이 간격보다 길어지는 날(예: 데이터량 급증)에 fixedRate 는 밀린 회차를 간격 없이 연달아 돌립니다. 짧은 시간에 DB 커넥션을 몰아 써서 부하가 튑니다. 부하가 우려되면 fixedDelay 로 바꾸거나, 실행 시간이 임계치를 넘으면 이번 회차를 건너뛰는 로직을 추가합니다.
서버를 2대로 늘렸는데 겹침 방지 로직이 프로세스 안(AtomicBoolean)에만 있으면, 두 인스턴스가 각자 잠금을 얻은 것처럼 동작해 같은 리포트를 두 번 보냅니다. 프로세스를 넘는 잠금(ShedLock, DB 행 잠금)이 필요합니다.
// ❌ 리눅스 crontab 습관대로 5필드로 씀
@Scheduled(cron = "0 2 * * *") // IllegalArgumentException
// ✅ Spring 은 초가 맨 앞에 온다
@Scheduled(cron = "0 0 2 * * *")리눅스 crontab 경험이 있으면 초 필드를 빠뜨리기 쉽습니다. Spring 은 초까지 포함한 6필드이므로 앱 시작 시점에 IllegalArgumentException 으로 바로 드러납니다.
zone 속성 없이 배포한 cron 배치는 JVM 기본 시간대를 씁니다. 도커 이미지 기본 시간대가 UTC 라면, 02:00 은 KST 로 11:00 이 됩니다. zone = "Asia/Seoul" 을 항상 명시해야 합니다.
// ❌ private 이거나 인자가 있으면 스케줄 등록 자체가 안 되거나 무시된다
@Scheduled(fixedDelay = 60000)
private void sync(String region) { /* ... */ }Spring 의 스케줄링은 프록시 기반입니다. private 메서드는 프록시가 감쌀 수 없고, 인자가 있는 메서드는 스케줄러가 호출할 방법이 없습니다. public·인자 없음이 기본 요건입니다. 같은 클래스 안에서 this.sync() 로 직접 호출하는 것도 프록시를 건너뛰어 겹침 방지 등 부가 로직이 적용되지 않으니 피해야 합니다.
배치 전체를 한 트랜잭션으로 묶으면 몇만 건을 처리하는 동안 커넥션 하나를 계속 물고 있습니다. 다른 요청이 커넥션 풀 고갈로 대기합니다. @Scheduled 메서드는 짧게 단위를 나눠 각 단위마다 별도 트랜잭션(chunk)으로 처리해야 합니다.
shutdownNow() 를 바로 호출하거나 awaitTermination 대기 시간을 너무 짧게 잡으면, 진행 중인 배치가 인터럽트로 끊깁니다. 이미 일부만 커밋된 상태로 남을 수 있습니다. shutdown() 뒤 충분한 awaitTermination 을 기다리고, 배치는 재실행해도 안전하게 멱등 설계를 해 둬야 합니다.
ShedLock 의 lockAtMostFor 를 작업의 평소 소요 시간과 같게 잡으면, 데이터가 늘어 작업이 조금만 길어져도 잠금이 먼저 풀립니다. 그 사이 다른 인스턴스가 같은 작업을 또 시작합니다. lockAtMostFor 는 최대 예상 시간의 2~3배로 여유 있게 잡아야 합니다.