Redis Lua 스크립트
Lua 스크립트란?
Redis는 서버 측에서 Lua 스크립트를 실행하는 기능을 제공한다. Redis 2.6.0부터 내장되어 있으며, 별도 설치 없이 사용 가능하다.
Redis에서 Lua를 쓰는 이유
원자성 보장
Redis 명령어는 하나씩은 원자적이지만, 여러 명령어를 조합할 때는 그 사이에 다른 클라이언트가 끼어들 수 있다.
[Client A] GET counter → 값: 10
[Client B] GET counter → 값: 10 ← 끼어듦
[Client A] SET counter 11
[Client B] SET counter 11 ← 둘 다 11로 설정, 하나 손실
Lua 스크립트는 Redis 서버에서 단일 명령어처럼 실행되므로, 스크립트 전체가 원자적으로 처리된다.
왜 원자적인가 — 싱글 스레드 모델
Redis는 싱글 스레드로 명령어를 처리한다. Lua 스크립트가 실행되는 동안 다른 클라이언트의 명령어는 큐에서 대기한다.
명령어 큐:
[GET a] → [EVAL script] → [SET b] → [GET c]
↑
이 스크립트 실행 중에는 다른 명령어 처리 없음
따라서 스크립트 내부 로직 전체가 인터럽트 없이 실행된다.
INCR이 원자적인 이유
INCR key는 GET → 증가 → SET의 세 단계를 하나의 명령어로 실행한다. 싱글 스레드 모델에서 이 명령어 실행 중에 다른 명령어가 끼어들 수 없으므로 완전히 원자적이다.
INCR counter
= (내부적으로) GET counter → +1 → SET counter (원자적)
직접 GET → SET으로 구현하면 race condition이 생기지만, 단일 명령어 INCR은 안전하다.
EVAL 명령어
EVAL script numkeys [key [key ...]] [arg [arg ...]]
| 파라미터 | 설명 |
|---|---|
script |
Lua 스크립트 문자열 |
numkeys |
KEYS 배열에 전달할 키 개수 |
key |
Redis 키 목록 (KEYS 배열) |
arg |
추가 인자 (ARGV 배열) |
기본 예시
# Redis CLI에서 실행
EVAL "return 'hello'" 0
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 mykey myvalue
EVAL "return redis.call('get', KEYS[1])" 1 mykey
Java (Spring Data Redis)에서 EVAL
String script = "return redis.call('set', KEYS[1], ARGV[1])";
redisTemplate.execute(
new DefaultRedisScript<>(script, String.class),
List.of("mykey"), // KEYS
"myvalue" // ARGV
);
EVALSHA 명령어
스크립트를 매번 전송하면 네트워크 오버헤드가 발생한다. EVALSHA는 스크립트를 서버에 캐싱하고 SHA1 해시로 호출한다.
EVALSHA sha1 numkeys [key [key ...]] [arg [arg ...]]
스크립트 캐싱 흐름
1. SCRIPT LOAD "return redis.call('get', KEYS[1])"
→ "e0e1f9fabfa9d353e4... " (SHA1 반환)
2. EVALSHA e0e1f9fabfa9d353e4... 1 mykey
→ 캐시된 스크립트 실행
3. SCRIPT EXISTS sha1 → [1] (존재) / [0] (없음)
4. SCRIPT FLUSH → 모든 캐시 삭제
Java에서 EVALSHA 패턴
@Component
public class LuaScriptCache {
private final StringRedisTemplate redisTemplate;
private String scriptSha;
// 애플리케이션 시작 시 스크립트 등록
@PostConstruct
public void loadScript() {
String script = "return redis.call('get', KEYS[1])";
scriptSha = redisTemplate.execute(
(RedisCallback<String>) conn ->
conn.scriptingCommands().scriptLoad(script.getBytes())
);
}
public String get(String key) {
return redisTemplate.execute(
new DefaultRedisScript<>(/* sha 기반 */)
);
}
}
실제로는 DefaultRedisScript가 SHA 캐싱을 내부적으로 처리한다.
// DefaultRedisScript는 최초 실행 시 EVALSHA를 시도하고,
// NOSCRIPT 에러 발생 시 자동으로 EVAL로 폴백한다
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(scriptText, Long.class);
// 이후 호출부터는 자동으로 EVALSHA 사용
Lua 문법 기초 (Redis 관점)
변수와 타입
-- 지역 변수 (local 필수 — 전역 변수 사용 금지)
local key = KEYS[1]
local value = ARGV[1]
local count = tonumber(ARGV[2])
-- 타입: nil, boolean, number, string, table
local flag = true
local arr = {1, 2, 3} -- table (배열처럼 사용)
local obj = {a = 1, b = 2} -- table (맵처럼 사용)
조건문
local val = redis.call('get', KEYS[1])
if val == false then -- nil은 false로 처리
return 0
elseif tonumber(val) > 100 then
return 1
else
return -1
end
반복문
-- 숫자 기반 반복
for i = 1, #KEYS do
redis.call('del', KEYS[i])
end
-- 일반 while
local i = 0
while i < 10 do
i = i + 1
end
함수 정의
local function increment(key, amount)
local current = tonumber(redis.call('get', key)) or 0
local new_value = current + amount
redis.call('set', key, new_value)
return new_value
end
return increment(KEYS[1], tonumber(ARGV[1]))
타입 변환
-- Redis는 모든 값을 string으로 반환
local count = tonumber(redis.call('get', KEYS[1])) -- string → number
local str = tostring(count + 1) -- number → string
redis.call vs redis.pcall
redis.call
명령어 실행 중 에러가 발생하면 스크립트 전체가 중단되고 에러를 반환한다.
-- WRONGTYPE 에러 시 스크립트 중단
local val = redis.call('incr', KEYS[1]) -- 키가 string이 아니면 에러
return val
redis.pcall
에러를 잡아서 처리할 수 있다 (protected call).
local ok, err = pcall(function()
return redis.pcall('incr', KEYS[1])
end)
-- 또는 직접 에러 테이블 확인
local result = redis.pcall('incr', KEYS[1])
if type(result) == 'table' and result.err then
-- 에러 처리
return {err = "increment failed: " .. result.err}
end
return result
선택 기준:
| 상황 | 권장 |
|---|---|
| 에러가 나면 모두 롤백해야 할 때 | redis.call |
| 일부 실패를 허용하고 계속 진행해야 할 때 | redis.pcall |
| 에러 메시지를 로깅하고 싶을 때 | redis.pcall |
KEYS vs ARGV
| 항목 | KEYS | ARGV |
|---|---|---|
| 목적 | Redis 키 이름 | 추가 인자 (값, 옵션 등) |
| 접근 | KEYS[1], KEYS[2] … |
ARGV[1], ARGV[2] … |
| 인덱스 | 1부터 시작 (Lua 관례) | 1부터 시작 |
| 클러스터 | 키 슬롯 결정에 사용 | 라우팅에 미사용 |
클러스터 환경에서 중요: Redis Cluster는 KEYS의 키들이 모두 같은 슬롯에 있어야 한다. 다른 슬롯의 키에 접근하면 에러가 발생한다.
-- 올바른 사용
-- EVAL script 2 key1 key2 arg1 arg2
local key1 = KEYS[1] -- "order:1"
local key2 = KEYS[2] -- "stock:1"
local amount = ARGV[1]
local ttl = ARGV[2]
실무 패턴
1. 분산 락 해제
-- GET → 비교 → DEL 원자 실행
-- KEYS[1] = 락 키, ARGV[1] = 락 값(UUID)
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT;
static {
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
RELEASE_LOCK_SCRIPT.setScriptText(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end"
);
RELEASE_LOCK_SCRIPT.setResultType(Long.class);
}
public boolean releaseLock(String key, String value) {
Long result = redisTemplate.execute(RELEASE_LOCK_SCRIPT, List.of(key), value);
return Long.valueOf(1L).equals(result);
}
2. Rate Limiting (슬라이딩 윈도우)
-- KEYS[1] = rate_limit:{userId}
-- ARGV[1] = 현재 timestamp(ms), ARGV[2] = 윈도우 크기(ms), ARGV[3] = 최대 요청 수
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- 윈도우 밖의 요청 제거
redis.call('zremrangebyscore', key, 0, now - window)
-- 현재 요청 수 확인
local count = redis.call('zcard', key)
if count < limit then
-- 현재 요청 추가
redis.call('zadd', key, now, now)
redis.call('pexpire', key, window)
return 1 -- 허용
else
return 0 -- 거부
end
@Service
public class RateLimiter {
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT;
static {
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
RATE_LIMIT_SCRIPT.setScriptText(
"local key = KEYS[1]\n" +
"local now = tonumber(ARGV[1])\n" +
"local window = tonumber(ARGV[2])\n" +
"local limit = tonumber(ARGV[3])\n" +
"redis.call('zremrangebyscore', key, 0, now - window)\n" +
"local count = redis.call('zcard', key)\n" +
"if count < limit then\n" +
" redis.call('zadd', key, now, now)\n" +
" redis.call('pexpire', key, window)\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end"
);
RATE_LIMIT_SCRIPT.setResultType(Long.class);
}
public boolean isAllowed(String userId) {
String key = "rate_limit:" + userId;
long now = System.currentTimeMillis();
long window = 60_000L; // 1분
long limit = 100L; // 최대 100회
Long result = redisTemplate.execute(
RATE_LIMIT_SCRIPT,
List.of(key),
String.valueOf(now),
String.valueOf(window),
String.valueOf(limit)
);
return Long.valueOf(1L).equals(result);
}
}
3. 조건부 업데이트
-- 현재 값이 예상 값과 일치할 때만 업데이트 (Compare-And-Swap)
-- KEYS[1] = 키, ARGV[1] = 예상 값, ARGV[2] = 새 값
local current = redis.call('get', KEYS[1])
if current == ARGV[1] then
redis.call('set', KEYS[1], ARGV[2])
return 1 -- 성공
else
return 0 -- 실패 (값이 다름)
end
public boolean compareAndSet(String key, String expected, String newValue) {
String script =
"local current = redis.call('get', KEYS[1])\n" +
"if current == ARGV[1] then\n" +
" redis.call('set', KEYS[1], ARGV[2])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(key), expected, newValue
);
return Long.valueOf(1L).equals(result);
}
4. 원자적 재고 차감
-- KEYS[1] = stock:productId
-- ARGV[1] = 차감 수량
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil then
return -1 -- 상품 없음
end
if stock < tonumber(ARGV[1]) then
return -2 -- 재고 부족
end
local remaining = redis.call('decrby', KEYS[1], ARGV[1])
return remaining -- 남은 재고 반환
public int decrementStock(Long productId, int quantity) {
String script =
"local stock = tonumber(redis.call('get', KEYS[1]))\n" +
"if stock == nil then return -1 end\n" +
"if stock < tonumber(ARGV[1]) then return -2 end\n" +
"return redis.call('decrby', KEYS[1], ARGV[1])";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of("stock:" + productId),
String.valueOf(quantity)
);
if (result == null || result == -1) throw new ProductNotFoundException();
if (result == -2) throw new InsufficientStockException();
return result.intValue();
}
5. 복합 카운터 (일별 + 총계 동시 업데이트)
-- KEYS[1] = daily:count:{date}:{userId}
-- KEYS[2] = total:count:{userId}
-- ARGV[1] = TTL(초)
local daily = redis.call('incr', KEYS[1])
redis.call('expire', KEYS[1], tonumber(ARGV[1]))
local total = redis.call('incr', KEYS[2])
return {daily, total}
public long[] incrementCounters(String userId, String date) {
String script =
"local daily = redis.call('incr', KEYS[1])\n" +
"redis.call('expire', KEYS[1], tonumber(ARGV[1]))\n" +
"local total = redis.call('incr', KEYS[2])\n" +
"return {daily, total}";
List<Long> result = redisTemplate.execute(
new DefaultRedisScript<>(script, List.class),
List.of("daily:" + date + ":" + userId, "total:" + userId),
"86400" // 1일 TTL
);
return new long[]{result.get(0), result.get(1)};
}
스크립트 캐싱
동작 흐름
최초 호출:
Client → EVALSHA sha1 → NOSCRIPT 에러
→ EVAL script → 실행 + 서버에 캐시
이후 호출:
Client → EVALSHA sha1 → 캐시 hit → 실행
DefaultRedisScript는 이 과정을 자동으로 처리한다.
서버 재시작 시 주의
Redis 서버가 재시작되면 스크립트 캐시가 초기화된다. DefaultRedisScript는 NOSCRIPT 에러 시 자동으로 EVAL로 폴백하므로 실용적으로는 문제가 없다.
Redis Cluster에서 스크립트는 명령어를 받은 노드에만 캐시된다. 다른 노드에서 EVALSHA를 호출하면 NOSCRIPT 에러가 발생할 수 있다.
디버깅
redis-cli에서 테스트
# 직접 실행
redis-cli EVAL "return redis.call('get', KEYS[1])" 1 mykey
# 파일로 실행
redis-cli --eval /path/to/script.lua key1 key2 , arg1 arg2
# 쉼표(,) 앞이 KEYS, 뒤가 ARGV
로깅
-- redis.log로 서버 로그에 출력
redis.log(redis.LOG_WARNING, "처리 중: " .. KEYS[1])
redis.log(redis.LOG_NOTICE, "값: " .. tostring(ARGV[1]))
-- 로그 레벨: LOG_DEBUG, LOG_VERBOSE, LOG_NOTICE, LOG_WARNING
에러 메시지 반환
local result = redis.pcall('get', KEYS[1])
if type(result) == 'table' and result.err then
return redis.error_reply("custom error: " .. result.err)
end
주의사항
| 항목 | 주의 내용 |
|---|---|
| 실행 시간 | 스크립트 실행 중 Redis가 블로킹됨 — 빠르게 끝나야 함 |
| 전역 변수 금지 | 항상 local 사용. 전역 변수는 다음 스크립트에 영향 |
| 랜덤 함수 주의 | math.random은 복제 시 마스터/레플리카 결과 불일치 발생 가능 → redis.call('time') 사용 권장 |
| 무한 루프 금지 | lua-time-limit(기본 5000ms) 초과 시 스크립트 강제 종료 |
| 클러스터 제약 | KEYS 배열의 키가 모두 같은 슬롯에 있어야 함 (해시 태그 활용) |
| KEYS 명시적 선언 | 클러스터 라우팅을 위해 접근하는 모든 키를 KEYS에 전달해야 함 |
클러스터에서 해시 태그 활용
-- {user:1} 해시 태그로 같은 슬롯에 배치
-- KEYS[1] = {user:1}:profile
-- KEYS[2] = {user:1}:session
-- → 같은 슬롯에 있으므로 클러스터에서 사용 가능
// 해시 태그를 사용한 키 설계
String profileKey = "{user:" + userId + "}:profile";
String sessionKey = "{user:" + userId + "}:session";
// 두 키 모두 {user:userId} 부분으로 슬롯 결정 → 같은 노드에 배치
정리
| 항목 | 내용 |
|---|---|
| 원자성 근거 | Redis 싱글 스레드 모델 — 스크립트 실행 중 다른 명령어 없음 |
| EVAL | 스크립트 텍스트를 매번 전송 |
| EVALSHA | SHA1 해시로 캐시된 스크립트 호출 — 네트워크 절감 |
| redis.call | 에러 시 스크립트 전체 중단 |
| redis.pcall | 에러를 잡아 처리 가능 |
| KEYS | Redis 키 (클러스터 라우팅에 사용) |
| ARGV | 부가 인자 (값, 옵션 등) |
| 주요 사용처 | 분산 락 해제, Rate Limiting, 조건부 업데이트, 재고 차감 |