Spring @Scheduled 기본 동작
Spring의 @Scheduled는 주기적 작업을 간편하게 실행하지만, 분산 환경에서는 치명적인 문제가 있다. 인스턴스가 3대면 같은 작업이 3번 동시 실행된다. 이를 해결하는 것이 ShedLock이다.
문제: 다중 인스턴스 중복 실행
// 인스턴스가 3대면 매분 3번 실행됨
@Scheduled(cron = "0 * * * * *")
public void sendDailyReport() {
// 이메일이 3통 발송됨!
emailService.sendReport();
}
기존 해결 방식인 “리더 선출”, “특정 인스턴스만 실행” 등은 복잡하고 장애에 취약하다. ShedLock은 DB/Redis 기반 분산 락으로 이 문제를 간결하게 해결한다.
ShedLock 설정
의존성 추가
<!-- pom.xml -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.16.0</version>
</dependency>
<!-- Lock Provider 선택 (JDBC) -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>5.16.0</version>
</dependency>
DB 테이블 생성
-- PostgreSQL
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL PRIMARY KEY,
lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255) NOT NULL
);
-- MySQL
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL PRIMARY KEY,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
locked_by VARCHAR(255) NOT NULL
);
Configuration
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // DB 시간 사용 (인스턴스 간 시간 차이 방지)
.build()
);
}
}
usingDbTime()은 필수다. 인스턴스마다 시스템 시간이 다를 수 있으므로, DB 서버 시간을 기준으로 락을 관리해야 한다.
@SchedulerLock 사용법
@Service
@RequiredArgsConstructor
public class ReportScheduler {
private final ReportService reportService;
@Scheduled(cron = "0 0 9 * * MON") // 매주 월요일 9시
@SchedulerLock(
name = "weekly_report",
lockAtMostFor = "30m", // 최대 30분 락 유지
lockAtLeastFor = "5m" // 최소 5분 락 유지
)
public void sendWeeklyReport() {
reportService.generateAndSend();
}
}
lockAtMostFor vs lockAtLeastFor
| 설정 | 역할 | 가이드 |
|---|---|---|
lockAtMostFor |
락 최대 유지 시간. 인스턴스 장애 시 락이 영원히 잡히는 것 방지 | 작업 예상 최대 시간의 2~3배 |
lockAtLeastFor |
락 최소 유지 시간. 작업이 빨리 끝나도 다른 인스턴스가 바로 실행하지 못하게 방지 | 스케줄 간격의 절반 이하 |
// 매 5분 실행, 작업은 보통 10초에 완료
@Scheduled(fixedRate = 300_000) // 5분
@SchedulerLock(
name = "sync_inventory",
lockAtMostFor = "4m", // 장애 시 4분 후 자동 해제
lockAtLeastFor = "2m" // 다음 실행까지 최소 2분 대기
)
public void syncInventory() { ... }
Redis Lock Provider
DB 대신 Redis를 사용하면 락 획득/해제가 더 빠르다:
<!-- Redis Provider -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-redis-spring</artifactId>
<version>5.16.0</version>
</dependency>
@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
return new RedisLockProvider(connectionFactory, "prod-env");
// 두 번째 인자는 환경 접두사 (dev/staging/prod 분리)
}
| Provider | 장점 | 단점 |
|---|---|---|
| JDBC | 추가 인프라 불필요, 트랜잭션과 일관성 | DB 부하 추가, 상대적으로 느림 |
| Redis | 빠른 락 획득, DB 부하 없음 | Redis 인프라 필요, 재시작 시 락 유실 가능 |
| MongoDB | MongoDB 사용 환경에 자연스러움 | RDBMS가 없는 환경 전용 |
프로그래밍 방식 락
@Scheduled 외에도, 수동으로 락을 획득/해제할 수 있다:
@Service
@RequiredArgsConstructor
public class MigrationService {
private final LockProvider lockProvider;
public void runMigration() {
LockConfiguration config = new LockConfiguration(
Instant.now(),
"data_migration",
Duration.ofHours(2), // lockAtMostFor
Duration.ofMinutes(5) // lockAtLeastFor
);
LockingTaskExecutor executor = new DefaultLockingTaskExecutor(lockProvider);
executor.executeWithLock(
(Runnable) this::doMigration,
config
);
}
private void doMigration() {
// 마이그레이션 로직 - 한 인스턴스만 실행
}
}
TaskScheduler Thread Pool 설정
Spring의 기본 스케줄러는 단일 스레드다. 여러 @Scheduled 작업이 있으면 하나가 지연될 때 나머지도 밀린다:
@Configuration
public class SchedulerConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5); // 동시 실행 가능한 작업 수
scheduler.setThreadNamePrefix("sched-");
scheduler.setErrorHandler(t ->
log.error("Scheduled task error", t));
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
return scheduler;
}
}
모니터링과 알림
// ShedLock 테이블 모니터링 쿼리
-- 현재 잡힌 락 확인
SELECT * FROM shedlock WHERE lock_until > NOW();
-- 비정상 장기 락 감지 (1시간 이상)
SELECT name, locked_by, locked_at,
EXTRACT(EPOCH FROM (NOW() - locked_at)) / 60 AS minutes_held
FROM shedlock
WHERE lock_until > NOW()
AND locked_at < NOW() - INTERVAL '1 hour';
Micrometer와 연동하여 스케줄 실행 메트릭을 추적할 수도 있다:
@Scheduled(fixedRate = 60_000)
@SchedulerLock(name = "metric_task", lockAtMostFor = "50s")
public void trackedTask() {
Timer.Sample sample = Timer.start(meterRegistry);
try {
doWork();
} finally {
sample.stop(Timer.builder("scheduled.task")
.tag("name", "metric_task")
.register(meterRegistry));
}
}
운영 주의사항
- lockAtMostFor는 필수: 설정하지 않으면 인스턴스 장애 시 락이 영원히 해제되지 않는다
- 작업 이름 유니크:
name이 같으면 서로 다른 작업이 충돌한다. 패키지+메서드명 조합을 권장 - DB 시간 동기화:
usingDbTime()을 사용하지 않으면 인스턴스 간 시간 차이로 락이 오작동한다 - Graceful Shutdown: 작업 실행 중 인스턴스가 종료되면 lockAtMostFor까지 락이 유지된다. 종료 대기 시간을 충분히 설정하라
- 테스트: 단위 테스트에서는 ShedLock을 비활성화하라. 테스트 환경에서 DB 락은 불필요하다
정리
Spring ShedLock은 @Scheduled의 다중 인스턴스 중복 실행 문제를 DB/Redis 기반 분산 락으로 해결한다. lockAtMostFor로 장애 시 자동 해제를, lockAtLeastFor로 연속 실행 방지를 보장한다. 리더 선출보다 단순하고, 인프라 복잡도가 낮아 분산 환경 스케줄링의 표준 선택이다.