회사에서 진행하는 프로젝트에서 다중 인스턴스 환경의 서버의 스케줄러가 하나의 인스턴스마다 작업을 수행하는 문제가 발생했다.
이미 단일 스케줄러 동작을 보장해주는 Shedlock이라는 라이브러리가 있지만 프로젝트에서 사용하고 있는 라이브러리와 호완성 테스트 및 검증을 하기 어려운 상황으로 직접 구현해봤다.
구글과 GPT의 검색을 통해 Shedlock의 동작 방식을 확인해봤다.
개인적으로 느낀점은 DB에 해당 lock의 만료시간, 이름과 같은 고유한 데이터를 통해 lock 상태인 경우 스케줄러가 코드를 실행하지 않고 통과하는 것으로 생각되었다.
DB에 고유한 값을 저장하되 lock 처리를 위해 싱글 스레드로 동작하는 Redis를 활용해 구현했으며 이는 RDB로 처리하는것에 비해 읽기 쓰기 비용이 적게 들어간다고 생각했다.
구현 방식은 스케줄러 진입시 전달받은 KEY(Enum)로 setIfAbsent 진행후 반환받은 상태(true | false)에 따라 스케줄러 로직을 수행할지 판단했다.
Redis의 setIfAbsent은 Redis에서 키가 존재하지 않을 때만 값을 설정하는 명령어이며 SET 명령에 NX 옵션을 추가해 동작한다.
redisTemplate.opsForValue().setIfAbsent(key, value);
애플리케이션 레벨에서 GET을 통해 키가 존재하는지 확인하고, SET을 통해서 값을 세팅해주는 과정을 레디스 내에서 만든 명령어라고 생각하면 된다. 만약키가 이미 존재해서 값을 세팅하지 못했다면 false를,키가 존재하지 않아서 값을 세팅했다면 true를 반환한다.
동시성 테스트를 위해 executorService를 이용했다.
@Test
public void lockTest() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch (2);
// 1
executorService.execute( () -> {
// lock 획득
boolean lockStatus = redisTemplate.opsForValue().setIfAbsent(key, value);
log.info("1. lockStatus : {}", lockStatus);
if(lockStatus) {
log.info("1-1. Time : {}", LocalDateTime.now().toEpochSecond(ZoneOffset.UTC));
try {
// 스케줄러 실행로직
} catch (Exception e) {
} finally {
// lock 반납
redisTemplate.delete(key);
}
}
latch.countDown();
});
// 2
executorService.execute( () -> {
// lock 획득
boolean lockStatus = redisTemplate.opsForValue().setIfAbsent(key, value);
log.info("1. lockStatus : {}", lockStatus);
if(lockStatus) {
log.info("1-1. Time : {}", LocalDateTime.now().toEpochSecond(ZoneOffset.UTC));
try {
// 스케줄러 실행로직
} catch (Exception e) {
} finally {
// lock 반납
redisTemplate.delete(key);
}
}
latch.countDown();
});
latch.await();
}
완성된 코드를 스케줄러에 적용하던중 여러 스케줄러에서 동시에 사용했으면 하는 생각이 들었다.
현재의 상태로는 여러 스케줄러에서 해당 lock 기능을 이용할시 스케줄러마다 같은 코드를 반복해 가독성이 떨어지며 코드라인이 증가하는 문제가 있음으로 Spring AOP를 이용해 어노테이션으로 만들었다.
@Around를 이용해 메소드가 실행하기 전 lock을 수행하고 이미 lock 상태일시 메소드를 탈출하도록 했으며 Enum으로 ScheduleLockKey을 받아 스케줄러별 lock 기능을 수행하도록 구현했다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ScheduleLock {
ScheduleLockKey value();
}
lock을 성공했을 경우만 roceedingJoinPoint.proceed()을 통해 메서드를 실행시킨다.
@Around("@annotation(framework.scheduleLock.annotation.ScheduleLock)")
public Object scheduleLock(ProceedingJoinPoint proceedingJoinPoint) {
MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature();
ScheduleLock lockKey = methodSignature.getMethod().getAnnotation(ScheduleLock.class);
boolean lockStatus = scheduleLockService.lock(ConstantClass.SCHEDULE_LOCK, lockKey.value());
if (lockStatus) {
try {
return proceedingJoinPoint.proceed();
} catch (Throwable e) {
log.error("ScheduleLock Fail", e);
} finally {
scheduleLockService.unlock(ConstantClass.SCHEDULE_LOCK, lockKey.value());
}
}
return null;
}
구현한 코드는 shedlock이 제공하는 기능과 크게 다르지 않다고 생각한다.
이번 문제를 해결하며 Redis의 setIfAbsent를 알게 되었으며 AOP를 이용한 코드 리팩토링을 진행해 코드의 재사용성 및 가독성에 대해 생각해보게 되었다.