NoSQL

동시 차감 시 Redis Lua Script 활용하기

리승자이 2025. 5. 8. 01:23
728x90
반응형

이전에 업무하다가 동시성 이슈를 처리하기 위해 redis를 사용했었다.

 

아래 기능 구현이 실제 업무하는 환경과 비슷한 조건이다.

우리는 상금을 얻기 위해 게임 참여를 진행한다.

  1. 유저는 게임에 1회 참여할때마다 공동 상금에 1원씩 적립한다.
  2. 1등이 당첨되었을 때 1등은 현재까지 적립된 상금을 모두 가져간다.

위 2가지를 만족하는 기능 개발을 진행해야 한다.

 

일단 1등이라는 확률 자체가 희박하기 때문에 아래 기능으로 동작하게 만들어도 문제가 없을 것 같았다.

실제로 아직까지 문제가 발생했다는것은 아니다.

-> 근데 발생할 수 있기 때문에 로직도 수정해야 하는것이 맞다.

포인트 보정 산출식 추가하기

시나리오를 하나 생각해보면

유저 A, B 가 있다.

현재 상금은 98원까지 적립되어있다.

 

  1. 유저 A가 응모한다.
    1. 상금은 99원이된다.
  2. 유저 B가 응모한다.
    1. 상금은 100원이 된다.
  3. 경품을 뽑는다.

동시성에 위배될 수 있는 상황

위의 그림처럼 A, B 둘다 1등이 당첨됐을 때의 당시 상금을 먼저 조회하기 때문에 100원을 둘다 읽는 상황이 된다.

그래서 B는 A가 가져간 -100원 ~ B가 당첨 추출하기 이전의 상금을 가져가도록 설계해야 한다.

그래서 보정값을 주는 방식으로 처음에 해결했다!

 

이제부터 B는 사이에 있는 금액을 어떻게 가져갈지에 대한 연산 처리방식이다.

 

B가 당시 조회한 금액 100원

redis의 원자적 연산 decr 연산을 수행하여 차감한다.

redis에는 현재 A가 상금을 가져갔으므로 (100 - A가 가져간금액 + @) 원이 있을 것이다.

 

redis 상금 - B가 조회했던 100원 = 음수값

이 음수값은 추후에 보정을 해줘야 되는 값을 의미한다.

 

그래서 조회했던 100원 - 절대값(위에서 계산한 음수값)

 

redis에 쌓인 상금이 1원이라면

e.g)

1 - 100 = -99

 

100 - |-99| = 1

 

B에게는 1원을 지급하고 절대값 99원을 다시 redis에 INCR 연산을 진행해준다.

 

이렇게해서 2명이 동시에 당첨되었을 때는 보장이 가능해졌었다.

 

그럼, 3명이 되었을 때는요?

이제부터 문제가 발생한다.

음수의 보정을 한다고해서 계속해서 음수값으로 유지되는 상황이면 보정값도 의미가 없다.

다시 말해 일회성 보정 그 이상이하도 아니다.

 

위의 로직에서도 문제가 있었던건 조회와 incr or decr 연산이 하나로 뭉쳐진 원자적 연산은 아니었다는 것이다.

get의 시점과 증감하는 시점이 다르기 때문에 당연히 동시성에 위배될 수 있는 코드였다.

-> Race Condition 이라고 부른다.

https://redis.io/glossary/redis-race-condition/

사실 위의 코드도 문제가 있던 것이다.

 

그럼 이제 이 시점까지 왔다면 조회와 갱신하는 부분을 전부 통합해서 수행해주면 된다는 것을 알았을 것이다.

 

  • 1등 뽑기
  • get 상금
  • decr 상금
    • 획득하는 상금이 일정 금액까지 도달하지 못했다면 뽑기를 1등이 아닌 등수가 나올때까지 재시도한다.

이게 하나로 묶여야되는데 딱 떠오르는 1가지.

 

분산락 사용하기.

분산락 사용하면 1원씩 계속 넣고 빼고 상금을 처리하는데에 있어서 트래픽이 몰리면 답도 없을 것 같았다.

락을 잡지 않는 선에서 최대한 쳐내야했다.

 

✅ Lua Script 사용하기

이 스크립트를 사용해서 get과 decr 상금 을 엮어서 하나의 스크립트로 처리해주면 lock도 잡히지 않고 연산이 그리 복잡하지 않기 때문에 빠르게 처리해줄 수 있을 것 같았다.

그래서 안에 들어가는 로직은 1등이지만 제한된 누적상금 이상이어야 당첨되도록 하는 로직만 들어가면 됐다.

 

아래 스크립트를 대략적으로 작성했다. 

실제 업무코드에서는 이렇게 작성하지는 않았다.

String luaScript = """
            local current_amount = tonumber(redis.call('GET', KEYS[1]))
            local prize = tonumber(ARGV[1])
            
            if current_amount == nil then
                return redis.error_reply("키가 존재하지 않습니다.")
            end
            
            if prize < 100 then
                return redis.error_reply("재시도")
            end
            
            if current_amount < prize then
                return redis.error_reply("현재 상금보다 더 많은 값을 뺄 수 없습니다.")
            end
            
            redis.call('DECRBY', KEYS[1], prize)
            return current_amount - prize

        """;

 

get과 decr 연산을 묶어서 수행하며 상금 금액 limit 조건만 추가 해줬다.

 

그리고 `redisTemplate.execute()` 를 활용하여 해당 lua 스크립트를 실행해주면 정상적으로 동작했다.



여기서 유의해야할 점은 키에 해시태그를 이용하여 같은 슬롯에 구성해줘야한다!!!

 

redis가 클러스터링되어 운영되고 있다면 반드시 지켜주어야한다!!

 

redis에서 싱글스레드로 동작하기에 lock보다 더 괜찮은 원자적연산으로 동시성 이슈를 해결했다!!!

 

참고자료 - Line 기술블로그

728x90
반응형

'NoSQL' 카테고리의 다른 글

Redis Cache 성능 저하 이슈 수정  (0) 2025.03.26