@Configuration
@EnableScheduling
public class SchedulingConfig {
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler s = new ThreadPoolTaskScheduler();
s.setPoolSize(4); // 배치 수 + 여유 1
s.setThreadNamePrefix("batch-");
s.setErrorHandler(t ->
log.error("스케줄 작업 실패", t)); // 예외 삼키지 않고 로그
s.setWaitForTasksToCompleteOnShutdown(true); // shutdown 시 진행 중 대기
s.setAwaitTerminationSeconds(30);
return s;
}
}
@Component
public class SyncJob {
@Scheduled(fixedDelayString = "${sync.delay:60000}")
public void syncFromLegacy() { /* ... */ }
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")
public void dailyReport() { /* ... */ }
}fixedDelayString 은 프로퍼티로 간격을 외부화할 때 씁니다. zone 을 명시했으므로 컨테이너의 OS 시간대가 UTC 여도 항상 KST 02시에 돕니다.
<!-- pom.xml -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
</dependency>@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource ds) {
return new JdbcTemplateLockProvider(ds);
}
}
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")
@SchedulerLock(name = "dailyReport",
lockAtMostFor = "30m", lockAtLeastFor = "1m")
public void dailyReport() { /* ... */ }-- Oracle 용 shedlock 테이블
CREATE TABLE shedlock (
name VARCHAR2(64) NOT NULL PRIMARY KEY,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL,
locked_by VARCHAR2(255) NOT NULL
);lockAtMostFor 는 작업이 실제로 걸리는 최대 예상 시간보다 넉넉히 잡아야 합니다. 짧게 잡으면 작업이 끝나기 전에 잠금이 풀려 다른 인스턴스가 같이 돕니다. lockAtLeastFor 는 인스턴스 간 시계 차이를 흡수하는 최소 보유 시간입니다.
@RestController
@RequestMapping("/admin/jobs")
public class JobAdminController {
private final SafeTaskHolder dailyReport; // 스케줄과 같은 인스턴스 공유
@PostMapping("/{name}/run")
public ResponseEntity<String> runNow(@PathVariable String name) {
SafeTask task = dailyReport.find(name);
if (task.isRunning()) // 진행 중이면 겹치지 않게 거절
return ResponseEntity.status(409).body("이미 실행 중입니다");
new Thread(task).start();
return ResponseEntity.accepted().body("실행을 시작했습니다");
}
}수동 트리거가 스케줄 실행과 겹치는 (a) 상황을 막으려면, 관리 API 가 부르는 것도 스케줄이 부르는 것과 같은 SafeTask 인스턴스 여야 합니다. 그래야 running 플래그가 공유돼 겹침이 막힙니다.
CREATE TABLE job_run (
job_name VARCHAR2(64) NOT NULL,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
status VARCHAR2(16) NOT NULL, -- RUNNING, OK, FAIL
message VARCHAR2(2000)
);
-- 마지막 성공 이후 N분: 알림 조건
SELECT job_name,
ROUND((SYSDATE - MAX(finished_at)) * 24 * 60) AS minutes_since_ok
FROM job_run
WHERE status = 'OK'
GROUP BY job_name
HAVING (SYSDATE - MAX(finished_at)) * 24 * 60 > 60; -- 60분 이상 성공 없음SafeTask.run 의 시작 시점에 RUNNING 행을 insert 하고, 종료 시점에 같은 행을 OK/FAIL 로 update 합니다. 이 테이블이 있으면 "배치가 죽었는지" 를 콘솔 로그를 뒤지지 않고 SQL 한 줄로 확인할 수 있습니다.