여러 서버에서 동시에 같은 자원에 접근할 때, 하나의 서버만 작업하도록 보장하려면 어떻게 해야 할까요?

개념 정의

분산 락 은 여러 프로세스나 서버가 공유 자원에 동시에 접근하는 것을 방지하는 메커니즘입니다. 단일 서버의 synchronizedReentrantLock은 프로세스 내부에서만 동작하므로, 분산 환경에서는 Redis, ZooKeeper 등 외부 시스템을 이용한 락이 필요합니다.

왜 필요한가

  • 재고 차감: 동시에 주문이 들어오면 재고가 음수가 될 수 있습니다
  • 중복 결제 방지: 같은 결제 요청이 여러 서버에서 처리되면 안 됩니다
  • 스케줄러 중복 실행: 여러 인스턴스에서 같은 배치 작업이 동시에 실행되는 것을 방지합니다

기본 구현 — SETNX + TTL

Redis의 SET NX(SET if Not eXists)를 사용한 가장 기본적인 분산 락입니다.

BASH
# 락 획득 — 키가 없을 때만 설정, 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 구현

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 스크립트로 원자성을 보장합니다.

JAVA
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 스크립트가 필요한가

락 해제 시 "내가 설정한 락인지 확인" → "삭제"를 두 단계로 처리하면 문제가 생깁니다.

PLAINTEXT
시간 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 인스턴스에 의존하면 다음 문제가 있습니다.

  1. Single Point of Failure: Redis가 죽으면 락 자체가 불가능
  2. ** 복제 지연 **: Master-Replica 구성에서 Master가 락을 설정한 직후 죽으면, Replica가 승격되었을 때 락이 없을 수 있음
PLAINTEXT
시간 T: 서버A → Master에 락 설정 (성공)
시간 T+1: Master 다운 (아직 Replica에 복제 안 됨)
시간 T+2: Replica가 Master로 승격
시간 T+3: 서버B → 새 Master에 락 설정 (성공!) → 두 서버가 동시에 락 보유

Redlock 알고리즘

Antirez(Redis 개발자)가 제안한 알고리즘으로, N개의 독립된 Redis 인스턴스를 사용하여 안전성을 높입니다.

동작 과정 (N=5 기준)

PLAINTEXT
1. 현재 시간 기록 (T1)
2. 5개 인스턴스 모두에 순서대로 락 획득 시도
   - 각 인스턴스에 짧은 타임아웃 설정 (예: 5~50ms)
3. 과반수(3개) 이상에서 성공하고,
   총 소요 시간(T2-T1)이 TTL보다 짧으면 → 락 획득 성공
4. 실패하면 모든 인스턴스에서 락 해제

유효 잠금 시간 계산

PLAINTEXT
유효 잠금 시간 = TTL - (락 획득에 소요된 시간) - (클럭 드리프트)

예: TTL=10초, 소요시간=2초, 드리프트=0.1초
→ 유효 잠금 시간 = 10 - 2 - 0.1 = 7.9초

의사 코드

PYTHON
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

모든 인스턴스에 락 요청을 보낸 후, 과반수 성공 여부와 소요 시간을 기준으로 최종 판정합니다. 실패하면 부분적으로 획득한 락을 모두 해제합니다.

PYTHON
    # 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에 대해 중요한 비판을 제기했습니다.

핵심 논점: 프로세스 일시 정지

PLAINTEXT
시간 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

PLAINTEXT
1. 락을 획득할 때마다 단조 증가하는 토큰(fencing token)을 발급
2. 공유 자원에 접근할 때 토큰을 함께 전달
3. 자원 측에서 이전보다 낮은 토큰의 요청을 거부

클라이언트A: 토큰 33으로 락 획득
클라이언트B: 토큰 34로 락 획득
클라이언트A: 토큰 33으로 DB 쓰기 시도 → 거부됨 (34 이후만 허용)

Antirez의 반론

  • Redis는 타이밍 가정에 의존하지만, 실제로 GC가 10초 이상 멈추는 일은 극히 드물다
  • Fencing token은 자원 측에서 지원해야 하는데, 모든 시스템이 지원하지는 않는다
  • 완벽한 안전성보다는 실용적인 수준의 보호가 목적이다

Redisson — 프로덕션 레벨 구현

직접 구현하는 것보다 검증된 라이브러리를 사용하는 것이 권장됩니다.

JAVA
// Redisson 설정
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
RedissonClient redisson = Redisson.create(config);

설정이 완료되면 tryLock(waitTime, leaseTime, unit) 메서드로 대기 시간과 자동 해제 시간을 지정하여 락을 획득합니다.

JAVA
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을 연장합니다.

JAVA
// leaseTime을 지정하지 않으면 워치독 활성화 (기본 30초, 10초마다 갱신)
RLock lock = redisson.getLock("order:1001");
lock.lock();  // 워치독이 자동으로 TTL 연장
try {
    // 작업 시간이 길어져도 락이 만료되지 않음
    longRunningTask();
} finally {
    lock.unlock();
}

재진입 락 (Reentrant Lock)

JAVA
RLock lock = redisson.getLock("resource");
lock.lock();  // 획득 횟수: 1
lock.lock();  // 획득 횟수: 2 (같은 스레드)
lock.unlock(); // 획득 횟수: 1
lock.unlock(); // 완전 해제

공정 락 (Fair Lock)

JAVA
RLock fairLock = redisson.getFairLock("resource");
// 요청 순서대로 락을 획득 (FIFO)
fairLock.lock();

Redisson Redlock

JAVA
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();
}

분산 락 사용 시 주의사항

  1. **TTL은 작업 시간보다 충분히 길게 **: 작업이 TTL보다 오래 걸리면 락이 만료됩니다
  2. ** 반드시 finally에서 unlock**: 예외가 발생해도 락이 해제되어야 합니다
  3. ** 락의 범위를 최소화 **: 임계 영역만 락으로 보호하세요
  4. ** 분산 락 ≠ 분산 트랜잭션 **: 락은 동시 접근을 막을 뿐, 원자적 연산을 보장하지 않습니다
  5. ** 완벽한 안전성이 필요하면 **: 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 해제, 단순한 경우 충분
RedlockN/2+1 과반수 합의, 고가용성 필요 시
Redisson워치독, 재진입, 공정 락 지원, 프로덕션 권장
Kleppmann 비판GC 정지 시 안전하지 않음, fencing token 대안
완벽한 안전성ZooKeeper/etcd 기반 합의 프로토콜 검토
댓글 로딩 중...