Redis Lua Script가 필요한 이유
Redis는 싱글 스레드로 명령을 처리한다. 하지만 여러 명령을 조합한 원자적(Atomic) 연산이 필요할 때가 많다. 예를 들어 “키 값을 읽고, 조건을 확인하고, 값을 수정”하는 과정에서 WATCH/MULTI로는 경합(Race Condition)을 완벽히 막기 어렵다. Lua Script는 Redis 서버 내부에서 원자적으로 실행되므로 이 문제를 해결한다.
Spring Data Redis의 RedisScript와 RedisTemplate.execute()를 사용하면 Lua Script를 타입 안전하게 실행할 수 있다.
기본 구조: RedisScript 정의와 실행
@Configuration
public class RedisScriptConfig {
// 리소스 파일에서 Lua 스크립트 로드
@Bean
public RedisScript<Long> rateLimitScript() {
Resource script = new ClassPathResource("scripts/rate-limit.lua");
return RedisScript.of(script, Long.class);
}
// 인라인 스크립트
@Bean
public RedisScript<Boolean> compareAndSetScript() {
String lua = """
local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return true
end
return false
""";
return RedisScript.of(lua, Boolean.class);
}
}
@Service
@RequiredArgsConstructor
public class ScriptExecutor {
private final StringRedisTemplate redisTemplate;
private final RedisScript<Boolean> compareAndSetScript;
public boolean compareAndSet(String key, String expected, String newValue) {
return Boolean.TRUE.equals(
redisTemplate.execute(
compareAndSetScript,
List.of(key), // KEYS
expected, newValue // ARGV
)
);
}
}
KEYS는 Redis 키 목록이고, ARGV는 인자 목록이다. Redis Cluster에서는 모든 KEYS가 같은 슬롯에 있어야 Lua Script가 실행된다.
실전 1: 슬라이딩 윈도우 Rate Limiter
-- resources/scripts/rate-limit.lua
-- KEYS[1]: rate limit key (e.g., "rl:user:123")
-- ARGV[1]: 윈도우 크기 (초)
-- ARGV[2]: 최대 요청 수
-- ARGV[3]: 현재 타임스탬프 (밀리초)
local key = KEYS[1]
local window = tonumber(ARGV[1]) * 1000
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 윈도우 밖의 오래된 요청 제거
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 현재 윈도우의 요청 수 확인
local count = redis.call('ZCARD', key)
if count < limit then
-- 요청 허용: 현재 타임스탬프를 score와 member로 추가
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
redis.call('PEXPIRE', key, window)
return 1 -- 허용
end
return 0 -- 거부
@Service
@RequiredArgsConstructor
public class RateLimiterService {
private final StringRedisTemplate redisTemplate;
private final RedisScript<Long> rateLimitScript;
/**
* @return true면 요청 허용, false면 거부
*/
public boolean isAllowed(String identifier, int windowSeconds, int maxRequests) {
String key = "rl:" + identifier;
Long result = redisTemplate.execute(
rateLimitScript,
List.of(key),
String.valueOf(windowSeconds),
String.valueOf(maxRequests),
String.valueOf(System.currentTimeMillis())
);
return result != null && result == 1L;
}
}
// 인터셉터에서 사용
@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {
private final RateLimiterService rateLimiter;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String clientIp = request.getRemoteAddr();
if (!rateLimiter.isAllowed(clientIp, 60, 100)) {
response.setStatus(429);
response.getWriter().write("Too Many Requests");
return false;
}
return true;
}
}
실전 2: 분산 락 (Redlock 패턴)
-- resources/scripts/lock-acquire.lua
-- KEYS[1]: 락 키
-- ARGV[1]: 소유자 식별자 (UUID)
-- ARGV[2]: TTL (밀리초)
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
return 1
end
return 0
-- resources/scripts/lock-release.lua
-- 소유자 확인 후 삭제 — 다른 클라이언트의 락을 삭제하지 않음
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
@Service
@RequiredArgsConstructor
public class DistributedLock {
private final StringRedisTemplate redisTemplate;
private final RedisScript<Long> acquireScript;
private final RedisScript<Long> releaseScript;
public Optional<String> acquire(String lockKey, Duration ttl) {
String owner = UUID.randomUUID().toString();
Long result = redisTemplate.execute(
acquireScript,
List.of("lock:" + lockKey),
owner,
String.valueOf(ttl.toMillis())
);
return result != null && result == 1L
? Optional.of(owner)
: Optional.empty();
}
public boolean release(String lockKey, String owner) {
Long result = redisTemplate.execute(
releaseScript,
List.of("lock:" + lockKey),
owner
);
return result != null && result == 1L;
}
// try-with-resources 패턴
public <T> T withLock(String lockKey, Duration ttl, Supplier<T> action) {
String owner = acquire(lockKey, ttl)
.orElseThrow(() -> new LockAcquisitionException(lockKey));
try {
return action.get();
} finally {
release(lockKey, owner);
}
}
}
실전 3: 재고 차감 (원자적 감소)
-- resources/scripts/deduct-stock.lua
-- KEYS[1]: 재고 키
-- ARGV[1]: 차감 수량
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if stock >= amount then
redis.call('DECRBY', KEYS[1], amount)
return stock - amount -- 남은 재고 반환
end
return -1 -- 재고 부족
@Service
@RequiredArgsConstructor
public class StockService {
private final StringRedisTemplate redisTemplate;
private final RedisScript<Long> deductScript;
public long deductStock(String productId, int quantity) {
Long remaining = redisTemplate.execute(
deductScript,
List.of("stock:" + productId),
String.valueOf(quantity)
);
if (remaining == null || remaining == -1L) {
throw new InsufficientStockException(productId);
}
return remaining;
}
}
이 패턴은 Redis Pipeline 배치 최적화와 함께 사용하면 대량 재고 처리 성능을 극대화할 수 있다.
EVALSHA와 스크립트 캐싱
Redis는 EVAL 실행 시 스크립트를 SHA1 해시로 캐싱한다. Spring Data Redis의 RedisScript는 자동으로 EVALSHA를 먼저 시도하고, 캐시 미스 시 EVAL로 폴백한다.
// RedisScript.of()로 생성하면 SHA1이 자동 계산됨
RedisScript<Long> script = RedisScript.of(luaCode, Long.class);
String sha = script.getSha1(); // SHA1 해시 확인
// 내부적으로:
// 1차 시도: EVALSHA sha1 numkeys keys... args...
// NOSCRIPT 에러 시: EVAL script numkeys keys... args...
따라서 RedisScript를 Bean으로 한 번만 생성하는 것이 중요하다. 매 요청마다 새로 생성하면 SHA1 캐싱 이점을 잃는다.
디버깅과 주의사항
| 주의사항 | 설명 |
|---|---|
| 실행 시간 제한 | lua-time-limit (기본 5초) 초과 시 다른 명령 블로킹. SLOWLOG로 모니터링 |
| Cluster 슬롯 | 모든 KEYS가 같은 해시 슬롯이어야 함. {prefix}:key 해시태그 사용 |
| 반환 타입 | Lua → Redis 타입 변환: number→integer, string→bulk, boolean→integer(1/nil), table→array |
| 에러 전파 | redis.call()은 에러 전파, redis.pcall()은 에러를 테이블로 반환 |
-- Cluster 환경: 해시태그로 같은 슬롯 보장
-- {order:123}:stock과 {order:123}:lock은 같은 슬롯
local stock_key = KEYS[1] -- {order:123}:stock
local lock_key = KEYS[2] -- {order:123}:lock
-- 디버깅: redis.log() 사용
redis.log(redis.LOG_WARNING, "stock=" .. tostring(stock))
테스트 전략
@SpringBootTest
@Testcontainers
class LuaScriptTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Autowired
private RateLimiterService rateLimiter;
@Test
void 슬라이딩_윈도우_제한() {
String user = "test-user";
// 10초 윈도우, 최대 3요청
assertTrue(rateLimiter.isAllowed(user, 10, 3));
assertTrue(rateLimiter.isAllowed(user, 10, 3));
assertTrue(rateLimiter.isAllowed(user, 10, 3));
assertFalse(rateLimiter.isAllowed(user, 10, 3)); // 4번째 거부
}
@Test
void 동시_재고_차감_원자성() throws Exception {
redisTemplate.opsForValue().set("stock:item1", "10");
// 20개 스레드가 동시에 1개씩 차감 시도
ExecutorService executor = Executors.newFixedThreadPool(20);
AtomicInteger successCount = new AtomicInteger();
List<Future<?>> futures = IntStream.range(0, 20)
.mapToObj(i -> executor.submit(() -> {
try {
stockService.deductStock("item1", 1);
successCount.incrementAndGet();
} catch (InsufficientStockException ignored) {}
}))
.toList();
futures.forEach(f -> assertDoesNotThrow(() -> f.get()));
// 정확히 10개만 성공해야 함
assertEquals(10, successCount.get());
assertEquals("0", redisTemplate.opsForValue().get("stock:item1"));
}
}
Lua Script는 Redis의 원자적 실행 보장과 네트워크 라운드트립 감소를 동시에 달성한다. Redis Streams 이벤트 처리와 결합하면 복잡한 분산 시스템 로직도 안정적으로 구현할 수 있다.