Framework/Spring

다중 인스턴스 환경에 ShedLock 적용하기

chanyoun 2026. 7. 13. 18:03

 

상황

@Scheduled가 붙은 메서드는 애플리케이션 인스턴스마다 등록됩니다. 따라서 다중 인스턴스 환경에서는 같은 스케줄러가 여러 번 실행되어 기대했던 동작과 다른 결과를 만들 수 있습니다.

서버가 한 대일 때는 문제가 없지만, 무중단 배포를 위한 blue/green 환경, 오토 스케일링, 이중화 구성으로 애플리케이션 인스턴스가 둘 이상이 되면 모든 서버가 같은 시각에 같은 작업을 실행합니다.

00:00

서버 A: @Scheduled 작업 실행
서버 B: @Scheduled 작업 실행
서버 C: @Scheduled 작업 실행

이러한 중복 실행을 막기 위해 ShedLock을 사용할 수 있습니다.

 

ShedLock 이란?

ShedLock은 여러 인스턴스가 같은 스케줄러를 동시에 실행하지 않도록 돕는 분산 락 라이브러리입니다.

DB, Redis 같은 외부 저장소에 락 정보를 기록하고, 스케줄러 메서드를 실행하기 전에 락 획득을 시도합니다.

서버 A ─┐
서버 B ─┼─ "daily-batch" 락 획득 시도 ─ 공유 DB의 shedlock 테이블
서버 C ─┘

락 획득 성공: 메서드 본문 실행
락 획득 실패: 메서드 본문을 실행하지 않고 이번 주기 스킵

락을 획득한 인스턴스만 스케줄러 본문을 실행하고, 나머지 인스턴스는 대기하지 않고 이번 실행을 건너뜁니다.

 

설정

 

1. 의존성을 추가합니다

ShedLock은 Spring 연동 모듈과 락 저장소별 Provider를 함께 추가합니다. DB를 공유 저장소로 사용할 경우 JDBC Provider를 사용합니다.

dependencies {
    implementation "net.javacrumbs.shedlock:shedlock-spring:5.16.0"
    implementation "net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.16.0"
}

두 라이브러리의 버전은 반드시 동일하게 맞춰야 합니다.

현재 프로젝트는 Spring Boot 3.2를 사용하므로, 공식 호환표에서 Spring Boot 3.2 대상인 ShedLock 5.x 중 5.16.0을 사용했습니다.

schedLock-version.png|419

 

2. 락 테이블을 생성합니다

CREATE TABLE shedlock (
    name VARCHAR(64) NOT NULL,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at TIMESTAMP(3) NOT NULL,
    locked_by VARCHAR(255) NOT NULL,
    PRIMARY KEY (name)
);

ShedLock은 이 테이블의 한 행으로 락을 관리합니다. 각 필드의 역할은 다음과 같습니다.

  • name: 락의 식별자입니다. 같은 이름의 작업끼리만 경쟁하므로 PK여야 합니다. 여러 인스턴스가 동시에 같은 락을 만들려 해도 DB는 한 행만 보장합니다.
  • lock_until: 이 시각 전까지 다른 인스턴스는 같은 락을 얻을 수 없습니다.
  • locked_at: 락을 획득한 시각입니다. 락이 언제부터 유지됐는지 확인할 때 사용합니다.
  • locked_by: 락을 획득한 인스턴스 식별자입니다. 어떤 서버가 현재 작업을 실행 중인지 운영 환경에서 확인할 수 있습니다.

예를 들어 서버 A가 shed-lock-example-scheduler 락을 얻으면 lock_until을 미래 시각으로 갱신합니다. 서버 B는 같은 이름의 락을 시도하지만 아직 만료 전이므로 획득하지 못하고, 메서드 본문을 실행하지 않습니다.

 

3. Scheduler Lock을 활성화하고 LockProvider를 등록합니다

@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "1h")
public class ShedLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime()
                .build()
        );
    }
}

@EnableSchedulerLock(defaultLockAtMostFor = "1h")defaultLockAtMostFor는 각 @SchedulerLock에서 값을 따로 주지 않았을 때 사용할 최대 락 유지 시간입니다. 서버가 작업 중 종료되더라도 이 시간이 지나면 락이 만료되어 다른 서버가 작업을 시작할 수 있습니다.

lockAtMostFor는 락을 반드시 그 시간만큼 유지한다는 뜻이 아닙니다. 작업이 정상 종료되면 락은 바로 해제됩니다. 이 값은 서버 장애 시 락이 영구히 남지 않도록 하는 최대 만료 시간이며, 실제 작업의 최장 실행 시간보다 충분히 길어야 합니다. 작업이 끝나기 전에 시간이 만료되면 다른 서버도 락을 획득해 중복 실행될 수 있습니다.

usingDbTime()은 앱 서버 시간이 아닌 DB 시간을 기준으로 락을 판단하게 합니다. 여러 서버의 시스템 시간이 다른 경우가 있을수 있기에, DB 의 시간을 기준으로 사용합니다.

 

4. 중복 실행을 막을 메서드에 락 이름을 부여합니다

@Component
@RequiredArgsConstructor
public class ShedLockExampleScheduler {

    private final SchedulerLoggerProvider schedulerLoggerProvider;
    private final Environment environment;

    @Scheduled(fixedRate = 60000)
    @SchedulerLock(
        name = "shed-lock-example-scheduler",
        lockAtMostFor = "1m",
        lockAtLeastFor = "59s"
    )
    public void logLockAcquisition() {
		 // 로직
    }
}

name은 프로젝트 전체에서 고유해야 합니다. 서로 다른 스케줄러가 같은 이름을 쓰면 의도치 않게 서로의 실행을 막습니다.

lockAtMostFor = "1m"은 이 예시 작업에만 적용하는 최대 락 유지 시간입니다. 서버가 작업 중 종료되면 최대 1분 뒤 다른 서버가 락을 얻을 수 있습니다.

lockAtLeastFor = "59s"는 락을 획득한 시점부터 락을 최소로 유지할 시간입니다. 작업이 바로 끝나도 ShedLock은 lock_until을 59초 미래로 설정하여 작업이 끝나더라도 다른 스케줄러가 중복해서 스케줄러를 실행하지 않도록 막습니다.

따라서 lockAtLeastFor는 “정확히 1분마다 실행”을 보장하기보다, 같은 락 이름의 작업이 최소 59초 동안 다시 실행되지 않도록 보장합니다. 어느 인스턴스가 락을 먼저 얻을지는 실행 타이밍에 따라 달라질 수 있지만, 같은 주기에 두 인스턴스가 모두 실행되는 문제는 막을 수 있습니다.

 

검증

@Component
@RequiredArgsConstructor
public class ShedLockExampleScheduler {

    private final SchedulerLoggerProvider schedulerLoggerProvider;
    private final Environment environment;

    @Scheduled(fixedRate = 60000)
    @SchedulerLock(
        name = "shed-lock-example-scheduler",
        lockAtMostFor = "1m",
        lockAtLeastFor = "59s"
    )
    public void logLockAcquisition() {
        long startTime = System.currentTimeMillis();
        String serverPort = environment.getProperty("server.port");

        schedulerLoggerProvider.getLogger()
            .info("[ShedLock Example] Lock acquired. serverPort: {}, activeProfiles: {}", serverPort,
                String.join(",", environment.getActiveProfiles()));

        long endTime = System.currentTimeMillis();
        schedulerLoggerProvider.getLogger()
            .info("[ShedLock Example] Finished in {} ms", endTime - startTime);
    }
}

intelljj 에서 compound 를 활용하여 2개의 서버를 띄워 실제로 하나의 서버에서만 스케줄러가 돌아가는지 검증해보겠습니다.

 

스크린샷 2026-07-13 오후 5.35.23.png 스크린샷 2026-07-13 오후 5.35.23.png

 

결과를 보면 2개의 Application이 동시에 떳지만, 9002 번포트를 사용한 Application 에서만 스케줄러 lock 이 실행된것을 확인할 수 있습니다.

 

usingDbTime()을 사용하는 MySQL ShedLock은 UTC_TIMESTAMP(3)을 기준으로 락 시간을 계산합니다. 아래 조회는 DB 세션이 UTC인 상태에서 CONVERT_TZ를 사용해, 조회 결과만 KST로 변환한 것입니다.

SELECT
    name,
    CONVERT_TZ(locked_at, '+00:00', '+09:00') AS locked_at_kst,
    CONVERT_TZ(lock_until, '+00:00', '+09:00') AS lock_until_kst,
    locked_by
FROM shedlock;

스크린샷 2026-07-13 오후 5.36.54.png

조회 결과에서 lockAtLeastFor = "59s"에 따라 17:34:50에 락이 시작됐고, 59초 뒤인 17:35:49까지 lock_until이 적용된 것을 확인할 수 있습니다.

 

Reference