배경
이전 글(Transactional Outbox Pattern으로 비동기 작업 신뢰성 보장하기)에서 Transactional Outbox 패턴으로 알림 발송의 신뢰성을 보장했습니다. 사용자가 인증할 때 인증 정보를 저장함과 동시에 Outbox 테이블에 "알림을 발송하겠다"는 작업을 저장합니다. 이후 스케줄러가 주기적으로 Outbox를 폴링하면서 실제 알림을 발송합니다.

문제 상황: 스케줄러 중복 실행
하지만 여러 인스턴스로 운영되는 분산 환경에서 이 스케줄러가 중복으로 실행되는 문제가 발생할 수 있었습니다. 두 인스턴스가 동시에 Outbox를 조회하고 같은 알림을 중복하여 발송하는 상황입니다. 사용자가 짧은 시간 안에 중복 알림을 받으면 사용자 경험이 저해됩니다.

중복 발송을 막으려면 하나의 인스턴스가 작업을 수행하고 있을 때, 다른 인스턴스들은 접근하지 못하게 막으면 됩니다.
여러 프로세스 혹은 스레드가 동시에 같은 자원에 접근하려고 할 때 이를 통제하기 위해 데이터베이스나 운영체제에서 흔히 사용하는 방식이 락(Lock)입니다. 한 프로세스가 "지금 내가 이 자원을 점유하고 있다"는 의미로 락을 획득하면, 다른 프로세스들은 이 락이 해제될 때까지 기다리거나 즉시 포기합니다.
하지만 단일 서버 애플리케이션 레벨의 락(ex. Java의 synchronized, ReentrantLock)은 JVM 프로세스 내의 스레드들을 제어하는데에 사용할 수 있지만 분산 환경에서 작동하지 않습니다. 현재 상황은 서로 다른 서버에서 실행 중인 둘 이상의 JVM 프로세스들입니다.
해결 방법: 분산 락(Distributed Lock)
따라서 여러 서버가 공유할 수 있는 저장소를 대상으로 락을 구현해야 합니다. 이것이 분산 락(Distributed Lock)입니다.
해결 과정에서 검토한 여러 분산 락 방식에 대해 소개하겠습니다.
먼저 기존 인프라를 활용하기 위해 MySQL을 대상으로 분산 락을 구현하는 여러 방법들을 검토했습니다.
대안1: MySQL Named Lock
MySQL의 GET_LOCK() 함수를 사용하는 방식입니다.
SELECT GET_LOCK('TodoNotWrittenNotification', 0);
-- 작업 수행
SELECT RELEASE_LOCK('TodoNotWrittenNotification');
서버 A가 락을 획득하면 서버 B는 락 점유에 실패합니다. timeout 값을 주면 락 획득을 위해 일정 시간 대기할 수도 있습니다.

이로서 중복 발송을 방지할 수 있습니다. 서버 A가 이미 PENDING 상태의 알림들을 모두 SUCCESS로 변경했기 때문에, 서버 B가 나중에 조회해도 중복해서 처리할 알림이 없기 때문입니다.
대안2: MySQL Skip Lock
MySQL의 FOR UPDATE SKIP LOCKED 구문을 사용합니다.
SELECT * FROM schedule
WHERE name = 'TodoNotWrittenNotification'
FOR UPDATE SKIP LOCKED;
동작 방식은 다음과 같습니다. 서버 A가 행 잠금을 획득하면, 서버 B는 해당 행을 건너뛰고 결과를 반환하지 않습니다. 즉, 락을 획득한 인스턴스만 작업을 수행하고 나머지는 즉시 빠져나갑니다.

하지만 두 방식 모두 락의 자동 만료 시간(TTL)을 설정할 수 없다는 문제가 있습니다.
서버 A가 작업 중 장애가 발생하면 서버 A가 획득한 행의 락은 어떻게 될까요? 프로세스가 완전히 종료되는 경우엔 DB 커넥션이 끊어지면서 락이 자동으로 해제됩니다. 하지만 프로세스는 살아있지만 작업이 끝나지 않는 경우(외부 API 호출 지연) 등으로 인해 트랜잭션이 끝나지 않으면 락이 계속 유지됩니다.

그 동안 다른 서버는 스케줄러를 실행하지만 락을 획득하지 못해 Outbox의 PENDING 상태 알림들이 발송되지 못하고 쌓이게 됩니다.
이를 해결하려면 수동으로 락을 해제해야합니다.
위 방식처럼 스케줄링 작업에 대해 skip lock을 걸 수도 있지만, outbox 테이블을 조회할 때 skip lock을 거는 방법도 존재합니다.
하지만 이 방식은 for update skip lock을 호출한 뒤 락을 유지한 채 fcm을 호출해야합니다. 즉, 트랜잭션 내부에서 외부 API를 호출해야하기 때문에 고려하지 않았습니다.
대안3. ShedLock
ShedLock은 외부 저장소를 활용한 잠금 관리 라이브러리입니다. MySQL의 경우 다음과 같은 테이블이 필요합니다.
CREATE TABLE IF NOT EXISTS 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)
);
동작 원리는 조건부 UPDATE를 통한 낙관적 락 패턴입니다.
락을 획득하는 쿼리는 다음과 같습니다.
UPDATE shedlock
SET lock_until = TIMESTAMPADD(MICROSECOND, 15000000, UTC_TIMESTAMP(3))
WHERE name = 'CertifyNotification'
AND lock_until <= UTC_TIMESTAMP(3)
두 서버가 동시에 이 쿼리를 실행하면
1. 서버 A는 lock_until이 만료되었다는 조건을 읽음
2. 서버 A가 lock_until 값을 새로운 시간으로 갱신
3. 서버 B가 조건을 확인할 때는 이미 서버 A가 갱신한 상태
4. 서버 B는 조건을 만족하지 않으므로 업데이트되지 않음
결과적으로 서버 A는 1행 업데이트, 서버 B는 0행 업데이트가 됩니다.
int updated = jdbcTemplate.update(...);
if (updated == 1) {
// 락 획득 - 작업 수행
} else if (updated == 0) {
// 다른 서버가 락 보유 - 건너뜀
}
ShedLock은 이전 대안들의 문제점을 해결할 수 있습니다.
락 TTL을 설정할 수 있다
서버 A가 중단되어도 lock_until 값이 설정된 TTL에 따라 자동으로 만료됩니다. 설정된 TTL만큼만 기다리면 다른 서버가 락을 획득할 수 있습니다.
ShedLock의 한계
ShedLock을 사용할 때 두 가지 시간값을 설정해야 합니다.
1. lockAtMostFor: 락을 최대 얼마나 유지할 것인가
lockAtMostFor을 60초로 설정하면, 서버 장애가 나거나 작업이 오래 걸리더라도 60초가 지나면 락을 해제합니다.
2. lockAtLeastFor: 락을 최소 얼마나 유지할 것인가
10초로 설정하면, 작업이 1초 만에 끝나도 최소 10초는 락을 유지합니다.
이러한 고정 TTL 방식의 문제점은 다음과 같습니다.
문제 1: TTL 초과로 인한 중복 발송
예를 들어 lockAtMostFor를 30초로 설정했는데, fcm 서버 지연으로 인해 실제 알림 발송이 35초가 걸린다면 어떻게 될까요?
작업이 진행 중인데도 락이 만료되어 다른 서버가 같은 작업을 시작합니다. 즉 중복 알림 발송이 상황이 발생할 수 있습니다.
이러한 상황을 방지하기 위해 lockAtMostFor 값을 크게 설정할 수 있습니다.
하지만 크게 설정하면 또 다른 문제가 발생합니다.
문제 2: 서버 장애로 인한 알림 발송 지연
서버 A에 장애가 발생하면 서버 A가 획득한 락은 lockAtMostFor에 설정된 시간 동안 유지됩니다.
작업이 lockAtMostFor보다 오래걸려 중복 발송되는 상황을 방지하기 위해 lockAtMostFor를 늘린다면, 서버 A가 중단된 순간부터 그 시간동안 다른 서버들은 락을 획득할 수 없습니다. Outbox에 쌓인 PENDING 상태의 알림들이 발송되지 못합니다.
결국 딜레마입니다. lockAtMostFor를 크게 설정하면 중복 발송은 방지하지만 스케줄링 지연 시간이 늘어나고, 작게 설정하면 정상 작업 중 TTL 초과로 인한 중복 발송 확률이 커집니다.
해결 방법: 동적 TTL을 지원하는 Redisson RLock
고정 TTL의 한계를 극복하기 위해 Redisson의 RLock을 도입했습니다.
Redisson은 Java에서 Redis에 접근할 수 있게 제공하는 추상화된 라이브러리입니다. Redisson을 사용하려면 먼저 RedissonClient 빈을 등록해야 합니다.
@Slf4j
@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Bean
public RedissonClient redissonClient() {
String redisAddress = "redis://" + redisHost + ":" + redisPort;
Config config = new Config();
config.setLockWatchdogTimeout(30000)
.useSingleServer()
.setAddress(redisAddress);
RedissonClient redissonClient = Redisson.create(config);
log.info("RedissonClient 생성 완료");
return redissonClient;
}
}
여기서 lockWatchdogTimeout 설정에 주목해보겠습니다.
Redisson 공식문서에서 다음과 같이 설명합니다.
RLock object watchdog timeout in milliseconds. This parameter is only used if an RLock object is acquired without the leaseTimeout parameter. The lock expires after lockWatchdogTimeout if the watchdog didn't extend it to the next lockWatchdogTimeout time interval. This prevents infinity-locked locks due to a Redisson client crash, or any other reason why a lock can't be released properly.
이를 정리하면
1. RLock 객체가 leaseTimeout 파라미터 없이 획득되었을 때만 사용됩니다.
lock() 메서드를 파라미터 없이 호출했을 때만 WatchDog이 작동합니다. 만약 lock(30, TimeUnit.SECONDS)처럼 명시적으로 시간을 설정했다면 WatchDog은 작동하지 않습니다. (이후 설명)
2. 락은 watchdog이 다음 lockWatchdogTimeout 시간 간격으로 확장하지 못하면 lockWatchdogTimeout 후에 만료됩니다.
기본값은 30초로 설정되어 있고, 주기적으로 (설정값의 1/3 마다) 락 만료 시간을 lockWatchdogTimeout으로 초기화합니다.
3. Redisson 클라이언트 다운이나 락이 제대로 해제되지 못하는 다른 이유로 인한 무한 잠금 상태를 방지합니다.
서버가 중단되면 WatchDog도 함께 중단되기 때문에 TTL이 더 이상 연장되지 않고, 30초 후 자동으로 만료되어 다른 서버가 락을 획득할 수 있습니다.
즉 작업이 오래걸리는 경우에는 자동 갱신되어 중복 실행을 방지하고, 서버 장애 상황에서는 갱신되지 않아 무한 잠금을 방지할 수 있습니다.
스케줄러 구현
Redisson RLock을 활용한 스케줄러의 구현은 다음과 같습니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class NotificationBatchProcessor {
private static final String LOCK_KEY = "NotificationBatch";
private static final long WAIT_TIME = 1L;
private static final int MAX_RETRY_COUNT = 3;
private final NotificationOutboxRepository outboxRepository;
private final NotificationService notificationService;
private final RedissonClient redissonClient;
@Scheduled(fixedDelay = 20000)
public void sendPendingNotifications() {
final RLock lock = redissonClient.getLock(LOCK_KEY);
try {
final boolean isLocked = lock.tryLock(WAIT_TIME, TimeUnit.SECONDS);
if (!isLocked) {
log.debug("[알림 배치 스킵] 다른 인스턴스에서 실행 중");
return;
}
try {
processPendingNotifications();
} catch (Exception e) {
log.error("[알림 배치 실패] 알림 발송 중 오류 발생", e);
}
} catch (InterruptedException e) {
log.error("[알림 배치 인터럽트] 락 획득 중 인터럽트 발생", e);
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
락 획득
먼저 락 획득을 위해서 tryLock 메서드를 호출합니다.
Redisson의 RLock은 여러 버전의 tryLock 메서드를 제공합니다.
boolean tryLock();
boolean tryLock(long waitTime, TimeUnit unit);
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit);
저는 tryLock(1, TimeUnit.SECONDS) 을 사용하여 leaseTime을 제외하고 waitTime만 지정했습니다.
앞서 공식문서에서 보았듯이 leaseTimeout을 설정하지 않아야 동적 TTL이 작동하기 때문입니다.
결과적으로 한 인스턴스만 락 획득에 성공하고 나머지는 false를 반환합니다. true를 반환한 인스턴스의 WatchDog이 시작되고 작업 진행 중 주기적으로 TTL이 갱신됩니다.
갱신 주기가 어떻게 될지 궁금해 Redisson 공식문서를 확인해보았지만 문서에서 찾지 못해서 직접 코드를 살펴보며 알 수 있었습니다.
Redisson Watchdog 내부 구현
Redisson의 코드를 직접 살펴보면 다음과 같은 흐름입니다.
1. 락 획득
@Override
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {
return tryLock(waitTime, -1, unit);
}
leaseTime을 생략하면 내부적으로 -1로 호출합니다.
2. lockWatchdogTimeout을 사용할지 판단
private RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
if (leaseTime > 0) {
// leaseTime이 명시된 경우
// 고정 TTL 사용
ttlRemainingFuture = tryLockInnerAsync(...);
} else {
// leaseTime이 생략된 경우 (-1)
// internalLockLeaseTime 사용, WatchDog(동적 TTL) 작동
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, ...);
}
CompletionStage<Long> f = ttlRemainingFuture.thenApply(ttlRemaining -> {
if (ttlRemaining == null) { // 락 획득에 성공
if (leaseTime > 0) {
// 고정 TTL
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
// 동적 TTL
scheduleExpirationRenewal(threadId);
}
}
return ttlRemaining;
});
}
leaseTime이 -1인 경우 internalLockLeaseTime을 사용하는데, internalLockLeaseTime = lockWatchdogTimeout 임을 아래 RedissonLock 생성자에서 확인할 수 있습니다.
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
...
this.internalLockLeaseTime = getServiceManager().getCfg().getLockWatchdogTimeout();
...
}
이어서 tryLockInnerAsync가 끝난 후 실행되는 thenApply 콜백에서 락 획득에 성공했음을 확인한 뒤 leaseTime을 -1로 줬다면, scheduleExpirationRenewal를 실행합니다.
3. WatchDog 시작
private void renewExpiration() {
Timeout task = getServiceManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
// TTL 갱신 요청
CompletionStage<Boolean> future = renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
if (res) {
// 갱신 성공 시 다시 10초 후에 실행하도록 재귀 호출
renewExpiration();
} else {
cancelExpirationRenewal(null, null);
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); // 10초 주기
}
scheduleExpirationRenewal 내부에서 renewExpiration를 호출합니다.
newTimeout은 internalLockLeaseTime(=lockWatchdogTimeout) / 3 마다 실행됨을 확인할 수 있습니다.
다시 스케줄러로 돌아가보겠습니다.
if (!isLocked) {
log.debug("[알림 배치 스킵] 다른 인스턴스에서 실행 중");
return;
}
락을 획득하지 못한 인스턴스는 스킵됩니다.
만약 락을 획득했다면
try {
processPendingNotifications();
} catch (Exception e) {
log.error("[알림 배치 실패] 알림 발송 중 오류 발생", e);
}
알림 발송을 진행합니다. 이 동안 TTL 갱신이 백그라운드에서 이뤄집니다.
작업이 완료되면 락을 해제합니다.
finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
이로서 ShedLock을 사용할 때 딜레마가 제거되었습니다.
만약 FCM 서버 지연으로 알림 발송이 25초 소요된다고 가정하더라도, WatchDog이 주기적으로 (10초마다) TTL을 갱신합니다. 계속 락을 점유하므로 다른 인스턴스의 스케줄링으로 인한 중복 발송은 일어나지 않습니다.
또한 서버 A에 장애가 발생한 상황에서는 WatchDog도 중지되기 때문에 TTL은 갱신되지 않습니다. 30초가 지나면 자동으로 만료되고 서버 B가 락을 획득하여 알림을 발송할 수 있습니다.
동적 TTL로 두 문제를 동시에 해결할 수 있었습니다.
마무리
지금까지 분산 환경에서 스케줄러의 중복 실행을 방지하기 위해 세 가지 단계를 거쳤습니다.
첫 번째는 ShedLock + MySQL입니다. 기존 인프라를 활용할 수 있고, 낙관적 락 방식으로 성능도 좋았습니다. 스케줄링 주기도 지키고 Named Lock의 대기 문제도 해결했습니다. 하지만 고정 TTL이라는 한계가 있었습니다.
lockAtMostFor를 크게 설정하면 중복 발송은 방지하지만 서버 장애 시 다음 스케줄링이 지연됩니다. 작게 설정하면 장애 복구는 빠르지만 FCM 지연 같은 상황에서 중복 발송이 발생할 수 있습니다. 둘 중 하나만 선택해야 하는 딜레마였습니다.
따라서 Redisson RLock으로 전환했습니다. WatchDog이 자동으로 TTL을 관리하므로 작업이 얼마나 오래 걸리든 중복이 발생하지 않습니다. 동시에 서버 장애 시에는 고정된 시간 후 자동으로 복구됩니다.
읽어주셔서 감사합니다
참고 자료

'DND > project' 카테고리의 다른 글
| Transactional Outbox Pattern으로 비동기 작업 신뢰성 보장하기 (0) | 2025.11.04 |
|---|