ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot + Kotlin] Redis Luascript 적용
    프로젝트/redis 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/

     

    댓글

Designed by Tistory.