분산락 = "내가 작업 중이니 다른 서버는 손대지 마라"
락 획득: SET lock:order "내꺼" NX PX 3000 (3초 유효)
락 해제: 작업 끝나면 lock:order 삭제
문제 상황: GET → DEL로 해제할 때
// ❌ 잘못된 분산락 해제 코드
public void releaseLock(String key) {
String value = redisTemplate.opsForValue().get(key); // ① GET
if (value != null) {
redisTemplate.delete(key); // ② DEL
}
}
타임라인으로 보면
Server A (나) Server B (다른 서버)
─────────────────────────────────────────────────────
① 락 획득
SET lock "A소유" NX PX 3000
② 작업 수행 중...
(3초 넘어버림!)
③ 락 만료됨 (TTL 0)
④ Server B가 락 획득
SET lock "B소유" NX PX 3000
⑤ Server A: GET lock
→ "B소유" 반환
(내 꺼인지 확인 안 함!)
⑥ Server A: DEL lock ← B의 락을 삭제해버림!!
⑦ Server B는 락이 있다고 생각하고
작업 중인데 락이 사라짐
⑧ Server C가 락 획득 가능
→ B, C 동시 작업 → 데이터 꼬임!
핵심 문제: GET과 DEL 사이의 틈
GET lock → "B소유" 확인
↑ 여기서 시간이 흐름 (아주 짧아도!)
DEL lock → 삭제
이 사이에 상황이 바뀔 수 있음
→ 원자적이지 않음
→ "내 락인지 확인"과 "삭제" 가 분리되어 있음
올바른 해결: Lua Script로 원자적 처리
핵심 아이디어:
락을 걸 때 "내 고유 토큰(UUID)"을 값으로 저장
해제할 때 "내 토큰인지 확인 + 삭제"를 원자적으로 실행
// 락 획득 시
String myToken = UUID.randomUUID().toString(); // "abc-123-xyz"
redisTemplate.opsForValue()
.setIfAbsent("lock:order", myToken, Duration.ofSeconds(3));
// ────────────────────────────────────────────
// 락 해제 시 (Lua Script)
String script =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('DEL', KEYS[1]) " + // 내 토큰일 때만 삭제
"else " +
" return 0 " + // 남의 토큰이면 무시
"end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of("lock:order"),
myToken // 내 토큰 전달
);
Lua Script로 해결되는 이유
Server A (나) Server B (다른 서버)
─────────────────────────────────────────────────────
① 락 획득
SET lock "token-A" NX PX 3000
② 작업 수행 중...
(3초 넘어버림!)
③ 락 만료
④ Server B 락 획득
SET lock "token-B" NX PX 3000
⑤ Server A: Lua 실행 (원자적!)
GET lock → "token-B"
"token-B" == "token-A" ?
→ NO → DEL 실행 안 함! ✅
⑥ Server B 락 유지됨 ✅
안전하게 작업 완료
한 줄 요약
┌─────────────────────────────────────────────────────────┐
│ │
│ GET → DEL : "확인"과 "삭제" 사이에 틈 존재 │
│ → 남의 락을 지울 수 있음 │
│ │
│ Lua Script: "내 토큰 확인 + 삭제"가 하나의 원자 단위 │
│ → 틈이 없음 → 내 락만 삭제 보장 │
│ │
└─────────────────────────────────────────────────────────┘
아주 쉽게 설명
화장실 칸 비유로 이해하기
분산락 = 화장실 칸 잠금장치
문제 상황
① A씨가 화장실 들어가서 문 잠금 (락 획득)
문에 "A사용중" 이라고 붙여놓음
② A씨가 너무 오래 있음
→ 자동으로 잠금 해제됨 (TTL 만료)
③ B씨가 들어와서 문 잠금
문에 "B사용중" 으로 바꿔 붙임
④ A씨가 뒤늦게 나오면서
문에 붙은 거 확인 → "사용중이네"
그냥 떼버림 ← 이게 DEL!!
(B꺼인데 A가 떼버린 것!)
⑤ B씨는 아직 안에 있는데
문이 잠금 해제된 상태
→ C씨도 들어올 수 있음
→ B, C 동시에 사용 → 대참사!
GET → DEL이 왜 문제인가
A씨의 잘못된 행동:
GET → "누가 사용중인지 확인" "B사용중"
↕
(이 사이에 상황 바뀔 수 있음)
DEL → "그냥 삭제" B꺼인데 삭제!
= "내 것인지 확인" 과 "삭제" 가 분리되어 있음
올바른 방법
락 걸 때:
그냥 "사용중" X
"A-고유번호-사용중" 으로 붙임 ← UUID 토큰
락 해제할 때 (Lua Script):
"붙어있는 게 내 번호가 맞으면 → 제거"
"남의 번호면 → 손대지 않음"
이 두 동작을 동시에 (원자적으로) 실행
④ A씨가 나오면서
문 확인 → "B-번호-사용중"
"어? 내 번호 아니네" → 그냥 나감 ✅
B씨 락 유지됨 ✅
한 줄 요약
┌──────────────────────────────────────────────────┐
│ │
│ GET → DEL │
│ = 문에 붙은 게 내 꺼인지 확인도 안 하고 떼버림 │
│ │
│ Lua Script │
│ = "내 번호 맞으면 뗌, 아니면 손 안 댐"을 │
│ 한 동작으로 처리 │
│ │
└──────────────────────────────────────────────────┘
분산락 Java 예시 코드
전체 코드
@Service
public class DistributedLockService {
private final StringRedisTemplate redisTemplate;
// ============================================
// ❌ 잘못된 방법: GET → DEL
// ============================================
public void wrongReleaseLock(String key) {
String value = redisTemplate.opsForValue().get(key); // ① GET
if (value != null) {
redisTemplate.delete(key); // ② DEL (남의 락도 삭제 가능!)
}
}
// ============================================
// ✅ 올바른 방법: Lua Script
// ============================================
private static final String RELEASE_LOCK_SCRIPT =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('DEL', KEYS[1]) " + // 내 토큰이면 삭제
"else " +
" return 0 " + // 남의 토큰이면 무시
"end";
public boolean acquireLock(String key, String token, long ttlSeconds) {
// SET key token NX EX ttl (원자적 획득)
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, token, Duration.ofSeconds(ttlSeconds));
return Boolean.TRUE.equals(result);
}
public boolean releaseLock(String key, String token) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(RELEASE_LOCK_SCRIPT, Long.class),
List.of(key),
token // 내 고유 토큰 전달
);
return Long.valueOf(1L).equals(result);
}
}
실제 사용 예시
@Service
public class OrderService {
private final DistributedLockService lockService;
private final OrderRepository orderRepository;
public void processOrder(Long orderId) {
String lockKey = "lock:order:" + orderId;
String myToken = UUID.randomUUID().toString(); // 내 고유 토큰
// ① 락 획득 시도
boolean acquired = lockService.acquireLock(lockKey, myToken, 30);
if (!acquired) {
throw new RuntimeException("다른 서버에서 처리 중입니다.");
}
try {
// ② 비즈니스 로직 실행
orderRepository.process(orderId);
} finally {
// ③ 반드시 finally에서 락 해제
boolean released = lockService.releaseLock(lockKey, myToken);
if (!released) {
// 내 토큰이 아님 = TTL 만료 후 다른 서버가 락 가져간 상태
log.warn("락이 이미 만료됨. orderId: {}", orderId);
}
}
}
}
시나리오별 동작 확인
@SpringBootTest
class DistributedLockTest {
@Autowired DistributedLockService lockService;
@Autowired StringRedisTemplate redisTemplate;
@Test
@DisplayName("❌ 잘못된 방법: 남의 락을 삭제하는 버그 재현")
void wrongRelease_deletesOthersLock() {
String key = "lock:test";
// Server A가 락 획득
redisTemplate.opsForValue()
.set(key, "token-A", Duration.ofSeconds(30));
// (TTL 만료 후) Server B가 락 획득
redisTemplate.opsForValue()
.set(key, "token-B", Duration.ofSeconds(30));
// Server A가 잘못된 방식으로 해제 시도
lockService.wrongReleaseLock(key);
// token-B인데 삭제됨! → null
String remaining = redisTemplate.opsForValue().get(key);
System.out.println("남은 락: " + remaining); // null ← B의 락이 삭제됨!!
}
@Test
@DisplayName("✅ 올바른 방법: 남의 락은 건드리지 않음")
void correctRelease_doesNotDeleteOthersLock() {
String key = "lock:test";
// Server A가 락 획득
String tokenA = UUID.randomUUID().toString();
redisTemplate.opsForValue()
.set(key, tokenA, Duration.ofSeconds(30));
// (TTL 만료 후) Server B가 락 획득
String tokenB = UUID.randomUUID().toString();
redisTemplate.opsForValue()
.set(key, tokenB, Duration.ofSeconds(30));
// Server A가 올바른 방식으로 해제 시도 (tokenA로 시도)
boolean released = lockService.releaseLock(key, tokenA);
System.out.println("해제 성공 여부: " + released); // false ← 내 토큰 아님
// token-B 그대로 유지됨!
String remaining = redisTemplate.opsForValue().get(key);
System.out.println("남은 락: " + remaining); // token-B ← 유지됨 ✅
}
@Test
@DisplayName("✅ 내 토큰이면 정상 해제")
void correctRelease_deletesOwnLock() {
String key = "lock:test";
String myToken = UUID.randomUUID().toString();
// 내가 락 획득
lockService.acquireLock(key, myToken, 30);
// 내 토큰으로 해제
boolean released = lockService.releaseLock(key, myToken);
System.out.println("해제 성공 여부: " + released); // true ✅
String remaining = redisTemplate.opsForValue().get(key);
System.out.println("남은 락: " + remaining); // null (정상 해제)
}
}
실행 결과 비교
❌ 잘못된 방법 실행 결과:
남은 락: null ← token-B였는데 삭제됨! (버그!)
✅ 올바른 방법 실행 결과:
해제 성공 여부: false ← 내 토큰 아님, 건드리지 않음
남은 락: token-B ← B의 락 유지됨 ✅
✅ 내 토큰 해제 실행 결과:
해제 성공 여부: true ← 정상 해제
남은 락: null ← 깔끔하게 삭제 ✅
핵심 요약
┌─────────────────────────────────────────────────────────┐
│ │
│ ❌ GET → DEL │
│ value = redis.get(key) // "token-B" │
│ redis.delete(key) // token-B든 뭐든 그냥 삭제 │
│ │
│ ✅ Lua Script │
│ GET key → token-B │
│ token-B == token-A? → NO → 삭제 안 함 │
│ (확인 + 삭제가 원자적으로 실행) │
│ │
└─────────────────────────────────────────────────────────┘
정확히 이해하셨습니다!
정확한 시나리오
Server A 코드:
String value = redis.get(key); // ① GET
if (value != null) {
redis.delete(key); // ② DEL
}
타임라인:
Server A Server B
────────────────────────────────────────────────────────
① redis.get("lock:order")
→ "token-A" 반환 (아직 살아있음)
[GET과 DEL 사이 찰나의 순간]
↓
TTL 만료! (lock:order 키 소멸)
② redis.setIfAbsent("lock:order", "token-B")
→ 성공! (키 없으니까)
→ B가 락 획득
③ redis.delete("lock:order") ← value != null 조건 통과했으니 실행!
→ token-B 를 삭제해버림!!
④ B는 락 갖고 있다고 생각하고
작업 중인데 락이 사라진 상태
⑤ Server C도 락 획득 가능
→ B, C 동시 작업 → 💥
핵심 포인트
value != null 체크는 "GET 시점"의 상태
GET 시점: key 존재 → value = "token-A" (null 아님)
DEL 시점: key = "token-B" (이미 B 꺼!)
→ null 체크가 의미 없어짐
→ GET과 DEL 사이에 세상이 바뀜
"찰나의 순간"이 실제로 발생하는 이유
Server A 코드 실행 흐름:
redis.get(key)
│
│ ← 이 사이에:
│ 1. 네트워크 응답 대기 (수 ms)
│ 2. JVM 코드 실행
│ 3. if 조건 평가
│ (수십 ms도 가능)
│
redis.delete(key)
TTL이 짧거나 (예: 100ms)
서버 부하가 높으면
충분히 발생 가능한 시나리오
Lua Script가 해결하는 이유
-- 이 전체가 하나의 원자 단위
-- GET과 DEL 사이에 아무것도 끼어들 수 없음
if redis.call('GET', KEYS[1]) == ARGV[1] then
-- GET 결과가 내 토큰과 같을 때만 DEL
return redis.call('DEL', KEYS[1])
else
return 0
end
Lua 실행 중:
redis.call('GET', key) → "token-B"
↑
이 사이에 아무것도 못 들어옴
(Redis 단일 스레드 보장)
redis.call('DEL', key) → "token-B" == "token-A" ? NO → DEL 안 함
정리
┌─────────────────────────────────────────────────────┐
│ │
│ 질문하신 것: GET 후 만료 → 다른 스레드 락 획득 │
│ → DEL로 남의 락 삭제 │
│ │
│ 답: 네, 정확합니다 ✅ │
│ │
│ GET → DEL: 두 명령 사이에 "시간의 틈" 존재 │
│ 그 틈에서 세상이 바뀔 수 있음 │
│ │
│ Lua Script: GET + 비교 + DEL을 틈 없이 실행 │
│ = 세상이 바뀔 틈 자체를 없앰 │
│ │
└─────────────────────────────────────────────────────┘