본문 바로가기
토이프로젝트/상품주문

[상품 주문 3편] Redis 설명

by 무대포 개발자 2023. 5. 8.
728x90
반응형

source 는 Github 에 있습니다.

목차는 상품 주문 에 있습니다.

[상품 주문 3편] Redis 설명

Webflux, 코틀린, Redis, 분산락 공부를 위해 작성했습니다.

Redis 분산락 로직 설명

  • 메소드를 실행 하기 전에 lock 을 획득하고, 메소드를 실행합니다.
  • 메소드 실행이 끝나면 lock release 가 이루어집니다.

로직 개선 사항

  • jointPoint.proceed 부분이 비즈니스 로직을 실행하는 부분입니다. 해당 부분은 별도 트랜잭션으로 분리를 해야하는데, 비동기 + kotlin 환경에서 트랜잭션을 분리하는 방법을 찾지 못해 숙제로 남겨놨습니다.
  • 트랜잭션을 별도로 분리해야하는 이유는 lock 을 거는 부분이 비즈니스 로직과 하나의 트랜잭션으로 묶여서 처리되면 안되기 때문입니다. lock 을 거는 부분에서 에러가 발생해도 비즈니스 로직의 트랜잭션에 영향을 줘선 안됩니다.
@Aspect
@Component
class DistributedLockAspect(private val distributedLockService: DistributedLockService) {

    private val logger = LoggerFactory.getLogger(this.javaClass)

    @Around("@annotation(DistributedLock)")
    fun lock(joinPoint: ProceedingJoinPoint): Mono<Any?> {
        var lockKey = getLockKey(joinPoint.args)
        return distributedLockService.acquireLock(lockKey)
            .flatMap { lockAcquired ->
                if (lockAcquired) {
                    logger.debug("Lock acquired: $lockKey")
                    try {
                        joinPoint.proceed() as Mono<Any?>
                    } finally {
                        distributedLockService.releaseLock(lockKey).subscribe {
                            logger.debug("Lock released: $lockKey")
                        }
                    }
                } else {
                    logger.debug("Failed to acquire lock: $lockKey")
                    Mono.empty<Any>()
                }
            }
            .switchIfEmpty(Mono.error(Exception("락을 획득하지 못했습니다.")))
    }

    private fun getLockKey(args: Array<Any>): String {
        require(args.isNotEmpty()) { "Arguments must not be empty" }
        val arg = args[0]
        return when (arg) {
            is String -> arg
            is OrderCommand.CreateOrder -> {
                requireNotNull(arg.getProductId()) { "ProductId must not be null" }
                "product:${arg.getProductId()}"
            }

            else -> throw IllegalArgumentException("Unsupported argument type: ${arg.javaClass.name}")
        }
    }
}



@Component
class DistributedLockService(
    private val reactiveRedisTemplate: ReactiveRedisTemplate<String, String>,
) {
    companion object {
        private const val DEFAULT_TIMEOUT = 5000L
    }

    fun acquireLock(key: String): Mono<Boolean> {
        // SETNX - lock 획득 (Atomic)
        // key : 저장할 키, "" : 저장 value, timeout : TTL
        return reactiveRedisTemplate.opsForValue()
            .setIfAbsent(
                key, "", Duration.ofMillis(DEFAULT_TIMEOUT)
            )
    }

    fun releaseLock(key: String): Mono<Long> {
        // DEL - 락 해제 (Atomic)
        return reactiveRedisTemplate.delete(key)
    }
}


@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class DistributedLock(val key: String)

redis 분산락 동작 여부 확인 방법

  • redis console 접속 -> MONITOR 명령어 실행 -> SETNX, DEL 명령어 확인

  • REDISSON 을 통해 SETNX, DEL 명령어를 호출을 했지만, 모니터링 한 결과 이상한 값이 찍혀있는데요.

    • REDISSON 내부에서 lua script 를 이용해서 처리한 결과입니다.
1683549724.618226 [0 172.21.0.1:47212] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);" "1" "product:product1002" "5000" "1c4eddfe-e10b-4e47-9e08-b510e610ca3c:3020424071110789844"
1683549724.618445 [0 lua] "exists" "product:product1002"
1683549724.618499 [0 lua] "hincrby" "product:product1002" "1c4eddfe-e10b-4e47-9e08-b510e610ca3c:3020424071110789844" "1"
1683549724.618547 [0 lua] "pexpire" "product:product1002" "5000"
1683549724.651154 [0 172.21.0.1:47238] "EVAL" "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;" "2" "product:product1002" "redisson_lock__channel:{product:product1002}" "0" "30000" "1c4eddfe-e10b-4e47-9e08-b510e610ca3c:3020424071110789844"
1683549724.651322 [0 lua] "hexists" "product:product1002" "1c4eddfe-e10b-4e47-9e08-b510e610ca3c:3020424071110789844"
1683549724.651387 [0 lua] "hincrby" "product:product1002" "1c4eddfe-e10b-4e47-9e08-b510e610ca3c:3020424071110789844" "-1"
1683549724.651408 [0 lua] "del" "product:product1002"
1683549724.651441 [0 lua] "publish" "redisson_lock__channel:{product:product1002}" "0"

Redis client redisson

  • redisson 를 선택한 이유는 비동기적으로 지원이 가능하며, Reactive 환경에서 동작 가능합니다.
  • Jedis 는 비동기를 지원 안합니다.
  • lettuce 는 비동기를 지원하지만 스핀락으로 인한 성능 문제, 기능을 (자료구조, 확장성 등) Redisson 이 더 많이 지원해서 Redisson 을 선택했습니다.

Redisson Reactive 락 주의사항

  • Redisson 에서 lock, unlock 을 할 때, 같은 ThreadId 를 사용하라고 돼있습니다. Reactive 환경에서 왜 이렇게 해놨는지는 모르겠는데, lock, unlock 할 때, 같은 threadId 를 넣어서 처리하도록 구현했습니다.

  • 다음 링크를 참고했습니다. 링크, 링크1

Reference

댓글