Spring Redis Lua Script 심화

Redis Lua Script가 필요한 이유

Redis는 싱글 스레드로 명령을 처리한다. 하지만 여러 명령을 조합한 원자적(Atomic) 연산이 필요할 때가 많다. 예를 들어 “키 값을 읽고, 조건을 확인하고, 값을 수정”하는 과정에서 WATCH/MULTI로는 경합(Race Condition)을 완벽히 막기 어렵다. Lua Script는 Redis 서버 내부에서 원자적으로 실행되므로 이 문제를 해결한다.

Spring Data Redis의 RedisScriptRedisTemplate.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...

따라서 RedisScriptBean으로 한 번만 생성하는 것이 중요하다. 매 요청마다 새로 생성하면 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 이벤트 처리와 결합하면 복잡한 분산 시스템 로직도 안정적으로 구현할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux