본문으로 건너뛰기

Redis Spin Lock에서 Redisson 기반 분산락으로 개선하기

· 약 7분
Johny Cho
Back End Engineer @ NHN


이번 포스트에서는 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 구조의 문제점

  1. Redis 부하 증가 (Polling 기반 구조)
    스레드가 잠들었다 깨며 매 100ms마다 SETNX를 호출함
    특히 락이 오래 유지되면, Redis는 불필요한 트래픽을 계속 받음
  2. Spin Lock은 CPU 효율이 좋지 않음
    100ms sleep을 해도 결국 폴링 기반이라, 불필요하게 스레드를 깨우고 put/get을 반복하게 됨
  3. 재진입(Reentrant) 락이 아님 → ThreadLocal로 억지 구현
    중첩 호출을 지원하기 위해 LockContextHolderThreadLocal을 활용하지만 한계점 존재
    • 누수 위험 있음
    • 여러 키를 동시에 잡는 상황에서 확장이 어려움
    • Redisson의 재진입 락에 비하면 기능적으로 많이 부족함
  4. 락 만료(expire)와 실제 unlock 타이밍이 맞지 않을 위험
    트랜잭션 길이가 예상보다 길어지면 TTL(SETNX expire)이 먼저 만료될 수 있음 (락 안정성이 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);
}
}
}

개선 결과

  1. Redis 부하 감소 (Polling → Pub/Sub 기반)
    Spin Lock 제거로 Redis 트래픽이 크게 감소
  2. 락 획득 효율 증가
    Redisson은 Redis Pub/Sub 을 활용해 이벤트 기반으로 락을 기다림
    → 락이 풀릴 때까지 조용히 대기
    CPU 사용량 감소
  3. 재진입(Reentrant) 락 내장ThreadLocal 제거
    Redisson의 락은 동일 스레드에서 여러 번 호출해도 자동으로 hold count 관리
    ThreadLocal 관리 코드 완전히 제거
    → 락 안정성 증가
    → 중첩 구조도 쉽게 표현 가능
  4. Watchdog 사용 패턴(TTL자동 연장)으로 안정성 증가
    Redisson lock은 watchdog을 통해 락을 주기적으로 연장할 수 있음
    비즈니스 로직이 길어져도 락이 먼저 expire되지 않아 트랜잭션 동안에 락이 유지됨
    → 스레드가 살아있는 동안 트랜잭션 끝날 때까지 watchdog이 계속 락을 유지

마치며

Redis에서 직접 SETNX로 락을 구현하고, 스핀락으로 반복 확인하고, TTL과 트랜잭션 타이밍을 수동으로 맞추던 코드와 비교하면 Redisson은 분산락을 훨씬 더 안전하고, 간결하게 관리할 수 있는 도구입니다.

직접 구현하려면 복잡해질 모든 기능을 Redisson이 일관된 방식으로 제공합니다.

  • 재진입(Reentrant) 락
  • Pub/Sub 기반 대기
  • 자동 연장을 위한 watchdog
  • Lua 스크립트를 통한 원자적 락 관리
Loading comments...