분산 락 — 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);
}
// 락 해제 — 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
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);
// 기본 락
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 기반의 합의 프로토콜을 고려하세요
정리
- 단순한 경우 SETNX + TTL + Lua 해제로 충분합니다
- 고가용성이 필요하면 Redlock (N/2+1 과반수 합의)을 고려합니다
- 프로덕션에서는 Redisson 같은 검증된 라이브러리를 사용하는 것이 좋습니다
- Kleppmann의 비판처럼 GC 정지 등의 극단적 상황에서 Redlock도 안전하지 않을 수 있으므로, 정말 중요한 경우에는 fencing token이나 합의 기반 시스템을 검토하세요
댓글 로딩 중...