Theme:

여러 Redis 명령을 조건에 따라 원자적으로 실행해야 할 때, MULTI/EXEC만으로는 부족합니다. 중간 결과를 보고 분기할 수 있는 방법은 없을까요?

Lua 스크립팅이란

Redis는 Lua 스크립트를 서버사이드에서 실행하는 기능을 제공합니다. 스크립트는 Redis의 싱글 스레드 실행 모델 안에서 원자적으로 실행되므로, 여러 명령을 조건 분기와 함께 안전하게 처리할 수 있습니다. MULTI/EXEC로는 불가능한 "읽은 값에 따라 다른 명령 실행"이 가능합니다.

왜 필요한가

MULTI/EXEC의 한계

PLAINTEXT
원하는 동작:
1. balance 읽기
2. balance >= 100이면 차감, 아니면 실패

MULTI/EXEC로는 불가능:
WATCH balance
GET balance → "150" (OK)
MULTI
  DECRBY balance 100  # 여기서 잔액 확인을 "다시" 할 수 없음
EXEC

Lua로 해결

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 스크립트 실행

기본 문법

BASH
EVAL <script> <numkeys> [key1 key2 ...] [arg1 arg2 ...]
  • script: Lua 코드
  • numkeys: KEYS 배열의 길이
  • key1, key2...: KEYS[1], KEYS[2]로 접근
  • arg1, arg2...: ARGV[1], ARGV[2]로 접근

기본 예제

BASH
# 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에 명시한 키만 접근해야 합니다.

LUA
-- 좋은 예: KEYS를 통해 접근
redis.call('GET', KEYS[1])

-- 나쁜 예: 하드코딩 (클러스터에서 문제)
redis.call('GET', 'hardcoded:key')

redis.call vs redis.pcall

LUA
-- 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)

LUA
-- 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
// 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회
);

재고 차감 (원자적)

LUA
-- 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  -- 남은 재고 반환

분산 락 해제 (안전한 방식)

LUA
-- 자기가 설정한 락인지 확인 후 삭제
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

EVALSHA — SHA1 캐싱

스크립트를 매번 전송하는 대신, 서버에 캐싱된 SHA1 해시로 호출합니다.

BASH
# 스크립트 로드 (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 활용

JAVA
// 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
#!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 로드 및 호출

BASH
# 라이브러리 로드
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/EVALSHARedis Function
영속성서버 재시작 시 사라짐AOF/RDB에 저장됨
관리 단위개별 스크립트라이브러리 (여러 함수 묶음)
배포클라이언트가 매번 전송FUNCTION LOAD로 한 번 등록
호출EVAL/EVALSHAFCALL/FCALL_RO
클러스터각 노드에 별도 전송FUNCTION LOAD가 자동 전파

스크립팅 주의사항

긴 실행 시간

BASH
# 기본 타임아웃: 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) 코드 작성

LUA
-- 나쁜 예: 비결정적 (레플리카에서 다른 결과)
local time = redis.call('TIME')    -- Redis 7.0 미만에서 금지

-- 좋은 예: ARGV로 외부에서 전달
local timestamp = tonumber(ARGV[1])

사이드 이펙트 최소화

LUA
-- Lua에서 사용 가능한 Redis 명령만 호출
-- 파일 I/O, 네트워크, OS 명령 등은 불가
-- 외부 Lua 라이브러리 로드 불가 (샌드박스)

정리

  • Lua 스크립팅은 MULTI/EXEC로 불가능한 조건부 원자적 연산을 구현합니다
  • redis.call()로 Redis 명령을 호출하고, KEYS/ARGV로 파라미터를 전달합니다
  • EVALSHA로 네트워크 트래픽을 줄이고, Spring의 DefaultRedisScript가 이를 자동 처리합니다
  • Redis 7.0+에서는 Function이 더 나은 관리성과 영속성을 제공합니다
  • 스크립트 실행 시간에 주의하세요. 오래 실행되면 전체 Redis가 블로킹됩니다
댓글 로딩 중...