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