Spring ShedLock 분산 스케줄링

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로 연속 실행 방지를 보장한다. 리더 선출보다 단순하고, 인프라 복잡도가 낮아 분산 환경 스케줄링의 표준 선택이다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux