코딩은 마라톤

[Redis] Lua Script로 동시성 문제 해결하기 본문

Backend

[Redis] Lua Script로 동시성 문제 해결하기

anxi 2025. 6. 9. 21:51

 

https://developer-anxi.tistory.com/76

 

[Redis] 외부 API Rate Limiter를 만들어보기 (with. AOP)

SPOT은... 외부 API 호출이 많다. SPOT의 메인 기능은 애플리케이션 레벨에서 최적의 중간 지점(역)을 계산하고, 이후 여러 출발지부터 중간 지점까지의 경로를 보여주는 것이다.이때 출발지 ~ 중간

developer-anxi.tistory.com

 

위 글과 이어지는 내용이라서 먼저 읽어주시면 좋을 거 같습니다 🙏🙏🙏

 

문제 인식 : 멀티스레드 환경에서 Race Condition이 발생

@Test
void 멀티스레드_환경에서_RedisRateLimiter_원자성을_보장한다() throws InterruptedException {
    // given
    LimitRequestPerDay annotation = new LimitRequestPerDay() {
        @Override
        public String key() {
            return "test";
        }

        @Override
        public int count() {
            return 10;
        }

        @Override
        public Class<? extends Annotation> annotationType() {
            return LimitRequestPerDay.class;
        }
    };

    int totalThreads = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);
    CountDownLatch latch = new CountDownLatch(totalThreads);

    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    for (int i = 0; i < totalThreads; i++) {
        executorService.submit(() -> {
            try {
                rateLimiter.tryApiCall(annotation);
                successCount.incrementAndGet();
            } catch (ClientException e) {
                failCount.incrementAndGet();
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    executorService.shutdown();

    System.out.println("성공 횟수: " + successCount.get());
    System.out.println("실패 횟수: " + failCount.get());

    Assertions.assertEquals(10, successCount.get());
}

 

 

    @Override
    public void tryApiCall(LimitRequestPerDay limitRequestPerDay) {
        String key = limitRequestPerDay.key();
        String value = redisRateLimitTemplate.opsForValue().get(key);
        if (value == null) {
            redisRateLimitTemplate.opsForValue().set(key, "1", getTTL());
            return;
        }

        long previousCount = Long.parseLong(value);
        if ((int) previousCount >= limitRequestPerDay.count()) {
            // Todo. Discord Notification
            throw new ClientException(ClientErrorType.EXCEED_RATE_LIMIT_PER_DAY);
        }

        redisRateLimitTemplate.opsForValue().increment(key);
    }

 

위 코드는 RedisRateLimiter의 로직이다.

  1. key를 이용해 [GET] 명령어를 수행한다.
    1. 없을 경우, [SET] 명령어를 이용해 새로운 value를 삽입한다.
  2. 있을 경우, [INCR] 명령어를 수행한다.

INCR은 원자적으로 처리된다.  결국 동시성 이슈가 발생하는 부분은 바로 [GET] 조회할 때다.

 

Redis에서 1) MUlTI 명령어 (트랜잭션), 2) Lock, 3) Lua Script를 통해 원자적으로 처리할 수 있다.

근데 글 제목에 적혀있듯이, 나는 Lua Script를 사용하여 동시성 이슈를 해결했다. 

 

각 방법의 장단점과 Lua Script를 통해 해결한 과정을 알아보자.


1) MULTI 명령어 (트랜잭션)

A 클라이언트에서 특정 작업을 처리하고 있을 때, B 클라이언트에서 A 클라이언트가 작업 중인 KEY에 작업을 하게 되면 이때 Race Condition이 발생한다. 

 

Redis에서는 트랜잭션을 처리하기 위해 MULTI/EXEC 명령어를 사용하여, 다른 클라이언트의 간섭 없이 원자적으로 처리할 수 있다.

MULTI 

SET A 10
INCR A
INCR A
GET A

EXEC

 

간단한 예시를 통해 MULTI/EXEC로 트랜잭션 실행 과정을 알아보자!

1. MULTI 명령어를 통해 트랜잭션 시작
2. SET, INCR, GET 명령어는 QUEUE에 적재
3. EXEC 명령어를 통해 QUEUE에 적재된 명령어가 원자적으로 처리

 

✅ 장점

가장 큰 장점은 단순하다. MULTI, EXEC 명령어만 사용하면 원자적 처리를 기대할 수 있다.

 

❌ 단점

MULTI

  SET ERR 10        # ✅ 성공 - 문자열 값 10 저장
  INCR ERR          # ✅ 성공 - 10을 숫자로 인식해서 11로 증가

  SET ERR A         # ✅ 성공 - 문자열 값 A로 덮어쓰기
  INCR ERR          # ❌ 실패 - ERR 값이 "A"이므로 숫자가 아님 (에러 발생)

EXEC
1) OK
2) (integer) 11
3) OK
4) (error) ERR value is not an integer or out of range

 

위 명령어 집합에서 현재 ERR 값이 A일 경우 INCR하는 과정에서 실패한다.

RDBMS에서의 트랜잭션으로 생각해보면, 전체 트랜잭션을 롤백된다.

 

하지만 Redis의 트랜잭션은 에러가 발생하더라도 롤백을 제공하지 않는다.

 

 

🧐 도입하지 않은 이유

트랜잭션은 Redis 내부에서만 원자성을 보장한다.
즉, Spring과 같은 멀티스레드 환경에서 여러 클라이언트가 동시에 동일한 키에 접근하면, MULTI/EXEC만으로는 Race Condition을 방지할 수 없다.

 

Redis에서 WATCH 명령어를 통해서 낙관적 락을 사용할 수도 있긴 하다. 하지만 낙관적 락은 말 그대로 충돌이 발생하지 않을 것을 가정하고 락을 거는 것이기 때문에, 현재 API 호출이 병렬적으로 발생하는 SPOT 서비스 특성상 적합하지 않다고 판단했다.


2. Lock

구글 레디스 추천 검색어

 

 

구글에 레디스를 검색하면 분산락이라는 단어가 상위에 뜨는 것을 볼 수 있다.

대부분의 기술 블로그를 보면, Lettuce를 이용한 Spin Lock, Reddison을 이용한 Distributed Lock을 사용해 Redis로 락을 걸곤 한다.

 

✅ 장점

락을 걸면 여러 요청이 동시에 같은 리소스(Critical Section)에 접근하려 할 때, 오직 하나의 요청만 접근을 허용하게 된다.

 

또한 분산 락을 사용하면, 시스템이 단일 서버 환경을 넘어 다중 서버로 확장되었을 때도 공통 리소스에 대한 접근을 일관되게 제어할 수 있다.

 

❌ 단점

Java에서 제공하는 synchronized, ReentrantLock과 비교했을 때,

분산 락은 상대적으로 구현 복잡성이 높고 락을 획득하고 해제하는 과정에서 네트워크 지연이나 성능 저하가 발생할 수 있다.

 

🧐 도입하지 않은 이유

Redisson은 강력한 분산 락 기능을 제공하는데, 단순한 요구 사항에 비해 의존성이 추가되는 점이 부담되어 도입하지 않았다.


3. Lua Script

Redis는 내장 스크립트 언어로 루아(Lua)를 채택하고 있다.

Redis 명령어만으로 구현할 수 없는 경우, 루아를 통해 빠르게 처리할 수 있다.

 

루아는 단순 명령어 조합 이상의 복잡한 작업을 수행할 때, 모든 작업을 원자적으로 처리할 수 있다!

 

 

위 특징이 Race Condition을 해결하는데 가장 중요하다.

또한 Redis는 2.6 버전부터 내장된 Lua Script Engine을 이용하여 서버에서 Lua Script를 실행할 수 있기 때문에 외부 클라이언트 의존성을 추가할 필요가 없다.

 

물론, 자바 언어가 아닌 루아 API를 사용해 스크립트를 작성해야하고 복잡한 로직이 포함된 스크립트라면 처리 시간이 오래 걸릴 수 있다.

하지만 현재 기능은 조회(조건) -> INCR 인 단순한 로직이기에 Lua Script가 가장 적합하다고 생각했다.

 

🚗 적용 과정

기존 코드에서 추가로 적용된 부분은 Lua Script와 Redis Template으로 Lua Script를 불러오는 코드만 추가하면 된다.

 

src/main/java/com/meetup/server/global/config/RedisScriptConfig.java

@Configuration
public class RedisScriptConfig {

    @Bean
    public RedisScript<Long> redisRateLimitScript() {
        Resource redisRateLimitScript = new ClassPathResource("scripts/rate_limit_script.lua");
        return RedisScript.of(redisRateLimitScript, Long.class);
    }
}

 

src/main/java/com/meetup/server/global/clients/util/RedisRateLimiter.java

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisRateLimiter implements RateLimiter {

    private final RedisTemplate<String, String> redisRateLimitTemplate;
    private final RedisScript<Long> redisRateLimitScript;

    @Override
    public void tryApiCall(LimitRequestPerDay limitRequestPerDay) {
        String key = limitRequestPerDay.key();
        int limitCount = limitRequestPerDay.count();

        Long result = redisRateLimitTemplate.execute(
                redisRateLimitScript,
                Collections.singletonList(key),
                String.valueOf(getTTL().toSeconds()),
                String.valueOf(limitCount)
        );

        if (result == -1) {
            throw new ClientException(ClientErrorType.EXCEED_RATE_LIMIT_PER_DAY);
        }
        ...
    }
}

 

src/main/resources/scripts/rate_limit_script.lua

local key = KEYS[1]
local current = tonumber(redis.call('GET', key))
local limitCount = tonumber(ARGV[2])

if not current then
    redis.call('SET', key, 1, 'EX', ARGV[1])
    return 1
end

if current >= limitCount then
    return -1
else
    redis.call('INCR', key)
    return current + 1
end

 

 

 

조건 동작 반환값
키 없음 (첫 접근) SET + TTL 설정 1
제한 초과 차단 (return -1) -1
제한 이내 INCR 후 값 반환 N

 

마무리

Redis는 싱글 스레드 기반으로 동작하기 때문에, 하나의 명령어는 원자적으로 처리된다.

하지만 자바 스프링 애플리케이션은 멀티 스레드 환경에서 Redis에 접근하게 되며, 단일 명령어가 아닌 여러 명령어 조합 (예: GET → INCR)으로 이루어진 로직의 경우 그 사이에 다른 요청이 끼어들 수 있다.

따라서 멀티 스레드 환경에서는 Redis의 단일 명령어의 원자적인 처리만으로는 충분하지 않기 때문에, Lua Script를 이용해 동시성 문제를 해결했다 🥳