Lua 스크립팅과 Redis Function — 서버사이드 원자적 연산
여러 Redis 명령을 조건에 따라 원자적으로 실행해야 할 때, MULTI/EXEC만으로는 부족합니다. 중간 결과를 보고 분기할 수 있는 방법은 없을까요?
Lua 스크립팅이란
Redis는 Lua 스크립트를 서버사이드에서 실행하는 기능을 제공합니다. 스크립트는 Redis의 싱글 스레드 실행 모델 안에서 원자적으로 실행되므로, 여러 명령을 조건 분기와 함께 안전하게 처리할 수 있습니다. MULTI/EXEC로는 불가능한 "읽은 값에 따라 다른 명령 실행"이 가능합니다.
왜 필요한가
MULTI/EXEC의 한계
원하는 동작:
1. balance 읽기
2. balance >= 100이면 차감, 아니면 실패
MULTI/EXEC로는 불가능:
WATCH balance
GET balance → "150" (OK)
MULTI
DECRBY balance 100 # 여기서 잔액 확인을 "다시" 할 수 없음
EXEC
Lua로 해결
-- 잔액 확인 후 조건부 차감
local balance = tonumber(redis.call('GET', KEYS[1]))
if balance >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1 -- 성공
else
return 0 -- 잔액 부족
end
EVAL — Lua 스크립트 실행
기본 문법
EVAL <script> <numkeys> [key1 key2 ...] [arg1 arg2 ...]
script: Lua 코드numkeys: KEYS 배열의 길이key1, key2...: KEYS[1], KEYS[2]로 접근arg1, arg2...: ARGV[1], ARGV[2]로 접근
기본 예제
# Hello World
127.0.0.1:6379> EVAL "return 'Hello Redis Lua'" 0
"Hello Redis Lua"
# KEYS와 ARGV 사용
127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue
OK
127.0.0.1:6379> EVAL "return redis.call('GET', KEYS[1])" 1 mykey
"myvalue"
KEYS와 ARGV를 분리하는 이유
클러스터 환경에서 Redis가 스크립트를 올바른 노드로 라우팅하려면 접근할 키를 미리 알아야 합니다. KEYS에 명시한 키만 접근해야 합니다.
-- 좋은 예: KEYS를 통해 접근
redis.call('GET', KEYS[1])
-- 나쁜 예: 하드코딩 (클러스터에서 문제)
redis.call('GET', 'hardcoded:key')
redis.call vs redis.pcall
-- redis.call: 에러 시 스크립트 즉시 중단
local value = redis.call('GET', KEYS[1])
-- GET이 실패하면 여기서 멈추고 에러 반환
-- redis.pcall: 에러 시 에러 테이블 반환 (스크립트 계속 실행)
local ok, err = pcall(redis.call, 'INCR', KEYS[1])
if not ok then
-- 에러 처리
return redis.error_reply("INCR 실패: " .. tostring(err))
end
실전 예제
Rate Limiter (Sliding Window)
-- KEYS[1]: 키 (예: rate:user:1001)
-- ARGV[1]: 현재 타임스탬프
-- ARGV[2]: 윈도우 크기 (초)
-- ARGV[3]: 최대 허용 횟수
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max_count = tonumber(ARGV[3])
-- 윈도우 밖의 오래된 요소 제거
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 현재 카운트 확인
local count = redis.call('ZCARD', key)
if count < max_count then
-- 허용: 현재 요청 추가
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
redis.call('EXPIRE', key, window)
return 1 -- 허용
else
return 0 -- 거부
end
// Java에서 호출
String script = "...위의 Lua 코드...";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of("rate:user:1001"), // KEYS
String.valueOf(System.currentTimeMillis() / 1000), // ARGV[1]
"60", // ARGV[2]: 60초 윈도우
"100" // ARGV[3]: 최대 100회
);
재고 차감 (원자적)
-- KEYS[1]: 재고 키
-- ARGV[1]: 차감할 수량
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then
return redis.error_reply("상품이 존재하지 않습니다")
end
local amount = tonumber(ARGV[1])
if stock < amount then
return -1 -- 재고 부족
end
redis.call('DECRBY', KEYS[1], amount)
return stock - amount -- 남은 재고 반환
분산 락 해제 (안전한 방식)
-- 자기가 설정한 락인지 확인 후 삭제
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
EVALSHA — SHA1 캐싱
스크립트를 매번 전송하는 대신, 서버에 캐싱된 SHA1 해시로 호출합니다.
# 스크립트 로드 (SHA1 해시 반환)
127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"a42059b356c875f0717db19a51f6aaa9161571a2"
# SHA1로 실행
127.0.0.1:6379> EVALSHA "a42059b356c875f0717db19a51f6aaa9161571a2" 1 mykey
"myvalue"
# 캐시 확인
127.0.0.1:6379> SCRIPT EXISTS "a42059b356c875f0717db19a51f6aaa9161571a2"
1) (integer) 1
Java에서 EVALSHA 활용
// DefaultRedisScript는 자동으로 EVALSHA → 실패 시 EVAL 폴백
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(luaCode);
script.setResultType(Long.class);
// Spring RedisTemplate은 내부적으로 EVALSHA를 먼저 시도
Long result = redisTemplate.execute(script, keys, args);
Redis Function (7.0+)
Redis 7.0부터 Lua 스크립팅을 대체하는 새로운 프로그래밍 모델입니다.
기존 Lua의 문제점
- 서버 재시작 시 스크립트 캐시가 사라짐
- 스크립트를 라이브러리로 묶어서 관리하기 어려움
- 클러스터에서 모든 노드에 스크립트를 배포하기 번거로움
Function 정의 및 등록
#!lua name=mylib
-- 라이브러리 내 함수 등록
redis.register_function('deduct_stock', function(keys, args)
local stock = tonumber(redis.call('GET', keys[1]))
if stock == nil then
return redis.error_reply("상품 없음")
end
local amount = tonumber(args[1])
if stock < amount then
return -1
end
redis.call('DECRBY', keys[1], amount)
return stock - amount
end)
redis.register_function('check_rate_limit', function(keys, args)
-- Rate Limiter 로직
local key = keys[1]
local now = tonumber(args[1])
local window = tonumber(args[2])
local max_count = tonumber(args[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < max_count then
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
redis.call('EXPIRE', key, window)
return 1
end
return 0
end)
Function 로드 및 호출
# 라이브러리 로드
127.0.0.1:6379> FUNCTION LOAD "#!lua name=mylib\n..."
"mylib"
# 함수 호출
127.0.0.1:6379> FCALL deduct_stock 1 product:1001 5
(integer) 95
# 읽기 전용 함수 호출
127.0.0.1:6379> FCALL_RO check_rate_limit 1 rate:user:1 1679000000 60 100
(integer) 1
# 함수 목록 확인
127.0.0.1:6379> FUNCTION LIST
# 라이브러리 삭제
127.0.0.1:6379> FUNCTION DELETE mylib
# 라이브러리 교체 (업데이트)
127.0.0.1:6379> FUNCTION LOAD REPLACE "#!lua name=mylib\n..."
EVAL vs Function 비교
| 특성 | EVAL/EVALSHA | Redis Function |
|---|---|---|
| 영속성 | 서버 재시작 시 사라짐 | AOF/RDB에 저장됨 |
| 관리 단위 | 개별 스크립트 | 라이브러리 (여러 함수 묶음) |
| 배포 | 클라이언트가 매번 전송 | FUNCTION LOAD로 한 번 등록 |
| 호출 | EVAL/EVALSHA | FCALL/FCALL_RO |
| 클러스터 | 각 노드에 별도 전송 | FUNCTION LOAD가 자동 전파 |
스크립팅 주의사항
긴 실행 시간
# 기본 타임아웃: 5초
# 5초 이상 실행 시 다른 클라이언트에 BUSY 에러 반환
127.0.0.1:6379> CONFIG SET lua-time-limit 5000
# 스크립트 강제 종료 (쓰기가 없었던 경우)
127.0.0.1:6379> SCRIPT KILL
# 쓰기가 있었던 경우 SCRIPT KILL 불가 → SHUTDOWN NOSAVE 필요
결정적(Deterministic) 코드 작성
-- 나쁜 예: 비결정적 (레플리카에서 다른 결과)
local time = redis.call('TIME') -- Redis 7.0 미만에서 금지
-- 좋은 예: ARGV로 외부에서 전달
local timestamp = tonumber(ARGV[1])
사이드 이펙트 최소화
-- Lua에서 사용 가능한 Redis 명령만 호출
-- 파일 I/O, 네트워크, OS 명령 등은 불가
-- 외부 Lua 라이브러리 로드 불가 (샌드박스)
정리
- Lua 스크립팅은 MULTI/EXEC로 불가능한 조건부 원자적 연산을 구현합니다
redis.call()로 Redis 명령을 호출하고, KEYS/ARGV로 파라미터를 전달합니다- EVALSHA로 네트워크 트래픽을 줄이고, Spring의 DefaultRedisScript가 이를 자동 처리합니다
- Redis 7.0+에서는 Function이 더 나은 관리성과 영속성을 제공합니다
- 스크립트 실행 시간에 주의하세요. 오래 실행되면 전체 Redis가 블로킹됩니다
댓글 로딩 중...