프로젝트/redis

[Spring Boot + Kotlin] Redis Luascript 적용

Junuuu 2023. 11. 8. 00:01

개요

  • 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/