백엔드 데이터 인프라 60편. Redis Lua Scripting — EVAL·EVALSHA로 서버 사이드 원자적 로직 실행. KEYS·ARGV 구분의 의미, 스크립트 캐싱, 분산 락 해제·rate limiter·atomic counter 같은 클래식 패턴까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 130편 중 60편이에요. 59편 에서 MULTI/EXEC 가 진짜 원자성을 보장 못 한다는 함정을 봤다면, 그 한계를 Lua Scripting(Redis 안에서 돌아가는 임베디드 스크립트) 이 어떻게 풀어주는지가 이번 60편. 49편에서 잠깐 본 분산 락 해제 한 줄도 사실 Lua 였어요.
Lua Scripting이 어렵게 느껴지는 이유
Lua 가 낯선 언어라 첫인상이 부담스러운데, 막상 두 가지만 잡으면 끝나요.
첫째, Lua 문법 자체는 매우 단순해요. if·for·local·function·return 정도만 알면 90%는 끝납니다. Python 이나 JavaScript 비슷한 감각이라 5분이면 익숙해져요. 부담은 언어 학습이 아니라 Redis 와의 연결 방식 — KEYS[]·ARGV[]·redis.call()·redis.pcall() 4가지뿐.
둘째, EVAL 과 EVALSHA·스크립트 캐싱이 헷갈려요. 처음에는 EVAL(스크립트 본문을 통째로 보내는 명령) 로 보내고 이후 EVALSHA(SHA1 해시로 캐시된 스크립트 호출) 로 재사용하는 흐름, 그리고 NOSCRIPT(서버 캐시에 스크립트 없음) 에러 대응이 매번 새 환경에서 또 헷갈리고요.
이 글에서 Lua 기본·KEYS/ARGV/redis.call·EVAL/EVALSHA 캐싱·NOSCRIPT 핸들링·클래식 3가지 패턴(분산 락 해제·rate limiter·atomic counter)·Lua 5.1 제한·Redis Functions (61편 예고) 까지 한 번에.
핵심 명령어 — EVAL
> EVAL "return 'hello'" 0
"hello"
> EVAL "return KEYS[1]" 1 mykey
"mykey"
> EVAL "return ARGV[1]" 0 hello
"hello"
> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myval
OK
시그니처
EVAL <script> <numkeys> <key1> <key2> ... <arg1> <arg2> ...
<script>— Lua 코드 (문자열)<numkeys>— 키의 개수 (이게 핵심)<key1>...— Lua 안에서KEYS[1]·KEYS[2]... 로 접근<arg1>...— Lua 안에서ARGV[1]·ARGV[2]... 로 접근
KEYS vs ARGV — 왜 분리?
여기서 시험 함정이 하나 있어요. 키와 인자를 왜 굳이 분리할까? 답은 Cluster 지원.
Redis Cluster 에서는 어떤 키가 어느 노드에 있는지가 slot(0~16383 해시 슬롯) 으로 결정돼요. EVAL 을 보낼 때 어떤 키들에 접근할지 미리 알려줘야 Cluster 가 올바른 노드로 라우팅해요. KEYS[] 에 박힌 키만 Cluster 가 추적하고, ARGV[] 는 일반 데이터로 본다는 뜻.
규칙은 단순해요. Redis 키 이름은 반드시 KEYS[] 로 전달하고, 일반 값·문자열·숫자만 ARGV[] 에 넣는다. 이걸 어기면 Cluster 환경에서 CROSSSLOT(여러 슬롯에 걸친 키 접근) 에러나 잘못된 노드 라우팅이 터져요.
Lua 기본 — Redis 안에서
redis.call(cmd, ...) — 명령 실행
local val = redis.call('GET', KEYS[1])
redis.call('SET', KEYS[1], 'new value')
에러가 나면 스크립트 전체가 abort(중단) 됩니다. 반환값은 Lua 타입으로 변환되고요.
| Redis 타입 | Lua 타입 |
|---|---|
| Integer | number |
| Bulk string | string |
| Multi-bulk | table (배열) |
| Status reply | table {ok = "..."} |
| Error reply | table {err = "..."} |
| nil | false |
redis.pcall(cmd, ...) — 에러 무시 명령
local result = redis.pcall('GET', 'nonexistent')
if type(result) == 'table' and result.err then
-- 에러 처리
end
에러가 나도 스크립트가 계속 굴러가요. try-catch 비슷한 역할이에요.
Lua 핵심 문법 5가지
-- 1. 변수
local x = 10
-- 2. 조건
if x > 5 then
return 'big'
elseif x > 0 then
return 'small'
else
return 'zero'
end
-- 3. 반복
for i = 1, 10 do
redis.call('LPUSH', KEYS[1], i)
end
-- 4. 함수
local function double(n)
return n * 2
end
-- 5. 테이블 (Lua의 dict/array)
local t = {1, 2, 3}
local d = {name = 'Alice', age = 30}
이 5가지에 redis.call 이면 실무 Lua 스크립트의 99%는 작성됩니다.
원자성 — Lua 의 가장 큰 강점
Lua 스크립트는 통째로 한 명령처럼 실행돼요. 내부의 모든 redis.call 이 atomic(중간에 끼어들 수 없는 한 덩어리) 으로 묶이는 셈.
-- 진짜 atomic check-and-set
local val = redis.call('GET', KEYS[1])
if val == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1
else
return 0
end
이 스크립트가 도는 동안 다른 클라이언트의 어떤 명령도 끼어들지 못해요. MULTI/EXEC 가 격리만 보장했다면 Lua 는 조건부 로직까지 원자적으로 묶어줍니다.
EVALSHA — 스크립트 캐싱
EVAL 로 매번 스크립트 본문을 통째로 보내면 네트워크 부담이 커져요. 한 번 보낸 스크립트는 서버에 캐시되고, SHA1(고정 길이 해시값) 으로 재실행할 수 있어요.
흐름
# 1. 스크립트 등록 (또는 첫 EVAL 시 자동 캐시)
> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"abc123def456..." # SHA1
# 2. 이후 EVALSHA 로 재실행
> EVALSHA abc123def456... 1 mykey
"value"
# 3. 캐시에 없으면 NOSCRIPT 에러
> EVALSHA wrongsha 1 mykey
(error) NOSCRIPT No matching script
NOSCRIPT 처리 패턴
Redis 재시작이나 SCRIPT FLUSH 등으로 캐시가 비워질 수 있어요. 클라이언트는 NOSCRIPT 에러를 받으면 EVAL 로 다시 보내고 EVALSHA 로 재시도하는 흐름이 표준이에요.
def run_script(script, sha, keys, args):
try:
return r.evalsha(sha, len(keys), *keys, *args)
except redis.exceptions.NoScriptError:
r.script_load(script)
return r.evalsha(sha, len(keys), *keys, *args)
redis-py 의 r.register_script(lua_code) 는 이 흐름을 자동 처리해줘요.
unlock_script = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""")
unlock_script(keys=['lock:order:99'], args=['my-uuid'])
클래식 패턴 1 — 분산 락 해제 (49편 재방문)
49편 String 편에서 잠깐 본 check-and-delete 패턴이에요.
-- KEYS[1] = "lock:order:99"
-- ARGV[1] = my-uuid
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
GET 으로 내가 박은 UUID 가 맞는지 확인하고 맞으면 DEL. 두 명령이 원자적으로 묶이지 않으면 다른 워커의 락을 실수로 풀 위험이 있어요. Lua 가 필요한 가장 흔한 자리.
클래식 패턴 2 — Sliding Window Rate Limiter (53편 재방문)
53편 Sorted Set 에서 본 그 스크립트예요.
-- KEYS[1] = "rate:user42"
-- ARGV[1] = 현재 ms timestamp
-- ARGV[2] = 윈도우 ms (예: 60000)
-- ARGV[3] = 최대 횟수 (예: 60)
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
return 0 -- 차단
end
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window / 1000)
return 1 -- 통과
오래된 항목 정리, 현재 카운트 확인, 추가까지 3단계가 atomic 으로 묶여요. 일반 명령으로 풀면 race(여러 클라이언트가 동시에 같은 자원을 건드릴 때 결과가 꼬이는 상황) 위험이 따라붙고요.
클래식 패턴 3 — Atomic Counter With Limit
-- 일별 호출 횟수 + 100회 제한
local current = tonumber(redis.call('INCR', KEYS[1]))
if current == 1 then
redis.call('EXPIRE', KEYS[1], 86400)
end
if current > tonumber(ARGV[1]) then
return 0
end
return current
INCR 와 EXPIRE, 한계 체크까지 한 호출로 끝납니다. 첫 호출인지도 Lua 안에서 판단하고요.
Lua 5.1 — 제한 사항
Redis 가 쓰는 Lua 버전은 5.1, 꽤 오래됐어요. 일부 최신 Lua 기능이 빠져 있어요.
goto없음 (5.2+)bit32라이브러리 (5.2+ 표준) 없음 →bit.*사용- 일부 표준 라이브러리 sandbox(외부 자원 접근을 막아둔 격리 환경) 처리
또 Redis 가 의도적으로 I/O·OS 모듈을 차단해서 순수 데이터 처리만 허용해요.
한계·주의사항
1. 실행 시간 한계
Lua 스크립트는 Redis 메인 스레드에서 돌아요. 오래 걸리는 스크립트는 Redis 전체를 멈춰버려요. 기본 한계는 5초 (lua-time-limit).
5초를 넘으면 SCRIPT KILL 로 죽일 수 있는데, 단 쓰기 명령이 시작되기 전에만 가능해요. 쓰기가 이미 시작됐으면 SHUTDOWN NOSAVE 밖에 답이 없어요.
권장은 수 ms 안에 끝나는 스크립트만 돌리는 거예요. 수만 키 순회 같은 무거운 작업은 Lua 가 잘못된 선택.
2. Cluster 환경 — 모든 키가 같은 slot
EVAL 안의 모든 KEYS[] 가 같은 slot 에 있어야 해요. 아니면 CROSSSLOT 에러. hash tag({user42}:lock·{user42}:counter 처럼 중괄호로 묶어 같은 슬롯을 강제하는 표기) 로 보장합니다.
3. 결정론적 명령만 (Replica·AOF 일관성)
Lua 안에서 비결정적 명령(TIME·SRANDMEMBER·SPOP 등) 을 호출하면 replica 와 master 결과가 달라질 위험이 생겨요. AOF(Append-Only File, 쓰기 명령을 순서대로 기록하는 영속화 방식) 재생 결과도 어긋날 수 있고요. Redis 가 이런 명령은 경고를 띄우거나 차단해요.
해법은 비결정적 값을 클라이언트가 ARGV 로 넘겨주는 거예요 (예: ARGV[1] = 현재 timestamp).
4. 디버깅 어려움
Lua 스크립트엔 디버거가 없어요. redis.log(redis.LOG_NOTICE, 'debug message') 로 서버 로그에 출력하는 정도가 전부. 복잡한 로직은 unit test 로 검증하는 게 필수예요.
Lua vs Redis Functions (61편 예고)
여기까지 따라오셨다면 한 가지 의문이 생겨요. "새로운 Redis Functions 가 있다는데?" Redis 7+ 부터 Functions 라는 Lua scripting 의 후속이 도입됐어요.
차이는 한 줄.
- Lua script — 휘발 (서버 재시작 시 캐시 사라짐), 익명 (SHA 만으로 식별)
- Functions — 영속 (디스크 저장), 이름으로 식별, replica 자동 복제
새 프로젝트는 Functions 권장, 기존은 Lua script 그대로 둡니다. 61편에서 깊이 들어가요.
시험 직전 한 번 더 — Lua Scripting 함정 압축 노트
- EVAL = Lua 스크립트 실행 →
EVAL <script> <numkeys> <keys...> <args...> - EVALSHA = 캐시된 스크립트 SHA1 으로 실행
KEYS[]= Redis 키 이름 (Cluster 라우팅용)ARGV[]= 일반 인자 (문자열·숫자)- Cluster 안전성 = 모든 키는 반드시 KEYS[] 로 전달
redis.call(cmd, ...)= 명령 실행, 에러 시 스크립트 abortredis.pcall(cmd, ...)= 보호 호출, 에러 무시 가능- 원자성 = Lua 스크립트 통째로 atomic — 조건부 로직까지 원자적
- MULTI/EXEC 가 못 풀던 진짜 원자적 트랜잭션 을 Lua 가 풀어줌
- Lua 핵심 5가지 =
local·if·for·function·table - EVAL → EVALSHA = 첫 실행 후 SHA 로 재사용 (네트워크 절약)
- NOSCRIPT 에러 = 캐시 비워짐 → EVAL 다시
r.register_script()= NOSCRIPT 핸들링 자동SCRIPT LOAD/SCRIPT EXISTS/SCRIPT FLUSH보조 명령- 클래식 패턴 1 = 분산 락 해제 (check-and-delete)
- 클래식 패턴 2 = Sliding Window Rate Limiter (Sorted Set + Lua)
- 클래식 패턴 3 = Atomic counter with limit (INCR + 한계 체크)
- Lua 버전 = 5.1 (제한적),
goto·bit32등 없음 - 실행 시간 한계 = 기본 5초 (
lua-time-limit) - 초과 시 =
SCRIPT KILL(쓰기 전에만) - Cluster = 모든 KEYS[] 가 같은 slot 이어야 — hash tag
- 비결정적 명령 = ARGV 로 클라이언트가 전달 (timestamp 등)
- 디버깅 =
redis.log()로 서버 로그 출력만 - Lua vs Functions = 휘발 vs 영속, 익명 vs 명명, replica 수동 vs 자동
- 새 프로젝트는 Functions 권장 (61편)
공식 문서: Redis Programmability 에서 EVAL·Functions 사양을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 55편 — Redis TTL + Eviction Policy 8가지
- 56편 — Redis Keyspace Notifications + 세션 만료 패턴
- 57편 — Redis Pipelining + MULTI/EXEC와의 차이
- 58편 — Redis Pub/Sub + Sharded Pub/Sub
- 59편 — Redis Transactions + WATCH Optimistic Locking
다음 글: