본문 바로가기
  • Adillete
카테고리 없음

[REDIS] 분산락 실전 학습

by 아딜렛 2026. 6. 11.
Lua Redis Java 분산락 Spring WebFlux

Lua × Redis × Java — 분산락 Lua 스크립트 실전 이해

Lua를 전혀 모르는 상태에서 시작해서 RedisScript.of(...)를 직접 작성하고 이해하는 것까지.

목차
1. Lua 기초 — 딱 필요한 것만
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
Java와 차이점
Java는 블록을 { }로 감싸지만, Lua는 then ... end로 감싼다. 주석은 --로 시작한다.

② return — 즉시 반환

if 조건 then
    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')

④ KEYS와 ARGV — 외부에서 데이터 넘기는 방법 (핵심)

KEYS[1]   -- 첫 번째 키 인자  ※ 인덱스 1부터 시작! (Java는 0부터)
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의 락을 삭제해버림!

▶ Lua로 묶으면 원자적 실행 — 중간에 아무것도 끼어들 수 없다

-- GET과 DEL이 하나의 원자 명령처럼 실행됨
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
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..."true
3
DEL 실행 후 반환
키 삭제 → 1 반환 → Java에서 true

케이스 B — TTL 만료로 키 없음

1
GET 실행
redis.call('get', KEYS[1])nil 반환 (키 없음)
2
UUID 비교
nil == "550e8400..."false
3
else 실행
아무것도 하지 않음 → 0 반환 → Java에서 false

케이스 C — 다른 인스턴스의 락 (UUID 불일치) → 보호됨

1
GET 실행
"B의-uuid-xxxx" 반환
2
UUID 비교
"B의-uuid" != "A의-uuid"false
3
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
);

② execute() — 인자 전달 구조

reactiveRedisTemplate.execute(
    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
Lua 반환값 Java 매핑 의미
1Ltrue내 락 정상 삭제
0Lfalse내 락 아님 또는 만료
nullfalseRedis 오류 등 예외

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);
    }
}

핵심 요약 — 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에 넘겨야 소유권 검증이 성립한다.