728x90
반응형

[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


728x90
반응형

+ Recent posts