스케줄러로 API를 요청해 데이터를 받아오고 있었는데, 다중 서버 환경에서 스케줄러가 각 노드마다 개별 동작하여 동일한 API 요청이 서버 대수만큼 중복 발생하는 문제가 있었다. 이로 인해 다음과 같은 현상이 발생했다.
- 거의 동시에 응답이 왔을 때 redis에 저장되어 있는 응답의 개수가 덮어씌워져(Lost Update) Loki와의 데이터 정합성 문제가 발생
- API 요청 성공유무를 보여주는 화면 UI에 중복된 상태값이 표시되고, 서버 개수에 따라 관리자에게 보여지는 API 요청 개수가 달라지는 등 부정적인 사용자 경험을 유발
- 외부 서버(PHIS)와 내부 리소스(Redis, Loki)의 불필요한 부하를 초래
이러한 멀티 노드 간 중복 작업을 차단하여 파생되는 문제를 방지하기 위해 스케줄러 분산 락 매커니즘(ShedLock)을 도입했다.
ShedLock 깃허브 참고했다.
https://github.com/lukas-krecan/ShedLock/blob/master/README.md#redis-using-spring-redisconnectionfactory
1. ShedLock이란
ShedLock은 스케줄링된 작업이 동일한 시간에 최대 한 번만 실행되도록 보장한다. 그래서, 특정 노드에서 작업이 실행중이면 락(lock)을 획득하여 다른 노드(또는 스레드)에서 동일한 작업이 실행되는 것을 방지한다. 즉, 실행을 락 때문에 기다리는 것이 아니라 그냥 실행을 건너뛴다. 작업의 실행 주체는 여전히 Spring의 @Scheduled이고, ShedLock은 그저 그 순간에 다른 노드가 하고 있는지 체크를 해주는 도구이다. ShedLock은 스케줄링된 작업들이 병렬로 실행될 준비가 되지 않은(중복 실행되면 안 되는) 상황에서 사용하도록 설계되었다.(나의 상황과 일치한다)
여러 서버 간의 상태를 공유하기 위해 Mongo, RDB(JDBC), Redis, Zookeeper 같은 외부 저장소를 사용하는데, 외부 저장소를 이용해 누가 작업을 실행하고 있음을 표시해 다른 노드가 작업하지 못하게 막는다. 락은 시간 기반으로 작동하며 잠금 시작과 끝 시각을 관리하기에 각 노드의 시계는 동기화 되어 있어야하고, ShedLock은 각 노드의 시계가 동기화되어 있다고 가정한다.
ShedLock은 3가지 핵심 요소로 구성된다.
- Core (핵심 잠금 로직) : 락이 현재 유효한지, 언제 락을 해제해야 하는지와 같은 비즈니스 로직과 규칙 담당
- Integration (애플리케이션 통합) : Spring AOP, Micronaut AOP 또는 수동 코드를 사용하여 애플리케이션과 통합하는 부분
- Lock Provider (락 제공자/저장소) : SQL 데이터베이스, Mongo, Redis 등과 같은 외부 프로세스를 사용하여 락을 제공하는 부분
ShedLock의 두가지 모드는 다음과 같다.
- PROXY_METHOD (기본값): @Scheduled 메서드 자체를 감싸서 락을 건다.
- PROXY_SCHEDULER: Spring의 스케줄 실행기(TaskScheduler) 자체를 감싸서 관리한다.
PROXY_SCHEDULER 모드는 deprecated되어 PROXY_METHOD에 대해 자세히 알아보자. Scheduled Method Proxy는 4.0.0 버전부터 기본값으로 채택된 방식이며, 특징은 다음과 같다.
- 다른 프레임워크와 함께 사용할 때 충돌이 적고 잘 어울린다. 스케줄러에 의해서가 아니라, 코드에서 해당 메서드를 직접 호출해도 락이 적용된다. 즉, 어떤 경로로 실행하든 중복 실행을 막아준다.
- 만약 다른 서버가 이미 락을 잡고 있어서 실행이 안 될 경우 리턴 타입이 일반 객체면 null을 반환한다.
- final 메서드나 private, protected 같은 비공개 메서드에는 락이 걸리지 않는다. 락을 걸고 싶다면 메서드를 반드시 public이면서 non-final로 만들거나, 아니면 아래에서 설명하는 TaskScheduler proxy 방식을 사용해야한다.
PROXY_METHOD의 동작 흐름이다.

- Spring scheduling → TaskScheduler: Spring 스케줄링 엔진이 정해진 시간이 되면 작업을 실행하기 위해 Runnable 객체를 TaskScheduler에게 전달한다.
- TaskScheduler → Runnable: TaskScheduler는 받은 Runnable의 run() 메서드를 호출하여 작업을 시작한다.
- Runnable → ScheduledMethod AOP proxy: 여기서 ShedLock이 개입한다. run()이 실행되면서 실제 메서드를 호출하려고 할 때, AOP 프록시가 이 호출을 가로챈다(execute).
- AOP proxy → ScheduledMethod: 프록시 내부에서 executeWithLock 로직이 실행된다.
SchedulerLockInterceptor 클래스를 보면 아래와 같은 interceptor 메서드가 있다.
내부에서 yml등에 설정된 기본값 (defaultLockAtMostFor, defaultLockAtLeastFor)을 읽어와서 저장해두고, 락 설정을 확인 한 뒤, lockingTaskExecutor.executeWithLock을 호출하는 것을 확인할 수 있다. 파라미터에 들어가는 context::proceed 이 부분이 스케줄러 작업 대상인 Runnable 작업이라는 것을 알 수 있다.
@Override
public @Nullable Object intercept(MethodInvocationContext<Object, Object> context) {
Class<?> returnType = context.getReturnType().getType();
if (!void.class.equals(returnType) && !Void.class.equals(returnType)) {
throw new LockingNotSupportedException();
}
Optional<LockConfiguration> lockConfiguration =
micronautLockConfigurationExtractor.getLockConfiguration(context.getExecutableMethod());
if (lockConfiguration.isPresent()) {
lockingTaskExecutor.executeWithLock((Runnable) context::proceed, lockConfiguration.get());
return null;
} else {
return context.proceed();
}
}
@EnableSchedulerLock 어노테이션을 통해 ShedLock의 핵심 컴포넌트인 설정 추출기와 인터셉터가 스프링 컨텍스트의 빈으로 등록된다. 스프링은 빈 생성 과정에서 @SchedulerLock 어노테이션이 붙은 메서드를 전수 조사하여 해당 클래스를 AOP 프록시 객체로 감싸 실행을 가로챌 준비를 마친다. 이후 스케줄러에 의해 메서드가 호출되면 프록시 내부의 인터셉터가 실행 권한을 즉시 가로채어 동작한다. 인터셉터는 개별 메서드의 어노테이션 설정값과 @EnableSchedulerLock에 정의된 기본값을 조합하여 최종적인 락 구성 정보를 생성한다. 이렇게 만들어진 정보를 바탕으로 Redis나 DB와 연결된 LockProvider를 호출하여 저장소에 락 점유를 시도한다. 락 획득에 성공한 경우에만 실제 비즈니스 로직을 실행하며, 획득에 실패하면 작업을 수행하지 않고 즉시 종료한다. 마지막으로 메서드 실행이 끝나면 작업의 성공 여부와 상관없이 finally 블록을 통해 저장소의 락을 삭제하거나 설정된 최소 유지 시간에 맞춰 상태를 업데이트하며 전체 프로세스를 마무리한다.
2. Lock과 Unlock 과정 - DB(JdbcTemplate)
1. 새 락 레코드를 INSERT하려고 시도한다. (PK가 고유이름이므로 이미 있으면 실패함)
INSERT INTO shedlock (
name, -- :name (작업의 고유 이름)
lock_until, -- :lockUntil (현재 시간 + lockAtMostFor)
locked_at, -- :now (현재 잠금 시각)
locked_by -- :lockedBy (서버 호스트명)
)
VALUES (
'scheduledTaskName',
'2024-05-20 10:10:00.000',
'2024-05-20 10:00:00.000',
'my-server-01'
);
2. INSERT 성공(1행 삽입)하면 락을 획득한 것이다.
3. INSERT 실패(중복 키)하면 UPDATE를 시도한다.
UPDATE shedlock
SET
lock_until = '2024-05-20 10:10:00.000', -- :lockUntil (새로운 만료 시각)
locked_at = '2024-05-20 10:00:00.000', -- :now (현재 잠금 시각)
locked_by = 'my-server-01' -- :lockedBy (현재 서버명)
WHERE
name = 'scheduledTaskName' -- :name (작업 이름)
AND lock_until <= '2024-05-20 10:00:00.000'; -- :now (현재 시간보다 만료 시각이 작거나 같아야 함)
4. UDATE가 성공하면(1 updated row), 락을 획득한 것이다.
5. UPDATE가 실패했다면(0 updated rows), 다른 노드가 락을 가지고 있는 것이라 패스한다.
6. 락을 잡았던 스케줄러가 작업을 끝내면 UNLOCK한다.
UPDATE shedlock
SET
lock_until = '2026-01-19 17:05:00.000' -- :unlockTime (현재 시간 OR locked_at + lockAtLeastFor)
WHERE
name = 'scheduledTaskName' -- :name (작업 이름)
AND locked_by = 'my-server-01'; -- :lockedBy (내가 잡았던 락인지 확인)
7. 다음 주기가 되면, 위의 과정을 반복하며 사용했던 row를 재사용한다.
전체 과정은 StorageBasedLockProvider을 통해 알 수 있다.
@Override
public Optional<SimpleLock> lock(LockConfiguration lockConfiguration) {
boolean lockObtained = doLock(lockConfiguration);
if (lockObtained) {
return Optional.of(new StorageLock(lockConfiguration, storageAccessor));
} else {
return Optional.empty();
}
}
/**
* Sets lockUntil according to LockConfiguration if current lockUntil <= now
*/
protected boolean doLock(LockConfiguration lockConfiguration) {
String name = lockConfiguration.getName();
boolean tryToCreateLockRecord = !lockRecordRegistry.lockRecordRecentlyCreated(name);
if (tryToCreateLockRecord) {
// create record in case it does not exist yet
if (storageAccessor.insertRecord(lockConfiguration)) {
lockRecordRegistry.addLockRecord(name);
// we were able to create the record, we have the lock
return true;
}
// we were not able to create the record, it already exists, let's put it to the
// cache so we
// do not try again
lockRecordRegistry.addLockRecord(name);
}
// let's try to update the record, if successful, we have the lock
try {
return storageAccessor.updateRecord(lockConfiguration);
} catch (Exception e) {
// There are some users that start the app before they have the DB ready.
// If they use JDBC, insertRecord returns false, the record is stored in the
// recordRegistry
// and the insert is not attempted again. We are assuming that the DB still does
// not exist
// when update is attempted. Unlike insert, update throws the exception, and we
// clear the
// cache here.
if (tryToCreateLockRecord) {
lockRecordRegistry.removeLockRecord(name);
}
throw e;
}
}
출처: https://github.com/lukas-krecan/ShedLock/blob/master/providers/jdbc/shedlock-provider-jdbc-template/src/main/java/net/javacrumbs/shedlock/provider/jdbctemplate/JdbcTemplateLockProvider.java
https://github.com/lukas-krecan/ShedLock/blob/master/providers/sql/shedlock-sql-support/src/main/java/net/javacrumbs/shedlock/provider/sql/SqlStatementsSource.java
3. Lock과 Unlock 과정 - Redis
RDBMS가 아닌 Redis의 경우에는 락 내용을 기록하는 데에 테이블을 사용하지 않는다.
key에 작업 이름을 설정하고, value에 locked_by를 넣고, lockAtMostFor를 TTL로 설정한다.
1. 락 획득 시도 (Lock: setIfAbsent)
InternalRedisLockProvider
@Override
public Optional<SimpleLock> lock(LockConfiguration lockConfiguration) {
long expireTime = getMsUntil(lockConfiguration.getLockAtMostUntil());
String key = buildKey(lockConfiguration.getName(), keyPrefix, this.environment);
String uniqueLockValue = buildValue();
if (createLock(key, uniqueLockValue, expireTime)) {
return Optional.of(new RedisLock(key, uniqueLockValue, this, lockConfiguration));
}
return Optional.empty();
}
private boolean createLock(String key, String value, long expirationMs) {
return redisLockTemplate.setIfAbsent(key, value, expirationMs);
}
-----------------------------------------------------------------------------------
RedisLockProvider
@Override
public boolean setIfAbsent(String key, String value, long expirationMs) {
return set(key, value, expirationMs, SET_IF_ABSENT);
}
- Redis에 해당 키(keyPrefix + environment + taskName)가 있는지 확인한다
- 키가 없다면(락 안걸려있음): 값을 저장하고 동시에 expirationMs(lockAtMostFor)만큼 TTL(유효시간)을 설정하고, Redis Lock을 반환한다. (성공)
- 키가 있다면(이미 락 걸려있음): 아무 작업도 하지 않고 false를 반환하고, 결국 Optional.empty를 반환하게 된다. (실패)
2. 락 해제
@Override
public void doUnlock() {
long keepLockFor = getMsUntil(lockConfiguration.getLockAtLeastUntil());
// lock at least until is in the past
if (keepLockFor <= 0) {
try {
lockProvider.deleteLock(key, value);
} catch (Exception e) {
throw new LockException("Can not remove node", e);
}
} else {
lockProvider.setKeyExpiration(this, keepLockFor);
}
}
작업이 종료되는 시점에 unlock()이 호출되면 ShedLock은 다음 두 가지 상황을 판단한다.
- 상황 A: lockAtLeastFor가 없거나 이미 지났을 때: 이때는 그냥 delete를 해서 락을 즉시 없애버린다. (누구나 바로 다시 잡을 수 있게) -> delete/eval
- 상황 B: lockAtLeastFor가 아직 남았을 때: 락을 지우면 안 된다. 하지만 현재 락의 만료 시간(lockAtMostFor 기준)은 너무 길 수 있으니, 이를 lockAtLeastFor만큼으로 딱 줄여서 남겨둔다. -> setIfPresent
2-1. 락 업데이트 (Update: setIfPresent) - 작업 종료 후 lock_until을 locked_at + lockAtLeastFor로 갱신하거나 작업이 길어질 때 락이 중간에 풀리지 않도록 시간을 늘리는 기능
@Override
public boolean setIfPresent(String key, String value, long expirationMs) {
return set(key, value, expirationMs, SET_IF_PRESENT);
}
- Redis에 해당 키가 이미 존재할 때만 값을 덮어쓰고 만료 시간을 새로 갱신한다
- 만약 키가 사라졌다면(만료되었다면) 아무것도 하지 않는다
2-2. 락 제거 (Unlock: delete 또는 eval) - 작업 종료 후 lock_until을 현재 시각으로 갱신
private void deleteLock(String key, String value) {
if (safeUpdate) {
redisLockTemplate.eval(delLuaScript, key, value);
} else {
redisLockTemplate.delete(key);
}
}
@Override
public @Nullable Object eval(String script, String key, String... values) {
return template.execute(new DefaultRedisScript<>(script, Integer.class), List.of(key), (Object[]) values);
}
@Override
public void delete(String key) {
template.delete(key);
}
- eval: Redis 서버에서 Lua 스크립트를 실행하여 key에 저장된 값이 value(내 서버명)랑 똑같을 때만 이 키를 지우도록 한다. 이때, 원자적(Atomic)으로 실행되므로, 절대로 남의 락을 건드리지 않는다.
- delete: Redis의 DEL 명령어를 실행하여 삭제한다. 아주 빠르고 단순하지만, "누가 잡은 락인지" 묻지도 따지지도 않고 키를 지워버린다. 내 작업이 너무 길어져서 이미 락이 만료되었고 다른 서버가 락을 새로 잡았을 때, 내가 종료하면서 남의 락을 지워버릴 수 있는 위험이 아주 미세하게 존재한다.
출처: https://github.com/lukas-krecan/ShedLock/blob/master/providers/redis/shedlock-provider-redis-spring/src/main/java/net/javacrumbs/shedlock/provider/redis/spring/RedisLockProvider.java
https://github.com/lukas-krecan/ShedLock/blob/7cafe4a88da56c6a864ced30d87fc00126dafefd/providers/redis/shedlock-support-redis/src/main/java/net/javacrumbs/shedlock/provider/redis/support/InternalRedisLockProvider.java#L30
4. 사용해보기
공식 문서에 ShedLock을 사용하기 위한 순서가 나와있다. 이를 따라서 진행해보자
1. shedlock 프로젝트 import하기 (Redis)
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:7.5.0'
의존성 추가 후에는, @Configuration 클래스에 @EnableSchedulerLock을 붙여 설정을 활성화한다. 이때 defaultLockAtMostFor(기본 최대 잠금 시간)를 설정해야 한다.
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "1m")
public class SchedulingConfig {
2. 실행할 메서드에 @SchedulerLock(name = "고유이름")을 붙여 lock을 적용
public abstract class PhisSchedulingService {
@Scheduled(cron = "${phis.schedule.ward:0 * * * * *}")
@SchedulerLock(
name = "phis-room-bed",
lockAtMostFor = "${my.lock.time}", // _분 주기 직전까지만 잠금
lockAtLeastFor = "PT50S" // 최소 50초는 잠금 (중복 실행 방지)
)
public void executeWardStatusTask() {
wardStatusTask();
}
이때, 고유 이름이 같은 작업은 동시에 단 하나만 실행된다. 스케줄러가 아닌 코드로 직접 메서드를 호출해도 락이 적용된다. 또한, 위 예시처럼 lockAtMostFor값을 설정 파일(application.yml)에서 동적으로 가져와서 쓸 수 있다
ShedLock 설정의 핵심은 아래 두가지다.
- lockAtMostFor (최대 잠금 시간): 만약 작업을 수행하던 서버가 갑자기 전원이 꺼지거나 에러로 멈췄을 때(ex. JVM Crash), 락이 영원히 남아서 다음 실행을 방해하지 않도록 설정한 시간이 지나면 강제로 락을 해제한다. 개별 메서드의 @SchedulerLock에서 lockAtMostFor를 따로 적지 않으면, 설정 클래스의 @EnableSchedulerLock에 적어둔 기본값이 적용된다.
- lockAtLeastFor (최소 잠금 시간): 작업이 1초 만에 끝나더라도, 최소한 이 시간 동안은 락을 유지해 다른 노드가 실행하지 않도록 방지한다. "작업이 매우 빨리 끝나는 경우"와 "노드(서버) 간의 시계 차이" 때문에 발생하는 중복 실행을 막는 것이 주된 목적이다. 만일 lockAtLeastFor 설정이 없다면 작업이 정상 종료 시, 즉시 락이 해제된다.
3. DB(JdbcTemplate), Redis 등 락 정보를 저장할 곳(Lock Provider)을 설정
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "1m")
public class SchedulingConfig {
@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
return new RedisLockProvider(connectionFactory, "shedlock");
}
위와 같이 lockProvider를 프로젝트 상황에 맞게 선택해서 작성해주면 된다. Mysql을 사용할 수도 있었지만, 인스턴스 하나에서 1분에 2번 하루 약 2만 9천번의 적지 않은 꾸준한 쓰기 요청이 있었기에 Redis를 선택하였다.
redis의 경우에는 락을 걸때 삽입되고, ttl이 지나면 삭제되는 구조기에 테이블을 따로 생성하지 않아도 된다. 하지만, DBMS의 경우 테이블이 필요해, 락 이름, 만료 시각, 잠금 시각, 잠금 자원을 가진 서버 호스트명이 담긴 테이블을 생성해줘야 한다. 해당 필드를 사용한 락을 얻는 로직은 위에 설명했던 바와 같다.
# MySQL, MariaDB
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));
# Postgres
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));
실행시켜보면..!

이렇게 스케줄러 시작과 끝에 로그가 찍히는데, 이는 DefaultLockingTaskExecutor 클래스의 executeWithLock이 실행된 결과다.
return task.call() 로 실제 스케줄링 된 작업이 실행되고, 메서드가 끝나기 전 finally문으로 들어와 LockAssert.endLock()로 ThreadLocal 정리하고, activeLock.unlock()으로 Redis DEL 혹은 SET_IF_PRESENT가 실행됨을 알 수 있다.
@Override
public <T> TaskResult<T> executeWithLock(TaskWithResult<T> task, LockConfiguration lockConfig) throws Throwable {
String lockName = lockConfig.getName();
if (alreadyLockedBy(lockName)) {
logger.debug("Already locked '{}'", lockName);
return TaskResult.result(task.call());
}
Optional<SimpleLock> lock = lockProvider.lock(lockConfig);
if (lock.isPresent()) {
try {
LockAssert.startLock(lockName);
LockExtender.startLock(lock.get());
logger.debug(
"Locked '{}', lock will be held at most until {}", lockName, lockConfig.getLockAtMostUntil());
return TaskResult.result(task.call());
} finally {
LockAssert.endLock();
SimpleLock activeLock = LockExtender.endLock();
if (activeLock != null) {
activeLock.unlock();
} else {
// This should never happen, but I do not know any better way to handle the null
// case.
logger.warn("No active lock, please report this as a bug.");
lock.get().unlock();
}
if (logger.isDebugEnabled()) {
Instant lockAtLeastUntil = lockConfig.getLockAtLeastUntil();
Instant now = ClockProvider.now();
if (lockAtLeastUntil.isAfter(now)) {
logger.debug("Task finished, lock '{}' will be released at {}", lockName, lockAtLeastUntil);
} else {
logger.debug("Task finished, lock '{}' released", lockName);
}
}
}
} else {
logger.debug("Not executing '{}'. It's locked.", lockName);
return TaskResult.notExecuted();
}
}'Backend > Spring Boot' 카테고리의 다른 글
| SSE 동작 원리 네트워크 단까지 파해쳐보자! (0) | 2025.09.02 |
|---|---|
| [Spring Data Jpa] 3가지 방법으로 N+1 문제 해결해보기 (Fetch Join, Batch Fetching, EntityGraph) (1) | 2025.01.16 |
| FCM 프로젝트 만들기 (Firebase 2024 최신버전) (0) | 2024.11.26 |
| [Spring/Java] 웹소켓 이용하여 간단하게 빠칭코(게임) 구현하기 (0) | 2024.11.14 |
| [Spring/Java] 민감한 개인정보 AES 알고리즘으로 암호화/복호화하기 (1) | 2024.11.14 |