분산 락 — Redlock 알고리즘과 구현
여러 서버에서 동시에 같은 자원에 접근할 때, 하나의 서버만 작업하도록 보장하려면 어떻게 해야 할까요?
개념 정의
분산 락 은 여러 프로세스나 서버가 공유 자원에 동시에 접근하는 것을 방지하는 메커니즘입니다. 단일 서버의 synchronized나 ReentrantLock은 프로세스 내부에서만 동작하므로, 분산 환경에서는 Redis, ZooKeeper 등 외부 시스템을 이용한 락이 필요합니다.
왜 필요한가
- 재고 차감: 동시에 주문이 들어오면 재고가 음수가 될 수 있습니다
- 중복 결제 방지: 같은 결제 요청이 여러 서버에서 처리되면 안 됩니다
- 스케줄러 중복 실행: 여러 인스턴스에서 같은 배치 작업이 동시에 실행되는 것을 방지합니다
기본 구현 — SETNX + TTL
Redis의 SET NX(SET if Not eXists)를 사용한 가장 기본적인 분산 락입니다.
# 락 획득 — 키가 없을 때만 설정, 10초 TTL
127.0.0.1:6379> SET lock:order:1001 "server-1-uuid" NX EX 10
OK # 성공
127.0.0.1:6379> SET lock:order:1001 "server-2-uuid" NX EX 10
(nil) # 실패 — 이미 락이 존재
Java 구현
public class SimpleRedisLock {
private final StringRedisTemplate redisTemplate;
private final String lockKey;
private final String lockValue; // 고유 식별자 (UUID)
private final Duration ttl;
public SimpleRedisLock(StringRedisTemplate redisTemplate, String resource) {
this.redisTemplate = redisTemplate;
this.lockKey = "lock:" + resource;
this.lockValue = UUID.randomUUID().toString();
this.ttl = Duration.ofSeconds(10);
}
// 락 획득
public boolean tryLock() {
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, ttl);
return Boolean.TRUE.equals(result);
}
}
tryLock()은 SET NX EX를 한 번에 실행하므로 원자적입니다. 반면 락 해제는 "내 락인지 확인 → 삭제"를 두 단계로 해야 하므로, Lua 스크립트로 원자성을 보장합니다.
public class SimpleRedisLock {
// ... 필드, 생성자, tryLock() 생략
// 락 해제 — Lua 스크립트로 원자적 처리
public boolean unlock() {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(lockKey),
lockValue
);
return result != null && result == 1;
}
}
왜 Lua 스크립트가 필요한가
락 해제 시 "내가 설정한 락인지 확인" → "삭제"를 두 단계로 처리하면 문제가 생깁니다.
시간 T: 서버A가 GET lock:resource → "serverA-uuid" (내 락 맞음)
시간 T+1: TTL 만료로 락 자동 삭제
시간 T+2: 서버B가 SET lock:resource "serverB-uuid" NX → 성공
시간 T+3: 서버A가 DEL lock:resource → 서버B의 락을 삭제해버림!
Lua 스크립트는 Redis에서 원자적으로 실행되므로 이 문제를 방지합니다.
기본 구현의 한계
단일 Redis 인스턴스에 의존하면 다음 문제가 있습니다.
- Single Point of Failure: Redis가 죽으면 락 자체가 불가능
- ** 복제 지연 **: Master-Replica 구성에서 Master가 락을 설정한 직후 죽으면, Replica가 승격되었을 때 락이 없을 수 있음
시간 T: 서버A → Master에 락 설정 (성공)
시간 T+1: Master 다운 (아직 Replica에 복제 안 됨)
시간 T+2: Replica가 Master로 승격
시간 T+3: 서버B → 새 Master에 락 설정 (성공!) → 두 서버가 동시에 락 보유
Redlock 알고리즘
Antirez(Redis 개발자)가 제안한 알고리즘으로, N개의 독립된 Redis 인스턴스를 사용하여 안전성을 높입니다.
동작 과정 (N=5 기준)
1. 현재 시간 기록 (T1)
2. 5개 인스턴스 모두에 순서대로 락 획득 시도
- 각 인스턴스에 짧은 타임아웃 설정 (예: 5~50ms)
3. 과반수(3개) 이상에서 성공하고,
총 소요 시간(T2-T1)이 TTL보다 짧으면 → 락 획득 성공
4. 실패하면 모든 인스턴스에서 락 해제
유효 잠금 시간 계산
유효 잠금 시간 = TTL - (락 획득에 소요된 시간) - (클럭 드리프트)
예: TTL=10초, 소요시간=2초, 드리프트=0.1초
→ 유효 잠금 시간 = 10 - 2 - 0.1 = 7.9초
의사 코드
def redlock_acquire(instances, resource, ttl):
lock_value = generate_uuid()
start_time = current_time_ms()
acquired = 0
for instance in instances:
try:
if instance.set(resource, lock_value, nx=True, px=ttl, timeout=50):
acquired += 1
except ConnectionError:
pass
elapsed = current_time_ms() - start_time
quorum = len(instances) // 2 + 1
모든 인스턴스에 락 요청을 보낸 후, 과반수 성공 여부와 소요 시간을 기준으로 최종 판정합니다. 실패하면 부분적으로 획득한 락을 모두 해제합니다.
# redlock_acquire 계속
if acquired >= quorum and elapsed < ttl:
validity_time = ttl - elapsed
return lock_value, validity_time
else:
# 실패 — 모든 인스턴스에서 해제
for instance in instances:
try:
instance.eval(unlock_script, resource, lock_value)
except ConnectionError:
pass
return None
Martin Kleppmann의 비판
Martin Kleppmann(Designing Data-Intensive Applications 저자)은 Redlock에 대해 중요한 비판을 제기했습니다.
핵심 논점: 프로세스 일시 정지
시간 T: 클라이언트A가 Redlock으로 락 획득 (TTL=10초)
시간 T+1~T+11: 클라이언트A에서 GC 정지 (Stop-the-World) 발생
시간 T+10: 락 만료 (클라이언트A는 GC 중이라 모름)
시간 T+11: 클라이언트B가 같은 락 획득
시간 T+12: 클라이언트A의 GC 끝남 → 자기가 아직 락을 가지고 있다고 믿음
→ 두 클라이언트가 동시에 임계 영역에서 작업!
Kleppmann의 대안: Fencing Token
1. 락을 획득할 때마다 단조 증가하는 토큰(fencing token)을 발급
2. 공유 자원에 접근할 때 토큰을 함께 전달
3. 자원 측에서 이전보다 낮은 토큰의 요청을 거부
클라이언트A: 토큰 33으로 락 획득
클라이언트B: 토큰 34로 락 획득
클라이언트A: 토큰 33으로 DB 쓰기 시도 → 거부됨 (34 이후만 허용)
Antirez의 반론
- Redis는 타이밍 가정에 의존하지만, 실제로 GC가 10초 이상 멈추는 일은 극히 드물다
- Fencing token은 자원 측에서 지원해야 하는데, 모든 시스템이 지원하지는 않는다
- 완벽한 안전성보다는 실용적인 수준의 보호가 목적이다
Redisson — 프로덕션 레벨 구현
직접 구현하는 것보다 검증된 라이브러리를 사용하는 것이 권장됩니다.
// Redisson 설정
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
RedissonClient redisson = Redisson.create(config);
설정이 완료되면 tryLock(waitTime, leaseTime, unit) 메서드로 대기 시간과 자동 해제 시간을 지정하여 락을 획득합니다.
RLock lock = redisson.getLock("order:1001");
try {
// 최대 10초 대기, 30초 후 자동 해제
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
try {
// 임계 영역
processOrder(1001);
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Redisson의 워치독 (Watchdog)
leaseTime을 지정하지 않으면 워치독이 자동으로 TTL을 연장합니다.
// leaseTime을 지정하지 않으면 워치독 활성화 (기본 30초, 10초마다 갱신)
RLock lock = redisson.getLock("order:1001");
lock.lock(); // 워치독이 자동으로 TTL 연장
try {
// 작업 시간이 길어져도 락이 만료되지 않음
longRunningTask();
} finally {
lock.unlock();
}
재진입 락 (Reentrant Lock)
RLock lock = redisson.getLock("resource");
lock.lock(); // 획득 횟수: 1
lock.lock(); // 획득 횟수: 2 (같은 스레드)
lock.unlock(); // 획득 횟수: 1
lock.unlock(); // 완전 해제
공정 락 (Fair Lock)
RLock fairLock = redisson.getFairLock("resource");
// 요청 순서대로 락을 획득 (FIFO)
fairLock.lock();
Redisson Redlock
RLock lock1 = redisson1.getLock("lock");
RLock lock2 = redisson2.getLock("lock");
RLock lock3 = redisson3.getLock("lock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();
try {
// 과반수 이상의 인스턴스에서 락 획득
} finally {
redLock.unlock();
}
분산 락 사용 시 주의사항
- **TTL은 작업 시간보다 충분히 길게 **: 작업이 TTL보다 오래 걸리면 락이 만료됩니다
- ** 반드시 finally에서 unlock**: 예외가 발생해도 락이 해제되어야 합니다
- ** 락의 범위를 최소화 **: 임계 영역만 락으로 보호하세요
- ** 분산 락 ≠ 분산 트랜잭션 **: 락은 동시 접근을 막을 뿐, 원자적 연산을 보장하지 않습니다
- ** 완벽한 안전성이 필요하면 **: ZooKeeper나 etcd 기반의 합의 프로토콜을 고려하세요
함정/Pitfall
1. TTL보다 작업이 오래 걸리면 두 클라이언트가 동시에 락을 보유한다
작업 시간이 TTL을 초과하면 락이 만료되어 다른 클라이언트가 같은 락을 획득합니다. Redisson의 워치독이나 충분한 TTL 설정으로 방어해야 합니다.
2. Master-Replica 구성에서 복제 지연으로 락이 깨질 수 있다
Master에 락을 설정한 직후 Master가 죽으면, Replica에 복제되지 않은 상태에서 승격되어 다른 클라이언트가 같은 락을 획득할 수 있습니다. 이 문제를 해결하려면 Redlock이나 합의 기반 시스템이 필요합니다.
3. GC 정지 시 클라이언트가 락 만료를 인지하지 못한다
Kleppmann의 비판 핵심입니다. 클라이언트가 GC pause 중에 락이 만료되면, 깨어난 후 자신이 아직 락을 보유하고 있다고 착각합니다. 완벽한 안전성이 필요하면 fencing token을 도입하세요.
정리
| 항목 | 핵심 내용 |
|---|---|
| 기본 구현 | SETNX + TTL + Lua 해제, 단순한 경우 충분 |
| Redlock | N/2+1 과반수 합의, 고가용성 필요 시 |
| Redisson | 워치독, 재진입, 공정 락 지원, 프로덕션 권장 |
| Kleppmann 비판 | GC 정지 시 안전하지 않음, fencing token 대안 |
| 완벽한 안전성 | ZooKeeper/etcd 기반 합의 프로토콜 검토 |