백엔드 데이터 인프라 60편 — Redis Lua Scripting (EVAL · EVALSHA)

2026-05-17백엔드 데이터 인프라

백엔드 데이터 인프라 60편. Redis Lua Scripting — EVAL·EVALSHA로 서버 사이드 원자적 로직 실행. KEYS·ARGV 구분의 의미, 스크립트 캐싱, 분산 락 해제·rate limiter·atomic counter 같은 클래식 패턴까지 풀어쓴 학습 노트.

📚 백엔드 데이터 인프라 · 60편 — Redis Lua Scripting (EVAL · EVALSHA)

이 글은 백엔드 데이터 인프라 시리즈 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-pyr.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, ...) = 명령 실행, 에러 시 스크립트 abort
  • redis.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 사양을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!