동시성을 제어하는 방법 - 분산락

분산락 파헤치기

Posted by Yan on February 25, 2025

분산 락 (Distributed Lock)

자바, 스프링 기반의 웹 애플리케이션은 기본적으로 멀티 스레드 환경에서 구동이 되는데,
여러 스레드가 함께 접근할 수 있는 공유 자원에 대해 Race Condition(여러 스레드가 동시에 하나의 공유 자원에 접근할 때 발생하는 문제)이 발생하지 않도록 별도의 처리가 필요하다.

자바의 synchronized, ReentrantLock, AtomicInteger 같은 자바 내부의 Lock 방식이 있지만, 이것들은 같은 프로세스(JVM) 내에서만 상호 배제(Mutual Exclusion)가 보장된다.
멀티 인스턴스 환경(예: 여러 서버에 동일한 애플리케이션을 띄운 상황)에서는 서버마다 각각의 JVM 메모리를 사용하므로, 위 방식으로는 여러 서버에서 공유할 수 있는 Lock을 잡을 수 없다.

이럴 때 분산락이 필요하다.

분산 락을 구현하기 위해 락에 대한 정보를 공통된 저장소(Distributed Lock Manager DLM)에 보관하고 있어야 한다. 그리고 분산 환경에서 여러 대의 서버들은 Lock 상태를 서버들끼리 공유하고, 임계영역(Critical Section)에 접근할 수 있는지 확인할 수 있어야 한다.

이 원자성을 보장하는 분산환경의 공통된 저장소에 필요한 조건은 아래와 같다.

  • 공통된 저장소로서 Lock 상태를 모든 서버가 공유할 수 있게 한다.
  • Race Condition 방지: 여러 서버가 동시에 같은 자원에 접근하지 않도록 Mutex 역할을 한다.
  • 분산 환경에서도 데이터 일관성을 유지하도록 Lock 관리를 담당한다.
  • SPOF(Single Point of Failure)가 생기지 않도록 고가용성 및 Failover를 지원한다.

여러 서버에서 공유할 수 있는 Lock 저장소들

Redis

분산락을 구현할 때 Redis를 사용하는 이유:
고성능, 일관성, RedLock 알고리즘, TTL, Lua 스크립트 등의 이유로 Redis는 분산 락 구현에 매우 적합하기 때문이다.

  1. 빠른 성능과 낮은 지연시간
    • Redis는 메모리 기반 데이터 저장소로, 읽기/쓰기 속도가 매우 빠르다. -> 메모리 기반: 데이터를 디스크(HDD/SSD)가 아닌 주기억장치(RAM)에 저장하고 관리하는 방식
    • 분산 락에서는 락 획득과 해제가 빈번하게 발생하는데, Redis는 이 작업을 초고속으로 처리할 수 있다.
  2. 분산 환경에서의 일관성 보장
    • Redis는 싱글 스레드 모델로 작동하기 때문에 한 번에 하나의 명령어만 실행되고 다른 명령어가 중간에 끼어들지 않는.
    • 이를 통해 Race Condition을 방지하며, 분산 환경에서 일관성을 보장할 수 있다.
  3. RedLock 알고리즘 지원
    • Redis는 RedLock 알고리즘을 통해 분산 락의 안정성을 높인다.
      • 여러 Redis 인스턴스에 동시에 락을 설정하고, 과반수 이상의 인스턴스에서 성공할 경우 락이 획득된 것으로 간주한다. 예를 들어, 5개의 Redis 인스턴스가 있다면 3개 이상에서 락 획득 시 성공으로 판단해서 한두 개의 Redis 인스턴스가 장애가 발생해도 분산 락을 안전하게 유지할 수 있다.
      • 이를 통해 네트워크 분할이나 서버 장애 시에도 안정적인 락 관리가 가능하다. 하나의 Redis가 다운돼도 나머지 인스턴스로 락 관리가 가능하기 때문에 SPOF 문제 해결할 수 있다.
  4. TTL (Time To Live) 설정 가능
    • Redis는 락에 만료 시간(TTL) 을 설정해 데드락(Deadlock) 상황을 방지할 수 있다.
      • 만약 애플리케이션이 락을 해제하지 못하고 종료되더라도, TTL이 만료되면 락이 자동 해제된다.
  5. 분산 환경에서의 편리한 사용
    • Redis는 분산 환경에서 중앙 집중식으로 락을 관리할 수 있다.
      • 분산 시스템의 각 노드가 동일한 Redis 인스턴스를 바라보며 락을 획득하고 해제할 수 있다.
      • 이를 통해 분산된 서버 간의 자원 접근 동기화가 가능하다.
  6. Lua 스크립트 지원
    • Redis는 Lua 스크립트를 지원하여 원자적 작업을 보장한다.
      • 락 획득 및 해제 작업을 하나의 Lua 스크립트로 작성하면, 원자성을 보장받아 Race Condition을 완벽히 방지할 수 있다.
      • 예: SETNXEXPIRE 명령어를 하나의 Lua 스크립트로 묶어서 사용

Redisson으로 분산락 구현하기

Lettuce가 아닌 Redisson을 사용하는 이유:

  • Lettuce의 Lock은 setnx 메서드를 통해 사용자가 직접 스핀락 형태로 구성한다.
    • 스핀 락은 지속적으로 락의 획득을 시도하는 작업이기 때문에 레디스에 계속 락 점유 시도 요청을 보내게 되고 레디스는 이런 트래픽을 처리하느라 부담을 받게 된다.
    • 버전 2.6.12부터 setnx는 deprecated
    • setnx 대체: SET key value NX 형태로 사용하면, 키가 존재하지 않을 때만 값을 설정
  • Lettuce의 경우 만료시간을 제공하고 있지 않기 때문에, Redisson과는 다르게 만약 Lock을 잡고 있는 상태에서 장애가 발생할 경우 다른 서버들에서는 락을 점유할 수 없다.
    • 만약 Lock의 타임아웃이 없다면, lock을 점유한 어플리케이션이 어떤 오류 때문에 종료되었으 경우, 다른 모든 어플리케이션이 락 해제를 기다리면서 영원히 대기상태가 되어서 서비스 장애가 된다.

Redisson 은 pub/sub 기반이다.

  • 만약 락을 얻지 못하면, Redis에 지속적으로 요청을 보내지 않고 “대기” 상태로 들어간다.
  • 락을 가진 클라이언트가 해제하면, pub/sub 메시지로 알림을 보낸다.
  • 대기 중이던 클라이언트는 이 메시지를 받으면 다시 락 획득을 시도한다.
1
2
3
4
// RedissonLock의 tryLock 메소드 : 타임아웃 시간을 명시
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
// waitTime: 락을 획득하기 위한 대기 시간, leaseTime: 락이 만료되는 시간, unit: 시간단
// 락 획득에 성공하면 true 반환

Redisson의 tryLock 메소드 내부 살펴보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    /*
    1. 메서드가 호출된 시점의 현재 시간과 스레드 ID를 가져오기
    */
    long time = unit.toMillis(waitTime);
    long current = System.currentTimeMillis();
    long threadId = Thread.currentThread().getId();

    /*
    2. tryAcquire 메서드를 호출하여 락을 획득하려고 시도 
      락을 획득하면 null을 반환하고, 그렇지 않으면 락의 남은 TTL(Time To Live)을 반환
    */
    Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
        return true;   획득 
    }

    /*
    3. 락 획득에 실패한 경우, 남은 대기 시간을 계산
    */
    time -= System.currentTimeMillis() - current;
    if (time <= 0) {
        acquireFailed(waitTime, unit, threadId);
        return false; // 대기시간 초과되어 락 획득 실패
    }
    
    current = System.currentTimeMillis();

    /*
    4. 락 해제 알림을 받기 위해 Pub/Sub 채널을 구독
    */
    CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
    try {
        subscribeFuture.get(time, TimeUnit.MILLISECONDS);
    } catch (TimeoutException e) {
        if (!subscribeFuture.completeExceptionally(new RedisTimeoutException(
                "Unable to acquire subscription lock after " + time + "ms. " +
                        "Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
            subscribeFuture.whenComplete((res, ex) -> {
                if (ex == null) {
                    unsubscribe(res, threadId);
                }
            });
        }
        acquireFailed(waitTime, unit, threadId); // 구독 중 오류 발생 시 락 획득 실패
        return false;
    } catch (ExecutionException e) {
        LOGGER.error(e.getMessage(), e);
        acquireFailed(waitTime, unit, threadId);
        return false; // 구독 중 오류 발생 시 락 획득 실패
    }

    /*
    5. 남은 시간 동안 주기적으로 락 획득 재시도
      락을 획득하면 true를 반환하고, 대기 시간이 초과되면 false를 반환
    */
    try {
        time -= System.currentTimeMillis() - current;
        if (time <= 0) {
            acquireFailed(waitTime, unit, threadId);
            return false; // 대기 시간이 초과되어 락 획득 실패
        }
    
        while (true) {
            long currentTime = System.currentTimeMillis();
            ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
            // lock acquired
            if (ttl == null) {
                return true; // 락 획득 성공
            }

            time -= System.currentTimeMillis() - currentTime;
            if (time <= 0) {
                acquireFailed(waitTime, unit, threadId);
                return false; // 대기 시간이 초과되어 락 획득 실패
            }

            // waiting for message
            currentTime = System.currentTimeMillis();
            if (ttl >= 0 && ttl < time) {
                commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
            }

            time -= System.currentTimeMillis() - currentTime;
            if (time <= 0) {
                acquireFailed(waitTime, unit, threadId);
                return false; // 대기 시간이 초과되어 락 획득 실패
            }
        }
    } finally {
        unsubscribe(commandExecutor.getNow(subscribeFuture), threadId);
    }
}