Lua
Redis
Java
분산락
Spring WebFlux
Lua × Redis × Java — 분산락 Lua 스크립트 실전 이해
Lua를 전혀 모르는 상태에서 시작해서 RedisScript.of(...)를 직접 작성하고 이해하는 것까지.
목차
1. Lua 기초 — 딱 필요한 것만
2. Redis에서 Lua를 쓰는 이유
3. 내 코드 한 줄씩 해부 (케이스 A/B/C)
4. Java에서 실제로 사용하는 법
5. 전체 완성 코드
2. Redis에서 Lua를 쓰는 이유
3. 내 코드 한 줄씩 해부 (케이스 A/B/C)
4. Java에서 실제로 사용하는 법
5. 전체 완성 코드
1. Lua 기초 — 딱 필요한 것만
Lua는 C로 만들어진 초경량 스크립트 언어다. Redis 내장 스크립트 엔진으로 채택되어 있다. 문법 전체를 배울 필요 없고, Redis Lua에서 쓰는 패턴 4개만 알면 된다.
① 변수와 if/else
-- 변수 선언: local 키워드
local x = "hello"
local n = 42
-- if/else 구조 — 블록을 end로 닫는다 (중괄호 없음)
if x == "hello" then
-- 참일 때 실행
else
-- 거짓일 때 실행
end
local x = "hello"
local n = 42
-- if/else 구조 — 블록을 end로 닫는다 (중괄호 없음)
if x == "hello" then
-- 참일 때 실행
else
-- 거짓일 때 실행
end
Java와 차이점
Java는 블록을 { }로 감싸지만, Lua는 then ... end로 감싼다. 주석은 --로 시작한다.
② return — 즉시 반환
if 조건 then
return 1 -- 즉시 반환, 스크립트 종료
end
return 0 -- 여기까지 오면 0 반환
return 1 -- 즉시 반환, 스크립트 종료
end
return 0 -- 여기까지 오면 0 반환
③ redis.call() — Redis 명령 호출
-- redis.call('Redis명령어', 인자1, 인자2, ...)
redis.call('get', 'mykey') -- GET mykey
redis.call('del', 'mykey') -- DEL mykey → 삭제한 키 수 반환
-- 반환값을 변수에 담을 수도 있다
local val = redis.call('get', 'mykey')
redis.call('get', 'mykey') -- GET mykey
redis.call('del', 'mykey') -- DEL mykey → 삭제한 키 수 반환
-- 반환값을 변수에 담을 수도 있다
local val = redis.call('get', 'mykey')
④ KEYS와 ARGV — 외부에서 데이터 넘기는 방법 (핵심)
KEYS[1] -- 첫 번째 키 인자 ※ 인덱스 1부터 시작! (Java는 0부터)
KEYS[2] -- 두 번째 키 인자
ARGV[1] -- 첫 번째 값 인자
ARGV[2] -- 두 번째 값 인자
-- 관례: 키(Redis key 이름)는 KEYS에, 나머지 값은 ARGV에
KEYS[2] -- 두 번째 키 인자
ARGV[1] -- 첫 번째 값 인자
ARGV[2] -- 두 번째 값 인자
-- 관례: 키(Redis key 이름)는 KEYS에, 나머지 값은 ARGV에
⚠ 주의 — 인덱스가 1부터 시작
Java 배열은 arr[0]부터 시작하지만 Lua는 KEYS[1]부터 시작한다. KEYS[0]은 nil(null)이다.
2. Redis에서 Lua를 쓰는 이유 — 원자성
Redis는 싱글 스레드로 명령을 처리한다. 그런데 여러 명령을 따로따로 보내면 그 사이에 다른 클라이언트의 명령이 끼어든다.
▶ GET과 DEL 사이에 타이밍 문제 발생
// A 인스턴스 실행 흐름
1. GET "outbox:lock:1:20260611:uuid-1234"
→ "my-uuid" 반환
← 여기서 TTL 만료! B 인스턴스가 같은 키로 락 획득 →
2. DEL "outbox:lock:1:20260611:uuid-1234"
→ B의 락을 삭제해버림!
1. GET "outbox:lock:1:20260611:uuid-1234"
→ "my-uuid" 반환
← 여기서 TTL 만료! B 인스턴스가 같은 키로 락 획득 →
2. DEL "outbox:lock:1:20260611:uuid-1234"
→ B의 락을 삭제해버림!
▶ Lua로 묶으면 원자적 실행 — 중간에 아무것도 끼어들 수 없다
-- GET과 DEL이 하나의 원자 명령처럼 실행됨
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
| 방식 | GET → DEL 사이 | 안전성 |
|---|---|---|
| 명령 2개 따로 | 다른 명령 끼어들 수 있음 | 위험 |
| Lua 스크립트 | 완전히 차단됨 | 안전 |
| MULTI/EXEC | 조건 분기 불가 | 제한적 |
왜 MULTI/EXEC가 아닌가
Redis 트랜잭션(MULTI/EXEC)은 명령을 큐에 쌓고 한 번에 실행하는데, 조건 분기(if/else)가 불가능하다. "내 UUID인지 확인하고 맞으면만 삭제"는 Lua로만 구현 가능하다.
3. 내 코드 한 줄씩 해부
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
return redis.call('del', KEYS[1])
else
return 0
end
| Lua 변수 | 실제 값 (예시) | 어디서 오는가 |
|---|---|---|
KEYS[1] |
"outbox:lock:1:20260611:uuid-1234" | Java에서 List.of(lockKey)로 전달 |
ARGV[1] |
"550e8400-e29b-41d4..." | Java에서 List.of(lockValue)로 전달. tryLock 시 저장한 UUID |
케이스 A — 내 락이 맞고 정상 해제
1
GET 실행
redis.call('get', KEYS[1]) → "550e8400..." 반환2
UUID 비교
"550e8400..." == "550e8400..." → true3
DEL 실행 후 반환
키 삭제 → 1 반환 → Java에서
true케이스 B — TTL 만료로 키 없음
1
GET 실행
redis.call('get', KEYS[1]) → nil 반환 (키 없음)2
UUID 비교
nil == "550e8400..." → false3
else 실행
아무것도 하지 않음 → 0 반환 → Java에서
false케이스 C — 다른 인스턴스의 락 (UUID 불일치) → 보호됨
1
GET 실행
→
"B의-uuid-xxxx" 반환2
UUID 비교
"B의-uuid" != "A의-uuid" → false3
else 실행
B의 락 건드리지 않음 → 0 반환 → B의 락 안전하게 보호됨 ✓
4. Java에서 실제로 사용하는 법
① RedisScript 객체 생성
// static final로 선언 → SHA1 해시를 한 번만 계산, 재사용
private static final RedisScript<Long> RELEASE_SCRIPT = RedisScript.of(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end",
Long.class // DEL은 삭제한 키 수(정수) 반환 → Long
);
private static final RedisScript<Long> RELEASE_SCRIPT = RedisScript.of(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end",
Long.class // DEL은 삭제한 키 수(정수) 반환 → Long
);
② execute() — 인자 전달 구조
reactiveRedisTemplate.execute(
RELEASE_SCRIPT,
List.of(lockKey), // → 스크립트 안에서 KEYS[1]
List.of(lockValue) // → 스크립트 안에서 ARGV[1]
)
RELEASE_SCRIPT,
List.of(lockKey), // → 스크립트 안에서 KEYS[1]
List.of(lockValue) // → 스크립트 안에서 ARGV[1]
)
③ 반환값 처리 — Flux → Boolean
reactiveRedisTemplate.execute(RELEASE_SCRIPT, List.of(lockKey), List.of(lockValue))
.next() // Flux<Long> → Mono<Long>
.map(result -> result != null && result > 0) // 1 → true, 0 → false
.defaultIfEmpty(false) // 아무것도 없으면 false
.next() // Flux<Long> → Mono<Long>
.map(result -> result != null && result > 0) // 1 → true, 0 → false
.defaultIfEmpty(false) // 아무것도 없으면 false
| Lua 반환값 | Java 매핑 | 의미 |
|---|---|---|
| 1L | true | 내 락 정상 삭제 |
| 0L | false | 내 락 아님 또는 만료 |
| null | false | Redis 오류 등 예외 |
5. 전체 완성 코드
@Slf4j
@Service
@RequiredArgsConstructor
public class DistributedLockService {
private final ReactiveRedisTemplate<String, String> reactiveRedisTemplate;
private static final Duration LOCK_TTL = Duration.ofSeconds(30);
private static final String LOCK_PREFIX = "outbox:lock:";
// KEYS[1] = lockKey, ARGV[1] = lockValue(UUID)
// GET으로 확인 → 내 UUID면 DEL+return 1, 아니면 return 0
private static final RedisScript<Long> RELEASE_SCRIPT = RedisScript.of(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end", Long.class
);
private String buildLockKey(String userId, String eventId) {
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
return LOCK_PREFIX + userId + ":" + today + ":" + eventId;
}
// 락 획득 — UUID를 value로 저장, 반환된 UUID가 소유권 증명
public Mono<String> tryLock(String userId, String eventId) {
String lockKey = buildLockKey(userId, eventId);
String lockValue = UUID.randomUUID().toString();
return reactiveRedisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, LOCK_TTL)
.map(acquired -> acquired ? lockValue : null);
}
// 락 해제 — Lua로 원자적 소유권 확인 후 삭제
public Mono<Boolean> releaseLock(String userId, String eventId, String lockValue) {
String lockKey = buildLockKey(userId, eventId);
return reactiveRedisTemplate.execute(
RELEASE_SCRIPT,
List.of(lockKey), // KEYS[1]
List.of(lockValue) // ARGV[1]
).next()
.map(r -> r != null && r > 0)
.defaultIfEmpty(false);
}
}
@Service
@RequiredArgsConstructor
public class DistributedLockService {
private final ReactiveRedisTemplate<String, String> reactiveRedisTemplate;
private static final Duration LOCK_TTL = Duration.ofSeconds(30);
private static final String LOCK_PREFIX = "outbox:lock:";
// KEYS[1] = lockKey, ARGV[1] = lockValue(UUID)
// GET으로 확인 → 내 UUID면 DEL+return 1, 아니면 return 0
private static final RedisScript<Long> RELEASE_SCRIPT = RedisScript.of(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end", Long.class
);
private String buildLockKey(String userId, String eventId) {
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
return LOCK_PREFIX + userId + ":" + today + ":" + eventId;
}
// 락 획득 — UUID를 value로 저장, 반환된 UUID가 소유권 증명
public Mono<String> tryLock(String userId, String eventId) {
String lockKey = buildLockKey(userId, eventId);
String lockValue = UUID.randomUUID().toString();
return reactiveRedisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, LOCK_TTL)
.map(acquired -> acquired ? lockValue : null);
}
// 락 해제 — Lua로 원자적 소유권 확인 후 삭제
public Mono<Boolean> releaseLock(String userId, String eventId, String lockValue) {
String lockKey = buildLockKey(userId, eventId);
return reactiveRedisTemplate.execute(
RELEASE_SCRIPT,
List.of(lockKey), // KEYS[1]
List.of(lockValue) // ARGV[1]
).next()
.map(r -> r != null && r > 0)
.defaultIfEmpty(false);
}
}
핵심 요약 — 5줄
→Lua 스크립트 안의 Redis 명령들은 원자적으로 실행된다. 중간에 아무것도 끼어들 수 없다.
→
KEYS[1]은 락 키 이름, ARGV[1]은 비교할 UUID. Java에서 각각 List.of(key), List.of(value)로 전달.→GET으로 저장된 UUID와 ARGV[1] 비교 → 내 락이면 DEL, 아니면 0 반환.
→일치하면 1 반환 → Java
true, 불일치 0 반환 → Java false.→
tryLock이 저장한 UUID를 Java 체인에서 들고 있다가 releaseLock에 넘겨야 소유권 검증이 성립한다.