Redlock: 여러 Redis 노드의 분산 잠금과 그에 대한 논쟁
1. 개요
여러 서비스 인스턴스가 동일한 리소스를 다루는 경우, 예를 들어 Order의 재고를 차감할 때는 분산 잠금이 필요합니다. 단일 Redis 노드에 대한 잠금은 간단하지만, 그 노드가 죽으면 잠금이 사라집니다. Redlock은 Redis 제작자(antirez)가 제안한 알고리즘으로, 여러 개의 독립적인 Redis 노드에서 잠금을 획득합니다.
이 글에서는 Redlock의 동작 방식, Jedis를 사용한 직접 구현, Testcontainers를 이용한 테스트 작성을 설명합니다. 마지막 부분에서는 Martin Kleppmann과 antirez 사이의 논쟁을 분석합니다. 이 글을 읽고 나면 Redlock을 언제 사용할 수 있는지, 언제 다른 도구를 선택해야 하는지 알 수 있을 것입니다.
2. 단일 Redis 노드로는 충분하지 않은 이유
단일 노드에서의 기본 잠금에는 하나의 명령만 필요합니다.
SET lock:order:42 "c3a1-uuid" NX PX 30000
NX는 키가 아직 없을 때만 설정합니다. PX 30000은 TTL을 30초로 설정하여 클라이언트가 죽으면 잠금이 자동으로 만료됩니다. 값은 무작위 토큰이며, 잠금을 소유한 클라이언트만 이를 삭제할 수 있습니다.
문제는 가용성에 있습니다. 마스터-레플리카 복제를 사용하면 다음과 같은 시나리오가 발생할 수 있습니다.
- 클라이언트 A가 마스터에서 잠금을 획득합니다.
- 마스터가 레플리카로 키를 복제하기 전에 죽습니다(Redis의 복제는 비동기입니다).
- 레플리카가 새 마스터로 승격되는데, 거기에는 잠금 키가 없습니다.
- 클라이언트 B가 같은 잠금을 획득합니다.
이 시점에서 A와 B가 동시에 잠금을 보유합니다. Redlock은 이런 시나리오를 처리하기 위해 만들어졌습니다.
3. Redlock 알고리즘
Redlock은 독립적인 N개의 Redis 노드를 사용하며, 서로 복제되거나 클러스터로 묶여있지 않습니다. 보통 N = 5입니다. 클라이언트는 다음 단계에 따라 잠금을 획득합니다.
- 시작 시간을 기록합니다.
- N개의 모든 노드에 순서대로
SET key token NX PX ttl을 전송합니다. 각 요청은 짧은 타임아웃(수십 ms)을 설정하여 죽은 노드가 클라이언트를 블로킹하지 않도록 합니다. - 경과 시간을 계산합니다. 다음 두 조건을 만족할 때 잠금 획득이 성공한 것으로 간주됩니다.
- 최소
N/2 + 1개 노드에서 획득(쿼럼), - 경과 시간이 TTL보다 작음.
- 최소
- 실제 잠금 유효 시간은
ttl - elapsed - drift이며, 여기서drift는 노드 간의 시간 편차를 보정합니다. - 실패하면 클라이언트는 모든 노드에 unlock 명령을 전송하며, 획득하지 못한 것 같은 노드도 포함합니다.
핵심 아이디어는 두 클라이언트가 같은 시간 범위에서 동시에 대다수 노드를 차지할 수 없다는 것입니다.
4. Jedis를 사용한 Redlock 구현
4.1. 의존성
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.1.0</version>
</dependency>
4.2. Redlock 클래스
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.exceptions.JedisException;
import redis.clients.jedis.params.SetParams;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public class Redlock {
private static final String UNLOCK_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
private static final double CLOCK_DRIFT_FACTOR = 0.01;
private final List<JedisPool> nodes;
private final int quorum;
public Redlock(List<JedisPool> nodes) {
this.nodes = nodes;
this.quorum = nodes.size() / 2 + 1;
}
public Optional<LockHandle> tryLock(String resource, long ttlMillis) {
String token = UUID.randomUUID().toString();
long start = System.nanoTime();
int acquired = 0;
for (JedisPool pool : nodes) {
if (lockInstance(pool, resource, token, ttlMillis)) {
acquired++;
}
}
long elapsed = (System.nanoTime() - start) / 1_000_000;
long drift = (long) (ttlMillis * CLOCK_DRIFT_FACTOR) + 2;
long validity = ttlMillis - elapsed - drift;
if (acquired >= quorum && validity > 0) {
return Optional.of(new LockHandle(resource, token, validity));
}
unlockAll(resource, token);
return Optional.empty();
}
public void unlock(LockHandle handle) {
unlockAll(handle.resource(), handle.token());
}
private boolean lockInstance(JedisPool pool, String resource, String token, long ttl) {
try (Jedis jedis = pool.getResource()) {
return "OK".equals(jedis.set(resource, token, SetParams.setParams().nx().px(ttl)));
} catch (JedisException e) {
return false; // 노드가 죽었거나 타임아웃: 획득 실패로 간주
}
}
private void unlockAll(String resource, String token) {
for (JedisPool pool : nodes) {
try (Jedis jedis = pool.getResource()) {
jedis.eval(UNLOCK_SCRIPT, List.of(resource), List.of(token));
} catch (JedisException ignored) {
// 노드가 응답하지 않으면 키가 TTL에 따라 자동 만료됨
}
}
}
public record LockHandle(String resource, String token, long validityMillis) {}
}
주요 사항들:
quorum = N/2 + 1. 3개 노드의 경우 쿼럼은 2입니다. 5개 노드의 경우 쿼럼은 3입니다.lockInstance는 예외를 무시하고false를 반환합니다. 죽은 노드는 투표 수를 줄일 뿐 전체 작업을 망치지 않습니다.validity는 실제로 잠금을 보유할 수 있는 시간입니다. 이 시간은 항상 TTL보다 작습니다.- Unlock은 Lua 스크립트를 사용하여 ‘토큰 비교 후 삭제’ 작업을 원자적으로 수행합니다. 별도의 두 명령으로
GET다음DEL을 하면, 다른 클라이언트가 방금 획득한 잠금을 실수로 삭제할 수 있습니다. - 실패할 때,
unlockAll은 모든 노드에 전송됩니다. 이유는SET명령이 서버에서는 성공했지만 클라이언트 쪽 응답이 타임아웃될 수 있기 때문입니다.
4.3. 짧은 타임아웃으로 노드 초기화
import redis.clients.jedis.JedisPoolConfig;
List<JedisPool> pools = List.of(
new JedisPool(new JedisPoolConfig(), "redis-1", 6379, 50),
new JedisPool(new JedisPoolConfig(), "redis-2", 6379, 50),
new JedisPool(new JedisPoolConfig(), "redis-3", 6379, 50));
Redlock redlock = new Redlock(pools);
마지막 매개변수는 50ms의 타임아웃입니다. 기본 타임아웃인 2초를 사용하면, 응답하지 않는 노드가 잠금의 TTL을 거의 다 소비할 수 있습니다.
4.4. InventoryService에서 사용
public class InventoryService {
private final Redlock redlock;
private final InventoryRepository repository;
public InventoryService(Redlock redlock, InventoryRepository repository) {
this.redlock = redlock;
this.repository = repository;
}
public boolean reserve(String sku, int quantity) {
var handle = redlock.tryLock("lock:sku:" + sku, 10_000);
if (handle.isEmpty()) {
return false; // caller가 backoff와 함께 재시도하도록
}
try {
int stock = repository.findStock(sku);
if (stock < quantity) return false;
repository.updateStock(sku, stock - quantity);
return true;
} finally {
redlock.unlock(handle.get());
}
}
}
Unlock은 항상 finally 블록에 있어야 합니다. 잠금 영역 내의 코드는 validityMillis 내에 완료되어야 합니다. 작업이 오래 걸릴 수 있다면, 잠금 갱신 메커니즘(watchdog) 또는 작업 분할이 필요합니다.
5. Testcontainers를 사용한 테스트
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
@Testcontainers
class RedlockTest {
@Container static GenericContainer<?> r1 = redis();
@Container static GenericContainer<?> r2 = redis();
@Container static GenericContainer<?> r3 = redis();
static GenericContainer<?> redis() {
return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
}
static Redlock newClient() {
return new Redlock(Stream.of(r1, r2, r3)
.map(c -> new JedisPool(new JedisPoolConfig(),
c.getHost(), c.getMappedPort(6379), 200))
.toList());
}
@Test
void secondClientCannotAcquireWhileFirstHoldsLock() {
Redlock clientA = newClient();
Redlock clientB = newClient();
var lockA = clientA.tryLock("lock:order:42", 5_000);
var lockB = clientB.tryLock("lock:order:42", 5_000);
assertThat(lockA).isPresent();
assertThat(lockA.get().validityMillis()).isLessThan(5_000);
assertThat(lockB).isEmpty();
clientA.unlock(lockA.get());
assertThat(clientB.tryLock("lock:order:42", 5_000)).isPresent();
}
@Test
void stillAcquiresWhenOneNodeIsDown() {
Redlock client = newClient();
r3.stop();
assertThat(client.tryLock("lock:order:99", 5_000)).isPresent();
}
}
첫 번째 테스트는 상호 배제를 확인합니다. B는 A가 잠금을 보유할 때 실패하고, A가 unlock한 후 즉시 획득할 수 있습니다. 두 번째 테스트는 3개 노드 중 1개가 손실되어도 Redlock이 작동함을 보여줍니다. 2개 노드가 남으면 쿼럼을 충족하기 때문입니다. 이 테스트는 컨테이너를 완전히 중지하므로 마지막에 두거나 별도의 클래스로 분리하는 것이 좋습니다.
6. 그럼 Redisson은?
실제로는 Redlock을 직접 작성하는 경우가 거의 없습니다. Redisson은 한때 RedissonRedLock을 제공했으며, 독립적인 클라이언트로부터 여러 RLock을 받습니다.
주의: 최근 Redisson 버전에서
RedissonRedLock과RedissonMultiLock은 deprecated 표시되었습니다. Redisson은 watchdog로 자동 갱신되고 레플리카로 동기화될 수 있는 일반RLock을 사용하거나, fencing token이 필요한 경우RFencedLock을 사용할 것을 권장합니다. API를 선택하기 전에 사용 중인 버전을 확인하세요.
가장 인기 있는 라이브러리도 Redlock을 포기한 사실이 우리를 논쟁으로 이끕니다.
7. 논쟁: Kleppmann vs antirez
7.1. Martin Kleppmann의 논거
Designing Data-Intensive Applications의 저자인 Kleppmann은 Redlock이 정확성을 요구하는 문제에 대해 안전하지 않음을 지적합니다. 주요 이유는 두 가지입니다.
프로세스 일시 중지. 다음 시나리오를 보세요.
t=0 클라이언트 A가 잠금을 획득 (TTL 10s)
t=1 A가 GC stop-the-world 15초 동안 멈춤
t=10 모든 노드에서 잠금이 만료됨
t=11 클라이언트 B가 잠금을 획득, DB에 데이터 기록
t=16 A가 깨어남, 여전히 잠금을 보유했다고 생각하고, B의 데이터를 덮어씀
TTL 기반의 잠금 알고리즘은 이 시나리오를 방지할 수 없습니다. 쓰기 직전에 validity를 확인해도 도움이 되지 않습니다. 왜냐하면 일시 중지는 확인 직후에 발생할 수 있기 때문입니다. 일시 중지의 원인은 GC뿐만 아닙니다. 페이지 폴트, VM 이동 또는 네트워크 지연도 같은 효과를 일으킵니다.
시간에 대한 의존성. Redlock은 노드의 시계가 거의 같은 속도로 실행된다고 가정합니다. 한 노드의 시계가 점프하면, 예를 들어 NTP 급격한 조정 또는 관리자의 수동 시간 변경으로 인해, 그 노드의 키가 일찍 만료될 수 있습니다. 5개 노드를 사용한 구체적인 시나리오:
A가 노드 1, 2, 3에서 잠금을 획득
노드 3의 시계가 앞으로 점프, 노드 3의 키가 만료됨
B가 노드 3, 4, 5에서 잠금을 획득
=> A와 B가 모두 쿼럼을 보유
Kleppmann의 결론: 안전하려면 fencing token이 필요합니다. 즉, 잠금과 함께 오는 단조 증가 번호입니다. Redlock은 이 번호를 제공하지 않으며, 토큰은 무작위 문자열입니다.
7.2. Fencing token이 어떻게 보이는지
아이디어는 storage가 이전 토큰이 있는 쓰기를 자동으로 거부하는 것입니다.
public class FencedInventoryRepository {
private final JdbcTemplate jdbc;
public FencedInventoryRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public boolean updateStock(String sku, int newStock, long fencingToken) {
int rows = jdbc.update("""
UPDATE inventory SET stock = ?, last_token = ?
WHERE sku = ? AND last_token < ?
""", newStock, fencingToken, sku, fencingToken);
return rows == 1;
}
}
위의 GC 시나리오에 적용하면: B는 토큰 34로 기록하고, A가 깨어나 토큰 33으로 기록합니다. A의 UPDATE는 0행에 영향을 주므로 B의 데이터는 유지됩니다. 핵심은 lock이 아닌 storage가 정확성을 보장한다는 것입니다. 토큰은 ZooKeeper(zxid) 또는 etcd(revision)와 같은 합의 시스템에서 생성되어야 합니다.
7.3. antirez의 응답
antirez는 다음과 같은 주요 포인트로 답변했습니다.
- Storage가 이미 compare-and-set 스타일의 토큰 확인을 지원한다면, 무작위 토큰을 직접 사용할 수 있으며, 그 경우 lock은 거의 불필요합니다.
- Redlock은 잠금을 획득한 후 시간을 확인하는 단계가 있어서 획득 중 네트워크 지연이 감지됩니다. 그 단계 이후의 일시 중지는 모든 TTL 기반 잠금이 공통으로 겪는 문제입니다.
- 시간 가정은 적절한 운영이 있으면 합리적입니다: NTP가 갑자기 시간을 점프하지 않도록 하고(slew 모드 사용), 수동 시간 조정을 하지 않습니다.
지금까지 어느 쪽도 절대적으로 ’승리’하지 못했습니다. 그러나 커뮤니티 대부분은 다음 섹션에서 설명하는 Kleppmann의 분류 방식에 동의합니다.
8. 언제 사용할 것인가, 언제 하지 말 것인가
스스로에게 물어보세요. 두 클라이언트가 동시에 잠금을 보유하면 어떻게 됩니까?
- 효율성 잠금(중복 작업 피하기): 중복 보고서 이메일 발송, 크론 작업 중복 실행, 캐시 재계산. 가끔 중복되어도 괜찮습니다. 이 경우
SET NX PX가 있는 단일 Redis 노드로 충분합니다. Redlock은 인프라를 3–5배 소비하면서 눈에 띄는 개선을 거의 제공하지 않습니다. - 정확성 잠금(잘못되면 데이터가 손상): 돈 차감, 재고 감소, 인보이스 번호 발급. Redlock은 충분하지 않습니다. etcd 또는 ZooKeeper와 fencing token을 사용하거나, optimistic locking(
@Versionin JPA),SELECT ... FOR UPDATE또는 unique constraint를 사용하여 제약 조건을 데이터베이스로 내려야 합니다.
주의: Redlock을 사용할 때 흔한 실수:
- Redis Cluster 또는 master-replica를 Redlock의 ’노드’로 사용합니다. 노드는 완전히 독립적이어야 합니다.
- 연결 타임아웃을 기본값으로 너무 길게 두기.
- 토큰 확인 Lua 스크립트 대신
DEL로 unlock하기.- Persistence를 활성화했지만
fsync=always를 구성하지 않았거나, 노드 재시작을 지연하지 않았습니다. 노드 재시작 및 키 손실로 인해 두 클라이언트가 쿼럼에 동시에 도달할 수 있습니다. antirez는 재시작된 노드가 최소한 가장 큰 TTL의 기간 동안 대기한 후 다시 참여하도록 권장합니다.
9. 결론
Redlock은 N개의 독립적인 Redis 노드의 다수결 잠금을 획득하며, 네트워크 지연과 시간 편차를 보정하기 위해 유효 시간을 계산합니다. Jedis를 사용한 구현 방식, Lua 스크립트를 사용한 안전한 unlock, Testcontainers를 사용한 테스트 방법을 보았습니다.
Redlock은 여전히 TTL과 시계에 의존하므로 프로세스 일시 중지나 시계 점프에 대항할 수 없습니다. 따라서 중복 작업만 피하려면 단일 Redis 노드로 충분합니다. 데이터의 정확성을 보호하는 잠금의 경우, 합의 시스템의 fencing token이 필요하거나, 데이터베이스가 자체 제약 조건을 유지해야 합니다.