Redis 분산락은 왜 완전히 안전하다고 보기 어려울까?

반응형

분산락이 무엇인지 학습 하고, 직접 적용하는 시간을 가져봤습니다.

 

분산락은 도대체 무엇일까?

이전에도 Lock에 대해 학습을 진행한적이 있었습니다. DB락 부터, 낙관 락, 비관 락에 대해 학습을 진행하였죠. RDB vs Nosql크게 DB에는 2가지 종류가 존재합니다. SQL을 사용하는 RDB와 SQL도 사용하는 N

b-programmer.tistory.com

 

분산락을 적용해보자!

분산락은 도대체 무엇일까?이전에도 Lock에 대해 학습을 진행한적이 있었습니다. DB락 부터, 낙관 락, 비관 락에 대해 학습을 진행하였죠. RDB vs Nosql크게 DB에는 2가지 종류가 존재합니다. SQL을 사

b-programmer.tistory.com

저는 ShedLock과 Redis 두 가지 모두 적용할 수 있도록 다음과 같은 인터페이스를 설계했습니다.

public interface DistributedLockExecutor {
  <T> Optional<T> executeWithLock(String key, LockOptions options, Supplier<T> task);
}

그리고 Redis 기반 구현은 다음과 같이 작성했습니다.

public <T> Optional<T> executeWithLock(String key, LockOptions options, Supplier<T> task) {
    String token = UUID.randomUUID().toString();
    Boolean acquired = redisTemplate.opsForValue().setIfAbsent(key, token, options.lockAtMostFor());
    if (!Boolean.TRUE.equals(acquired)) {
      return Optional.empty();
    }
    try {
      return Optional.ofNullable(task.get());
    } finally {
        redisTemplate.delete(key);
    }
  }

해당 코드는 Redis에 키가 존재하지 않을 때만 락을 획득하고, 작업이 끝난 뒤 delete로 락을 해제하는 방식입니다.
lockAtMostFor는 TTL로, 일정 시간이 지나면 자동으로 락이 해제됩니다.
하지만 이 방식이 완전히 안전하다고 보기는 어렵습니다. 작업 시간이 TTL을 초과하거나 예기치 못한 상황이 발생할 경우,
다른 인스턴스가 락을 다시 획득한 뒤 기존 인스턴스가 이를 삭제해버릴 가능성이 존재하기 때문입니다.
따라서 Redis 락은 단순히 키를 삭제하는 방식만으로는 충분하지 않을 수 있으며,
락의 소유권을 함께 검증하는 방식이 필요하다고 알려져 있습니다.

어째서 Redis 분산락은 안전하지 못할까? 

Redis 자체가 위험해서가 아니라, TTL 기반 락 구조가 특정 상황에서 상호 배제를 깨뜨릴 수 있기 때문입니다.

보통 Redis 락은 다음과 같이 작성합니다.

SET key value NX PX 10000
  • 키가 없으면 생성
  • 10초 후 자동 삭제

문제는 작업 시간이 TTL을 초과하는 경우입니다.

예를 들어 서버 A가 락을 획득했다고 가정해봅시다.

하지만 작업은 20초가 걸리고, TTL은 10초라고 해봅시다.

10초가 지나 TTL이 만료되면, 다른 서버는 해당 키를 다시 획득할 수 있습니다.
이 시점에 서버 B가 락을 획득했다고 가정해보겠습니다.

그런데 기존 코드를 보면, 작업이 완료된 후 delete로 키를 제거하고 있습니다.

 try {
      return Optional.ofNullable(task.get());
    } finally {
        redisTemplate.delete(key);
    }

이 경우 다음과 같은 문제가 발생할 수 있습니다.

  1. 서버 A와 서버 B가 동시에 실행될 수 있습니다.
  2. 서버 A가 작업을 마치며 delete를 실행하면, 서버 B가 새로 획득한 락까지 제거할 수 있습니다.

이는 곧, 이후에도 동일한 키에 대해 동시 실행이 가능해질 수 있다는 의미입니다.

분산 환경에서 동시 실행을 제어하기 위해 Redis를 사용했지만,
이처럼 TTL과 해제 로직을 신중하게 설계하지 않으면 오히려 상호 배제가 완전히 보장되지 않을 수 있습니다.

결론적으로, Redis 분산락은 빠르고 간단하지만, TTL과 락 소유권 검증을 제대로 설계하지 않으면 상호 배제가 깨질 가능성이 존재합니다.

그렇다면, 어떻게 해야 할까요?

TTL을 작업시간보다 길게 잡는방법

가장 1차적인 대응 방법입니다.
작업이 15초 정도 소요될 수 있다면, TTL을 30~60초처럼 넉넉하게 설정하는 방식입니다.

하지만 TTL을 길게 잡는다고 해서 완전히 안전해지는 것은 아닙니다.
예외 상황이 발생할 가능성은 여전히 존재하기 때문입니다.

예를 들어,

  • 네트워크 지연
  • GC로 인한 일시 정지
  • 예상보다 긴 작업 시간

과 같은 상황이 발생하면 TTL이 만료된 뒤 다른 인스턴스가 락을 획득할 수 있습니다.

결국 TTL을 늘리는 것은 위험을 줄이는 방법일 뿐, 상호 배제를 완전히 보장하는 해결책은 아닙니다.

락 해제: token 일치할 때만 삭제 (소유권 검증)

여기서 중요한 부분은 소유권 검증입니다. 다시 코드를 확인해봅시다.

 String token = UUID.randomUUID().toString();
    Boolean acquired = redisTemplate.opsForValue().setIfAbsent(key, token, options.lockAtMostFor());

Redis 락에는 key뿐만 아니라 token도 함께 저장하고 있습니다.
그렇다면 락을 해제할 때도 단순히 키만 삭제하는 것이 아니라, 토큰까지 확인한 뒤 삭제하면 되지 않을까요?

이를 자바 코드로 작성하면 다음과 같이 표현할 수 있습니다.

String currentToken = redisTemplate.opsForValue().get(key);
  if (currentToken.equals(token)) {
     redisTemplate.delete(key);
   }

이렇게 작성하면 토큰이 일치하는 경우에만 락을 해제할 수 있습니다.
겉으로 보기에는 소유권까지 확인하고 있기 때문에 꽤 괜찮은 방법처럼 보입니다.

하지만 이 방식에도 문제가 있습니다. get과 delete가 하나의 동작이 아니라 두 번의 요청으로 분리되어 있다는 점입니다.

즉, get()으로 값을 확인한 직후 TTL이 만료되거나, 다른 인스턴스가 락을 다시 획득할 수 있습니다.
그 상태에서 delete()를 실행하면, 이미 다른 서버가 획득한 락을 제거하는 상황이 발생할 수 있습니다.
결국 검사와 삭제가 하나의 원자적 동작으로 묶여 있지 않다면, 완전히 안전하다고 보기는 어렵습니다.

Lua 스크립트: 검사와 삭제가 하나로 원자적으로 사용

앞서 살펴본 것처럼, get과 delete를 분리해서 호출하는 방식은 검사와 삭제 사이에 틈이 생길 수 있습니다.

이 문제를 해결하기 위해 Redis에서는 Lua 스크립트를 사용할 수 있습니다.
Lua 스크립트는 Redis 내부에서 하나의 명령처럼 실행되기 때문에,
검사와 삭제를 원자적(Atomic) 으로 처리할 수 있습니다.

예를 들어 다음과 같은 스크립트를 작성할 수 있습니다.

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

이 스크립트는 다음과 같은 의미를 가집니다.

  • 현재 저장된 값이 내가 가진 token과 일치하는지 확인합니다.
  • 일치하는 경우에만 키를 삭제합니다.
  • 일치하지 않으면 아무 작업도 수행하지 않습니다.

중요한 점은, 이 모든 과정이 Redis 내부에서 하나의 동작으로 처리된다는 것입니다.
따라서 검사 직후 다른 인스턴스가 락을 획득하는 상황을 원천적으로 차단할 수 있습니다.

결국 Redis 분산락에서 중요한 것은 단순히 TTL을 설정하는 것이 아니라,
락의 소유권을 원자적으로 검증하고 해제하는 구조를 만드는 것입니다.

코드는 다음과 같이 작성할 수 있습니다.

redisTemplate.execute(
     new DefaultRedisScript<>(LockKey.LOCK_SCRIPT, Long.class),
     Collections.singletonList(key),
     token
)

그렇다면, Lua스크립트는 안전할까?

결론부터 말하면, 단일 Redis 인스턴스 기준에서는 충분히 안전하다고 볼 수 있습니다.
그 이유는 Redis의 실행 모델에 있습니다.

Redis는 기본적으로 싱글 스레드 모델로 동작합니다. 이는 한 번에 하나의 명령만 처리한다는 의미입니다.
즉, 여러 명령이 동시에 섞여 실행되는 구조가 아닙니다.

Lua 스크립트는 Redis 내부에서 하나의 명령처럼 실행됩니다.
중간에 다른 명령이 끼어들 수 없으며, 스크립트가 끝날 때까지 순차적으로 처리됩니다.

라서 우리가 작성한 검사 → 삭제 이 두 과정은 절대 분리되지 않고, 한 번에 실행됩니다.
이것이 바로 원자성(Atomicity) 입니다.
즉, 검사 직후 다른 인스턴스가 개입할 수 없도록 보장해주는 것이 Lua 스크립트의 가장 큰 장점입니다.

그렇다면, Redis락은 언제 사용하는것이 좋을까?

저번 글에서 AI로 정리했을 때 Redis 락은 "범용 락"이라고 했습니다.
확실히 DB 락처럼 특정 기능에만 묶여 있지 않고, 다양한 상황에 적용할 수 있다는 점에서 범용적이라고 느껴집니다.

다만 그렇다고 해서 아무 곳에나 Redis 락을 적용하기에는 어렵다고 생각합니다.
Redis 락은 편리하지만, TTL과 해제 방식(소유권 검증 등)을 함께 고려해야 하고, 운영 측면에서도 관리 포인트가 추가되기 때문입니다.

그래서 저는 Redis 락은 아래와 같은 상황에서 특히 의미가 있다고 생각합니다.

  • DB 트랜잭션으로 묶기 어려운 작업을 보호해야 하는 경우 (예: 외부 API 호출)
  • 비즈니스 키 단위로 실행을 제한하고 싶은 경우 (예: 특정 사용자/주문 단위로 한 번만 처리)

결국 Redis 락은 "락이 필요해서"라기보다는, 중복 실행을 줄이고 실행 주체를 제한해야 하는 상황에서 특히 효과적이라고 볼 수 있습니다.

마무리

Redis 락을 직접 적용해보면서, 왜 Redis 락을 사용하는지 그리고 어떤 문제가 발생할 수 있는지에 대해 고민해보는 시간을 가졌습니다.
기존의 단순한 락 해제 방식에서는 TTL보다 작업 시간이 길어질 경우, 다른 인스턴스가 락을 다시 획득할 수 있고, 그로 인해 동시에 실행될 수 있음을 확인했습니다. 이를 개선하기 위해 토큰을 생성하고, 현재 저장된 토큰이 내가 가진 값과 일치하는 경우에만 락을 해제하도록 변경했습니다. 이 방식으로 엉뚱한 인스턴스의 락을 제거하는 문제는 방지할 수 있었습니다. 하지만 get()과 delete()를 분리해서 호출하는 구조에서는 그 사이에 네트워크 지연이나 상태 변경이 발생할 가능성이 남아 있음을 확인했습니다. 결국 이 문제를 해결하기 위해, 검사와 삭제를 하나의 동작으로 묶을 수 있는 Lua 스크립트를 활용하여 원자적으로 실행되도록 코드를 개선하였습니다. 이번 과정을 통해 Redis 분산락은 단순히 "빠르고 편리한 락"이 아니라, 설계와 구현 방식에 따라 안정성이 크게 달라질 수 있는 기술이라는 점을 체감하게 되었습니다.



 

반응형

댓글

Designed by JB FACTORY