
분산락의 구현 방법론 중 가장 많이 사용되는 Redis를 통해 분산락을 구현하는 방법에 대해 알아보겠습니다.
Redis는 기본적으로 인메모리 DB이기 때문에 매우 빠릅니다. 컴퓨터 구조상 멀리에 위치해있는 하드디스크보다 바로 옆에 위치해있고 I/O 비용 없이 CPU가 바로 접근을 할 수 있기 때문이죠.
기본적으로 분산락의 조건은 다음과 같습니다.
이 조건들을 모두 만족하는 것, 그것이 바로 Redis이지요!

Redis에서 분산락을 사용하기 위해선 SET NX EX명령어를 사용해야 합니다. 이 명령어로 키가 없을 때만 값을 설정하고, 자동 만료 시간도 함께 지정할 수 있습니다.
명령어의 구조는 아래와 같습니다.
SET key value NX EX seconds
│ │ │ │ │ └── 만료 시간 (초 단위)
│ │ │ │ └── EX = Expire (만료 시간 설정)
│ │ │ └── NX = Not eXists (키가 없을 때만)
│ │ └── 저장할 값 (보통 UUID)
│ └── 키 이름 (락 식별자)
└── Redis SET 명령
그럼 왜 이 명령어를 사용해야 할까요?
이는 다음 두 가지 조건을 동시에, 원자적으로 만족해야 하기 때문입니다.
| 조건 | 이유 | 해결 옵션 |
|---|---|---|
| 1. 키가 없을 때만 설정 | 이미 락이 있으면 획득하면 안 됨 | NX |
| 2. 자동 만료 설정 | 서버 장애 시 락이 영원히 남으면 안 됨 | EX |

여기서 원자적이라는 말의 의미는 무엇일까요? 그것은 더 이상 쪼갤 수 없음을 뜻합니다.
즉, SET NX EX 명령은 존재확인 + 값 설정 + 만료시간 설정이 모두 하나의 동작으로 실행되게 됩니다.
그렇기 때문에 다른 명령들이 중간에 끼어들 수 없게되어 안전해집니다.
해당 명령어의 기본 문법은 아래와 같습니다.
# 기본 문법
SET key value NX EX seconds
# NX: 키가 존재하지 않을 때만 설정 (Not eXists)
# EX: 만료 시간(초) 설정
# 예시: user:123:point:lock 키를 30초 TTL로 설정
SET user:123:point:lock "server-a-uuid" NX EX 30
# 응답:
# - OK: 락 획득 성공 (키가 없었음)
# - nil: 락 획득 실패 (키가 이미 존재)
여기서 각 부분들의 역할을 자세히 보겠습니다.
| 부분 | 값 예시 | 역할 |
|---|---|---|
key | user:123:point:lock | 락을 식별하는 이름. 같은 키로 요청하면 같은 락을 경쟁 |
value | uuid-a1b2c3... | 락 소유자 식별. 나중에 "내가 획득한 락인지" 확인할 때 사용 |
NX | - | Not eXists. 키가 없을 때만 설정. 이미 있으면 실패 |
EX | 30 | EXpire. 30초 후 자동 삭제. 서버 장애 시 안전장치 |
다음은 락 획득 과정을 도식화한 자료입니다. 핵심원리는 Redis의 SET NX 명령이 원자적으로 동작한다는 것입니다.
이는 키가 없는지 확인하고 값을 설정하는 두 동작이 끊기지 않고 이어진다는 것을 의미하며 덕분에 두 서버가 동시에 같은 락을 획득하려해도 반드시 하나만 성공하게 됩니다.

위 도식화 자료를 순서대로 보자면 아래와 같습니다.
SET user:123:lock "uuid-a" NX EX 30 명령을 수행합니다.user:123:lock키가 없으므로 값을 설정하고 OK(락획득)을 반환합니다.NX조건을 만족할 수가 없어 아무것도 설정하지 않고 nil을 반환합니다.락 획득 못지 않게 락 해제도 정말 중요합니다. 사용한 자원을 당연히 잘 반납해야하니까요!
하지만 해제를 하다가 남의 락(다른 서버의 락)을 해제하면 안되겠죠. 그러니 자신이 획득한 락만을 해제해야 합니다.
# 단순 DELETE는 위험!
DEL user:123:point:lock
위와 같이 단순히 DEL 명령으로 락을 삭제하면 내가 획득한 락이 아닌 다른 서버의 락을 삭제해버릴 수도 있습니다.
이 상황은 락의 TTL이 지나 다른 서버가 락을 획득한 후 발생하곤 합니다.
문제상황을 도식화해보죠.

| 시간 | 서버 A | 서버 B | 서버 C | Redis 상태 |
|---|---|---|---|---|
| T1 | 락 획득 (uuid-a) | - | - | 🔒 uuid-a |
| T2 | 작업 중... (오래 걸림) | - | - | 🔒 uuid-a |
| T2.5 | (TTL 만료) | - | - | 🔓 락 없음 |
| T3 | 작업 중... | 락 획득 (uuid-b) | - | 🔒 uuid-b |
| T4 | DEL lock 실행! | 작업 중... | - | 🔓 락 없음 |
| T5 | - | 작업 중... | 락 획득 (uuid-c) | 🔒 uuid-c |
| T6 | - | 작업 중! | 작업 중! | 💥 동시 접근! |
서버A는 자신이 가진 자신의락(uuid-a)를 삭제하려했지만 실제로는 서버B의 락(uuid-b)를 삭제해버렸습니다.
그 결과로 서버B와 서버C가 동시에 같은 자원에 접근해버리는 현상이 나타나게 되었지요.
이를 해결하기 위해선 원자성을 보장하는 방식으로 해결을 해야 합니다. 즉, 올바른 락 해제를 위해서 Lua 스크립트를 사용해야 한다는 말이죠.
단순히 Java 코드로 값을 확인하고 삭제를 하게 되면 다음과 같은 문제들이 생기게 됩니다.
// ❌ 위험한 방식 - 원자적이지 않음!
String currentValue = redisTemplate.opsForValue().get(lockKey); // 1단계: 값 확인
if (lockValue.equals(currentValue)) {
redisTemplate.delete(lockKey); // 2단계: 삭제
}
| 시간 | 서버 A | 서버 B | Redis 상태 |
|---|---|---|---|
| T1 | GET lock → "uuid-a" ✓ | - | 🔒 uuid-a |
| T2 | (값 일치 확인 완료) | - | 🔒 uuid-a |
| T3 | (아직 DEL 실행 전...) | (TTL 만료) | 🔓 락 없음 |
| T4 | - | SET lock "uuid-b" | 🔒 uuid-b |
| T5 | DEL lock 실행! | - | 🔓 락 없음 💥 uuid-b 삭제됨! |
결국 Lua 스크립트를 활용하게 되면 위의 작업들이 하나의 원자적 실행으로 묶이게 되어 하나의 명령처럼 동작하게 되기 때문이죠.

Lua 스크립트는 다음과 같이 작성됩니다.
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
| 구성 요소 | 설명 | 예시 |
|---|---|---|
KEYS[1] | 락의 키 이름 (첫 번째 키 인자) | "user:123:point:lock" |
ARGV[1] | 비교할 값 (첫 번째 일반 인자) | "uuid-a-1234-5678" |
redis.call("get", ...) | Redis GET 명령 실행 | 현재 저장된 값 조회 |
redis.call("del", ...) | Redis DEL 명령 실행 | 키 삭제 후 1 반환 |
return 0 | 값 불일치 시 | 삭제하지 않음 |
Lua 스크립트를 사용하면 원자성 뿐만 아니라 네트워크 왕복도 줄여주게되죠. 왜냐하면 한 번에 전부 다 실행되니까요!
Java에서 Lua스크립트를 활용해 안전하게 락을 해제 하는 예제는 다음과 같습니다.
@Component
public class RedisLockManager {
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
@Autowired
private StringRedisTemplate redisTemplate;
public boolean unlock(String lockKey, String lockValue) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(UNLOCK_SCRIPT);
script.setResultType(Long.class);
Long result = redisTemplate.execute(
script,
Collections.singletonList(lockKey),
lockValue
);
return result != null && result == 1L;
}
}
다음은 분산락의 핵심 개념 중 하나인 TTL에 대해 알아보겠습니다.
TTL은 데드락을 방지하는 핵심 메커니즘입니다. 락이 설정된 후 일정 시간이 지나면 자동으로 삭제되도록 하는 기능입니다. 이는 서버 장애 상황시에 락이 영원히 남아있는 것을 방지하는 역할을 하죠.

갑작스러운 서버 다운으로 인하여 서버 락이 영원히 Redis에 남아있게 된다면? 그 락 때문에 해당 자원은 영원히 서비스를 제공할 수 없게 되는거죠.
만약 TTL이 설정되어있다면 어떻게 될까요?

만약 서버가 다운이 된다고 해도 Redis는 자동으로 TTL 설정 시간(예 : 30초) 후에 자동으로 락을 삭제하게 됩니다.
이후 다른 서버들이 락을 획득할 수 있게 되어 시스템이 자동으로 복구되게 됩니다.
| 상황 | TTL 없음 | TTL 있음 |
|---|---|---|
| 정상 동작 | ✅ 문제없음 | ✅ 문제없음 |
| 서버 장애 시 | ❌ 락 영구 유지 → 서비스 중단 | ✅ TTL 후 자동 해제 → 자동 복구 |
| 네트워크 단절 시 | ❌ 락 해제 불가 | ✅ TTL 후 자동 해제 |
| 애플리케이션 버그 | ❌ unlock() 누락 시 영구 락 | ✅ TTL이 최후의 안전망 역할 |
TTL 설정 가이드 라인은 아래와 같습니다.
/**
* TTL 설정 기준
*
* 기본 공식: 예상 처리 시간 × 2~3 + 여유 시간
*
* 예시:
* - 단순 포인트 차감: 1초 예상 → TTL 5초
* - DB 트랜잭션 포함: 3초 예상 → TTL 10초
* - 외부 API 호출 포함: 10초 예상 → TTL 30초
*/
public class LockTimeConstants {
public static final long QUICK_OPERATION = 5; // 5초
public static final long STANDARD_OPERATION = 10; // 10초
public static final long LONG_OPERATION = 30; // 30초
public static final long EXTENDED_OPERATION = 60; // 60초
}
이번에는 Redis를 활용한 분산락에 대해 간단하게 알아보았습니다. 핵심은 Redis 활용 시에도 락 획득을 위해 SET key value NX EX 30의 방식을 사용하여 키의 정합성과 TTL을 설정하였고, Lua 스크립트를 활용해 원자적으로 키 값을 비교하여 자신만의 락을 삭제하도록 설정할 수 있었습니다.
다음에는 Spring Boot 환경에서의 분산락 구현 방법에 대해 스터디 해본 내용을 남기도록 하겠습니다.
감사합니다 🫡