Redis Spin Lock에서 Redisson 기반 분산락으로 개선하기
이번 포스트에서는 Redis 기반의 Spin Lock(SETNX + while-loop) 구조를 Redisson 기반의 Reentrant Distributed Lock으로 전환했던 경험을 공유하고자 합니다.
기존 Spin Lock 구조
public <T> T executeInSpinLock(String lockKey, Supplier<T> supplier) {
while (Boolean.FALSE.equals(acquireLock(lockKey))) {
log.info("sleep to acquire distributed lock (lock key = {})", lockKey);
ThreadUtil.sleep(100, TimeUnit.MILLISECONDS);
}
try {
return supplier.get();
} catch (Exception e) {
errorNotifier.sendErrorMessage("error occurs in executing task in lock", e);
throw e;
} finally {
eventPublisher.publishEvent(RedisUnlockEvent.of(lockKey));
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION, fallbackExecution = true)
public void releaseLock(RedisUnlockEvent event) {
if (lockContextHolder.isEmpty()) {
return;
}
retry(() -> {
final Boolean releaseDistributedLock = redisTemplate.delete(event.getLockKey());
if (Boolean.TRUE.equals(releaseDistributedLock)) {
lockContextHolder.clear();
}
return null;
});
}
private Boolean acquireLock(String lockKey) {
if (lockContextHolder.hasAlreadyAcquireDistributedLock(lockKey)) {
log.info("accessing the critical section within the same thread. (lock key = {})", lockKey);
return true;
}
return retry(() -> {
final Boolean acquireDistributedLock = redisTemplate.opsForValue().setIfAbsent(lockKey, StringUtils.EMPTY, Duration.ofMinutes(1));
if (Boolean.TRUE.equals(acquireDistributedLock)) {
lockContextHolder.setValue(lockKey);
}
return acquireDistributedLock;
});
}
private <T> T retry(Supplier<T> supplier) {
try {
return retryTemplate.execute(context -> supplier.get());
} catch (Exception e) {
errorNotifier.sendErrorMessage("fail in retry", e);
throw e;
}
}
기존 코드는 다음과 같은 방식으로 분산락을 구현하고 있었습니다.
- Redis
SETNX(setIfAbsent) → 락 획득 - 락을 얻을 때까지
while(false)로 반복 (100ms sleep) - 락을 획득하면 비즈니스 로직 수행
- 트랜잭션이 끝난 뒤(
AFTER_COMPLETION) 이벤트 기반으로 락 해제
여기서 문제가 되는 핵심 구현은 아래 부분입니다.
while (Boolean.FALSE.equals(acquireLock(lockKey))) {
log.info("sleep to acquire distributed lock (lock key = {})", lockKey);
ThreadUtil.sleep(100, TimeUnit.MILLISECONDS);
}
락을 획득하는 과정에서 내부적으로 Redis에 다음과 같은 커맨드를 계속 날립니다.
SET key value NX EX 60
락이 이미 있다면 → 실패 → sleep → 다시 SETNX
이 과정이 락을 얻을 때까지 무한 반복됩니다.
기존 Spin Lock 구조의 문제점
- Redis 부하 증가 (Polling 기반 구조)
스레드가 잠들었다 깨며 매 100ms마다SETNX를 호출함
특히 락이 오래 유지되면, Redis는 불필요한 트래픽을 계속 받음 - Spin Lock은 CPU 효율이 좋지 않음
100ms sleep을 해도 결국 폴링 기반이라, 불필요하게 스레드를 깨우고 put/get을 반복하게 됨 - 재진입(
Reentrant) 락이 아님 →ThreadLocal로 억지 구현
중첩 호출을 지원하기 위해LockContextHolder로ThreadLocal을 활용하지만 한계점 존재- 누수 위험 있음
- 여러 키를 동시에 잡는 상황에서 확장이 어려움
Redisson의 재진입 락에 비하면 기능적으로 많이 부족함
- 락 만료(expire)와 실제 unlock 타이밍이 맞지 않을 위험
트랜잭션 길이가 예상보다 길어지면 TTL(SETNXexpire)이 먼저 만료될 수 있음 (락 안정성이 TTL에 너무 의존)
Redisson 기반 분산락으로 개선하기
Redisson은 다음 기능을 기본 지원합니다.
- Redis 기반 Reentrant Lock(재진입 가능)
- Pub/Sub 기반 락 대기 → Spin Lock 제거
- TTL + Watchdog 기반 자동 연장
- 분산 환경에서 안전하게 동작
최종 개선 코드
@Service
@RequiredArgsConstructor
@Slf4j
public class RedissonDistributedLockService {
private final RedissonClient redissonClient;
private final ApplicationEventPublisher eventPublisher;
private final ErrorNotifier errorNotifier;
public <T> T executeWithRedissonLock(String lockKey, Supplier<T> supplier) {
RLock lock = null;
try {
lock = acquireLock(lockKey, 30, TimeUnit.SECONDS);
return supplier.get();
} catch (Exception e) {
errorNotifier.sendErrorMessage("error occurs in executing task in redisson lock", e);
throw e;
} finally {
// unlock은 AFTER_COMPLETION에서 실행
if (lock != null) {
eventPublisher.publishEvent(RedissonUnlockEvent.of(lockKey));
}
}
}
private RLock acquireLock(String lockKey, long leaseTime, TimeUnit unit) {
RLock lock = redissonClient.getLock(lockKey);
lock.lock(); // leaseTime 지정 X (watchdog 동작)
// lock.lock(leaseTime, unit); // 무한 대기 + leaseTime이 지나면 강제로 락이 해제됨
log.info("acquired redisson lock. key={}", lockKey);
return lock;
}
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMPLETION,
fallbackExecution = true
)
public void releaseLock(RedissonUnlockEvent event) {
RLock lock = redissonClient.getLock(event.getLockKey());
try {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("released redisson lock. key={}", event.getLockKey());
}
} catch (Exception e) {
log.error("failed to unlock redisson lock. key={}", event.getLockKey(), e);
}
}
}
개선 결과
- Redis 부하 감소 (Polling → Pub/Sub 기반)
Spin Lock 제거로 Redis 트래픽이 크게 감소 - 락 획득 효율 증가
Redisson은 Redis Pub/Sub 을 활용해 이벤트 기반으로 락을 기다림
→ 락이 풀릴 때까지 조용히 대기
→ CPU 사용량 감소 - 재진입(Reentrant) 락 내장 →
ThreadLocal제거
Redisson의 락은 동일 스레드에서 여러 번 호출해도 자동으로 hold count 관리
→ThreadLocal관리 코드 완전히 제거
→ 락 안정성 증가
→ 중첩 구조도 쉽게 표현 가능 - Watchdog 사용 패턴(TTL자동 연장)으로 안정성 증가
Redissonlock은 watchdog을 통해 락을 주기적으로 연장할 수 있음
비즈니스 로직이 길어져도 락이 먼저 expire되지 않아 트랜잭션 동안에 락이 유지됨
→ 스레드가 살아있는 동안 트랜잭션 끝날 때까지 watchdog이 계속 락을 유지
워치독의 이면 — 락이 안 풀려 무한 대기하는 경우
워치독은 편리하지만 주의할 점이 있습니다. 워치독은 "소유 프로세스가 살아 있는 한" 락을 계속 연장하므로, 그 프로세스가 살아 있는 채로 멈추면 락이 영원히 안 풀립니다.
- 소유자가 살아있는 채 멈춤 — 긴 GC 정지, 비즈니스 로직 데드락,
unlock()누락(예외 경로에서finally가 빠짐) 등. 워치독이 계속 연장 → 락 영구 점유. 이때 대기 측이lock()(무한 블로킹)이면 영원히 대기합니다. - 소유자가 종료되면(crash) — 워치독이 멈춰 남은 lease(기본 30초)만 지나면 자동 해제됩니다. 즉 프로세스가 완전히 종료되는 건 시간 안에 자가 치유되고, "종료되지 않고 멈춘" 경우가 진짜 문제입니다.
대응
먼저 "무한"이 두 종류라는 걸 구분해야 합니다 — 대기자(waiter)가 락을 못 잡아 무한 대기하는 것과, 보유자(holder)가 살아있는 채 멈춰 락을 영원히 쥐는 것. 앞의 것은 워치독을 켠 채로도 해결되고, 뒤의 것은 워치독의 성질상 근본 해결이 안 됩니다.
1) 대기자의 무한 대기 — 워치독을 켠 채 없앤다
무한 대기의 직접 원인은 lock()이 잡을 때까지 무한 블로킹하는 것입니다. leaseTime을 주지 않는 tryLock(waitTime, unit)을 쓰면 워치독(보유자 자동 연장)은 그대로 켜진 채 획득 대기에만 상한이 생깁니다. 못 잡으면 빠르게 실패해 재시도·알림으로 넘깁니다.
RLock lock = redissonClient.getLock(lockKey);
// leaseTime 없음 → 워치독 유지 + 최대 3초만 대기
boolean acquired = lock.tryLock(3, TimeUnit.SECONDS);
if (!acquired) {
throw new IllegalStateException("lock 획득 실패 — 빠른 실패로 처리");
}
try {
// 임계 구역 (보유 동안 워치독이 자동 연장)
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
즉 "워치독을 유지한 채 무한 대기를 없애는" 답은 leaseTime 없는 tryLock(waitTime, unit) 입니다 — 보유자 쪽 자동 연장은 지키고, 대기자 쪽 무한 블로킹만 끊습니다.
2) 보유 시간까지 강제하려면 leaseTime을 명시
1)로도 "보유자가 살아있는 채 멈춰 락을 계속 쥐는" 상황 자체는 안 풀립니다(워치독이 계속 연장하니까 — 이게 워치독의 목적). 그 홀드까지 강제로 끊으려면 leaseTime을 줍니다 — 이땐 워치독 대신 그 시간이 지나면 무조건 해제됩니다. 단, 임계 구역이 leaseTime보다 길어지면 작업 도중 락이 풀려 이중 실행이 날 수 있으니, leaseTime은 넉넉히 잡고 임계 구역을 멱등하게 설계합니다.
// waitTime 3초 + leaseTime 30초(워치독 대신 30초 뒤 무조건 해제)
boolean acquired = lock.tryLock(3, 30, TimeUnit.SECONDS);
3) 해제는 finally + isHeldByCurrentThread로
예외가 나도 반드시 해제하고, 만료 뒤 다른 스레드가 잡은 락을 잘못 해제하는 것을 막습니다(위 코드처럼).
4) 임계 구역이 멈추지 않게 + 보유 시간 모니터링
임계 구역 안의 DB·외부 호출에 타임아웃을 걸어 소유자가 무한정 멈추지 않게 하고, 락 보유 시간을 지표로 둬 비정상적으로 긴 홀드를 알림으로 잡습니다.
long start = System.nanoTime();
try {
// 외부 호출엔 자체 타임아웃을 둬 무한정 멈추지 않게
externalClient.call(data, Duration.ofSeconds(5));
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
long heldMs = (System.nanoTime() - start) / 1_000_000;
if (heldMs > 3_000) {
log.warn("lock held too long: {}ms key={}", heldMs, lockKey); // 비정상 홀드 알림
}
}
응급 복구가 필요하면 forceUnlock()이 있지만, 정상 소유자의 작업을 중단시킬 수 있어 최후 수단입니다.
redisson.getLock(lockKey).forceUnlock(); // 소유자와 무관하게 강제 해제(주의)
5) 정합성이 결정적이면 Redis 락의 한계를 인지
Redis 분산락은 best-effort입니다. GC 정지·만료 타이밍으로 드물게 두 소유자가 동시에 존재할 수 있어, 절대적 상호배제가 필요하면 펜싱 토큰(자원 쪽에서 토큰 순번을 검증)이나 DB 트랜잭션 락을 함께 고려합니다.
// 락 획득 때마다 단조 증가하는 토큰을 발급
RAtomicLong fencing = redisson.getAtomicLong("fence:" + lockKey);
long token = fencing.incrementAndGet();
// 보호 대상 자원은 "더 큰 토큰의 쓰기만" 허용 → 멈췄다 되살아난 옛 소유자의 쓰기를 거부
resource.writeIfTokenNewer(data, token);
마치며
Redis에서 직접 SETNX로 락을 구현하고, 스핀락으로 반복 확인하고, TTL과 트랜잭션 타이밍을 수동으로 맞추던 코드와 비교하면
Redisson은 분산락을 훨씬 더 안전하고, 간결하게 관리할 수 있는 도구입니다.
직접 구현하려면 복잡해질 모든 기능을 Redisson이 일관된 방식으로 제공합니다.
- 재진입(Reentrant) 락
- Pub/Sub 기반 대기
- 자동 연장을 위한 watchdog
- Lua 스크립트를 통한 원자적 락 관리
