[Spring Boot + Kotlin] Redis Luascript 적용
개요
- luascript에 대한 간단한 개념을 학습
- Kotlin + Spring Boot Application 환경에서 LuaScript 적용
- 동시성제어를 테스트코드를 통해 확인
- luascript 실행 중 예외가 발생하면 어떻게 처리되는지 확인
Redis Luascript란?
LuaScript란 Lua 프로그래밍 언어로 작성된 사용자 지정 스크립트를 Redis 서버 내에서 직접 실행할 수 있는 기능입니다.
Atomic 한 작업으로 Redis 데이터에 대한 복잡한 작업을 수행할 수 있는 강력한 방법입니다.
Transaction, Pipeline 과는 다르게 중간 결과를 조작할 수 있습니다.
예를 들어 Transaction 내부에서는 데이터를 GET해오면 null이 반환됩니다.
중간 결과인 데이터를 통해 조건부로 명령을 실행할 수 있다는 장점이 있습니다.
LuaScript Bean 등록
@Configuration
class LuaScriptConfig {
@Bean
fun stockDeceaseScript(): RedisScript<Long>{
val scriptSource = ClassPathResource("redis-scripts/stockDecrease.lua")
return RedisScript.of(scriptSource, Long::class.java)
}
}
ClassPath의 경로를 불러와 RedisScript 객체를 만들어냅니다.
Spring Boot에서는 src/main/resources/redis-scripts/stockDecrease.lua 파일을 가져오기 위해서는 ClassPathResource를 활용할 수 있습니다.
첫 번째 인자로는 classpath에서 가져온 script를 넣어주고 두 번째 인자는 반환타입으로 Long 타입으로 반환받겠다는 것을 의미합니다.
RedisScript.of 메서드
static <T> RedisScript<T> of(Resource resource, Class<T> resultType) {
Assert.notNull(resource, "Resource must not be null");
Assert.notNull(resultType, "ResultType must not be null");
DefaultRedisScript<T> script = new DefaultRedisScript<>();
script.setResultType(resultType);
script.setLocation(resource);
return script;
}
내부 구현을 보면 반환타입과, script의 Localtion를 받아 기본전략인 DefaultRedisScript 클래스로 반환됩니다.
LuaScript 작성
-- HGET 명령어를 활용하여 현재 재고를 가져와 stockCount 변수에 저장합니다.
local stockCount = tonumber(redis.call('hget', KEYS[1], ARGV[1]))
-- 만약 stockCount가 1보다 작은 경우라면 error를 발생시킵니다.
if stockCount < 1 then
error("Inventory cannot be less than 0. Please replenish inventory")
end
-- hincryby 명령어를 활용하여 재고를 -1 감소시킵니다.
redis.call('hincrby', KEYS[1], ARGV[1], -1)
-- 차감 이후 남아있는 재고를 반환합니다.
return stockCount - 1
src/main/resources/redis-scripts/stockDecrease.lua의 경로로 파일을 하나 생성합니다.
luascript에 대한 간단한 설명
- --는 주석을 의미합니다.
- KEYS와 ARGV의 경우 인덱스는 1부터 시작하며 Spring Boot Application으로부터 주입받는 외부 변수를 의미합니다.
해당예제에서는 항상 재고를 -1 시켰지만 외부에서 변수로받아 유연하게 재고를 감소시킬 수 있습니다.
RedisTemplate를 활용하여 LuaScript 호출
@Component
class RedisTransactionWithLuaScript(
private val stockDecreaseScript: RedisScript<Long>,
private val stringRedisTemplate: StringRedisTemplate,
) {
fun luaScriptDeceaseStock() {
val stockCount = runCatching {
stringRedisTemplate.execute(
stockDecreaseScript, //resources에 작성된 script
listOf(PRODUCT_ITEM_ID_KEY), //keys를 넘긴다 KEY[1]부터 시작
STOCK_ID //args를 넘긴다 ARGV[1]부터 시작
)
}.getOrElse { throw StockEmptyException("재고가 소진되었습니다.") }
logger.info { "luaScript 호출 후 남은 재고 = $stockCount" }
}
companion object {
const val PRODUCT_ITEM_ID_KEY = "productItem"
const val STOCK_ID = "104"
}
}
class StockEmptyException(message: String): RuntimeException(message)
Bean으로 등록했던 RedisScript를 주입받습니다.
이후에는 KEYS와 ARGV 인자를 넘겨줍니다.
RedisTemplate의 execute 메서드
@Override
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
return scriptExecutor.execute(script, keys, args);
}
script, keys, args를 인자로 받아 scriptExecutor가 이를 대신 수행해 줍니다.
테스트 코드로 동시성제어 검증
@Test
fun `luaScript를 활용하여 Atomic 연산을 수행하여 30개의 재고가 존재할 때 50개의 요청을 동시에 보내면 0개의 재고가 남아있어야 한다`(){
val numberOfThreads = 50
val executorService = newFixedThreadPool(numberOfThreads)
val latch = CountDownLatch(numberOfThreads)
repeat(numberOfThreads) {
executorService.submit {
try {
redisTransactionWithLuaScript.luaScriptDeceaseStock()
} finally {
latch.countDown()
}
}
}
latch.await()
val actual = stringRedisTemplate.opsForHash<String, String>().get("productItem", "104")
assertEquals(actual, "0")
}
재고의 경우에는 이미 30개를 세팅해 준 상태에서 진행하였습니다.
테스트가 잘 통과되는 모습을 확인할 수 있습니다.
runCatching으로 예외를 잡지 않는다면
Caused by: io.lettuce.core.RedisCommandExecutionException:
ERR user_script:6: Inventory cannot be less than 0.
Please replenish inventory script: 128214c0c509947d73c0c678c4eeb89b40f67309, on @user_script:6.
재고가 0일 때 LuaScript에서 Error를 반환하며 위와 같은 예외가 발생합니다.
만약 LuaScript를 실행하다가 중간에 예외가 나면 나머지 연산들은 반영될까?
redis.call('SET', KEYS[1], 'stringValue')
redis.call('DECR', KEYS[1])
redis.call('SET', KEYS[1], 'changeValue')
위와 같은 luaScript를 작성하여 2번째 명령어에서 의도적으로 예외를 발생시켰습니다.
TestCode로 검증해 보면 자연스럽게 예외가 발생합니다.
Caused by: io.lettuce.core.RedisCommandExecutionException:
ERR value is not an integer or out of range script:
Redis에 결과는?
- stringValue라는 값만 존재하고 changeValue로 변환되지는 않았습니다.
이로써 예외가 발생하기 전까지의 연산만 반영되고 예외가 발생한 이후의 연산은 반영되지 않는 것을 확인할 수 있습니다.
Redis Transaction에서는 예외가 발생해도 연산이 반영되는 것과 차이점이 존재합니다.
참고자료
https://www.baeldung.com/spring-classpath-file-access
https://www.vinsguru.com/redis-lua-script-with-spring-boot/