[Spring] @Scheduled 멀티 인스턴스 중복 실행
환경 : Spring Boot 3.x, Kotlin 1.9, Kubernetes
1. 원인
- 여러 pod/replica가 동일한 스케줄 코드 보유 — 각자 독립적으로 실행
- 배치 작업이 멱등하지 않으면 데이터 중복 생성/처리
- 분산 락 없이 여러 인스턴스가 동시 작업
2. 해결 방법
2.1) ShedLock — 분산 락 기반 스케줄 중복 방지
가장 간단한 방법. Redis/MongoDB/JDBC 등의 공유 저장소에 락을 기록해서 한 번에 한 인스턴스만 실행
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
implementation("net.javacrumbs.shedlock:shedlock-spring:5.9.1")
implementation("net.javacrumbs.shedlock:shedlock-provider-mongo:5.9.1")
}
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
class ScheduleConfig
@Bean
fun lockProvider(mongoClient: MongoClient): LockProvider {
return MongoLockProvider(mongoClient.getDatabase("mydb"))
}
@Component
class ScheduledJob {
@Scheduled(cron = "0 0 * * * *") // 매시 정각
@SchedulerLock(
name = "dailyReport",
lockAtMostFor = "5m", // 최대 5분 후 락 자동 해제
lockAtLeastFor = "1m" // 최소 1분간 락 유지
)
fun generateDailyReport() {
println("[${LocalDateTime.now()}] 리포트 생성 시작 (pod: ${System.getenv("HOSTNAME")})")
// 실제 배치 로직
}
}
lockAtMostFor — 작업이 비정상 종료되도 락이 영원히 안 풀리지 않도록 최대 시간 설정
lockAtLeastFor — 작업이 너무 빨리 끝나도 일정 시간은 락 유지 (다른 인스턴스가 바로 또 실행하지 않도록)
2.2) ShedLock with JDBC
MongoDB 대신 DB 사용하는 경우
// build.gradle.kts
dependencies {
implementation("net.javacrumbs.shedlock:shedlock-spring:5.9.1")
implementation("net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.9.1")
}
@Bean
fun lockProvider(dataSource: DataSource): LockProvider {
return JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(JdbcTemplate(dataSource))
.usingDbTime()
.build()
)
}
DB에 shedlock 테이블 생성 필요
-- PostgreSQL 예시
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);
2.3) ShedLock with Redis
// build.gradle.kts
dependencies {
implementation("net.javacrumbs.shedlock:shedlock-spring:5.9.1")
implementation("net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.9.1")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
}
@Bean
fun lockProvider(connectionFactory: RedisConnectionFactory): LockProvider {
return RedisLockProvider(connectionFactory)
}
2.4) Quartz Cluster 모드
고급 스케줄링 기능(동적 스케줄 등록/삭제, 실패 재시도)이 필요하면 Quartz 사용
// build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-quartz")
# application.yml
spring:
quartz:
job-store-type: jdbc
properties:
org.quartz.jobStore.isClustered: true
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
org.quartz.scheduler.instanceId: AUTO
Quartz 테이블 스키마는 공식 문서 참고. ShedLock보다 무겁지만 분산 환경에서 안정적
2.5) Leader Election 패턴
Kubernetes 환경에서는 한 pod만 leader로 선출해서 스케줄 실행
// build.gradle.kts
implementation("org.springframework.integration:spring-integration-zookeeper")
@Configuration
class LeaderConfig {
@Bean
fun leaderInitiator(curatorFramework: CuratorFramework): LeaderInitiator {
return LeaderInitiator(curatorFramework, Candidate("schedulerLeader", Runnable {
println("리더 선출됨")
}))
}
}
@Component
class ScheduledJob(private val leaderInitiator: LeaderInitiator) {
@Scheduled(fixedRate = 60000)
fun task() {
if (leaderInitiator.context.isLeader) {
println("리더만 실행")
}
}
}
Zookeeper/etcd 같은 별도 인프라 필요. 대부분 환경에서는 ShedLock이 더 간단
2.6) Profile 격리 — 단일 인스턴스 전용
스케줄 작업을 특정 profile에서만 활성화
@Component
@Profile("scheduler")
class ScheduledJob {
@Scheduled(cron = "0 0 * * * *")
fun generateReport() { ... }
}
배포 시 한 pod만 SPRING_PROFILES_ACTIVE=scheduler 환경변수 주입. 나머지 pod는 스케줄 코드 자체가 빈 등록 안 됨
간단하지만 해당 pod가 죽으면 스케줄 중단됨 — 고가용성 낮음
3. 디버깅
3.1) 로그에 hostname/pod 이름 출력
여러 인스턴스 중 어디서 실행됐는지 확인
@Component
class ScheduledJob {
@Scheduled(cron = "0 * * * * *")
@SchedulerLock(name = "test", lockAtMostFor = "1m")
fun task() {
val hostname = System.getenv("HOSTNAME") ?: "local"
println("[${LocalDateTime.now()}] 실행됨 (pod: $hostname)")
}
}
ShedLock 적용 전에는 pod 개수만큼 로그 찍힘. 적용 후에는 하나만 찍혀야 정상
3.2) ShedLock 락 상태 확인
MongoDB 예시
db.shedLock.find()
JDBC 예시
SELECT * FROM shedlock;
lock_until 시간이 현재보다 미래면 락 유지 중
※ ShedLock이 가장 범용적. 간단한 배치는 ShedLock, 복잡한 스케줄 관리는 Quartz Cluster, 인프라 통제 가능하면 Leader Election
'TroubleShooting' 카테고리의 다른 글
| [Spring] @Value 주입이 null인 경우 (0) | 2026.05.26 |
|---|---|
| [Spring] @Async 동작하지 않는 경우 (0) | 2026.04.29 |
| [JPA] save()가 INSERT 대신 SELECT를 날리는 경우 (0) | 2026.04.15 |
| [JPA] MultipleBagFetchException (0) | 2026.04.04 |
| [vue] CORS 해결 (1) | 2022.06.19 |