Redis: Lua 를 통해 동시성 문제 해결하기

Coding Turtle·2025년 1월 30일
post-thumbnail

개요

대부분의 서비스 환경에서 동시성 이슈는 중요한 문제입니다. 특히 Redis를 사용하여 값을 읽고 수정하는 경우, 여러 클라이언트가 동시에 접근하면 Race Condition(경쟁 상태) 문제가 발생할 수 있습니다. 이를 해결하는 방법 중 하나가 Lua 스크립트를 활용하는 것입니다. 본 글에서는 Spring Boot 3 기반에서 Redis를 사용할 때 발생하는 동시성 문제와 이를 Lua 스크립트를 통해 해결하는 방법을 설명합니다.

샘플코드 보기


문제의 상황

코드를 통해서 예시를 보여드리겠습니다. 아래 코드는 Redis에서 값을 가져와 1 증가시킨 후 다시 저장하는 로직을 포함하는 코드입니다.

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;


@Component
@RequiredArgsConstructor
public class ApplicationCounterRedisOperation implements CounterRedisOperation{
    private final StringRedisTemplate redisTemplate;
    private static final String KEY = "counter";

    @Override
    public void increment() {
        // 1. redis 값 조회
        String value = redisTemplate.opsForValue().get(KEY);
        int intValue = (value != null) ? Integer.parseInt(value) : 0;
        
        // 2. 값 증가
        intValue++;
        
        // 3. 값 저장
        redisTemplate.opsForValue().set(KEY, String.valueOf(intValue));
    }
}

위 코드에서 incrementValue 메서드는 다음과 같은 과정을 거칩니다.

  1. Redis에서 현재 값을 가져옴
  2. 값을 1 증가
  3. 증가된 값을 Redis에 다시 저장

이제 동시성 환경에서 이 코드가 정상적으로 동작하는지 확인하기 위해 테스트를 진행해 보겠습니다.


테스트 코드

아래 테스트 코드는 멀티 스레드 환경에서 위의 incrementValue 메서드를 실행하여 동시성 문제를 확인하는 코드입니다.


@Import(TestcontainersConfiguration.class)
@SpringBootTest
public class RedisConcurrencyTest {
    @Autowired
    private ApplicationCounterRedisOperation applicationCounterRedisOperation;
    
    @Autowired
    private StringRedisTemplate redisTemplate;

    @Test
    void testApplication() throws InterruptedException {
        final int threadCount = 100;
        final int iterationCount = 100;
        int result = testConcurrentIncrement(applicationCounterRedisOperation, 100, 100);
        assertThat(result).isNotEqualTo(threadCount * iterationCount);
    }


    private int testConcurrentIncrement(CounterRedisOperation counterRedisOperation, int threadCount, int iterationCount) throws InterruptedException {
        redisTemplate.opsForValue().set("counter", "0"); // 초기값 설정
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        
        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                for (int j = 0; j < iterationCount; j++) {
                    counterRedisOperation.increment();
                }
            });
        }
        
        executor.shutdown();
        while (!executor.isTerminated()) {
            Thread.sleep(100);
        }
        
        String finalValue = redisTemplate.opsForValue().get("counter");
        System.out.println("최종 결과값: " + finalValue);
        return Integer.parseInt(finalValue);
    }
}

테스트 코드의 결과

이론적으로 counter 값은 100 (스레드 수) × 100 (반복 횟수) = 10,000 이어야 합니다. 하지만 실제 실행 결과는 다르게 나옵니다.

최종 결과값: 209  (or 다른 값)

이유는 Race Condition 때문입니다. 여러 스레드가 동시에 값을 읽고, 증가시키고, 저장하는 과정에서 데이터가 유실되는 문제가 발생합니다.


문제에 대한 설명

위의 동작을 시퀀스로 정리하면 다음과 같습니다.

  1. 스레드 A가 get(key) 실행 → 값: 5000
  2. 스레드 B가 get(key) 실행 → 값: 5000
  3. 스레드 A가 값 5001로 증가 후 set(key, 5001) 실행
  4. 스레드 B가 값 5001로 증가 후 set(key, 5001) 실행 → 값이 덮어씌워짐

즉, 여러 개의 스레드가 동일한 값을 읽고 처리하기 때문에 중간에 데이터가 유실되는 문제가 발생합니다. 이를 해결하려면 하나의 원자적(atomic) 연산으로 실행해야 합니다.


근본적인 해결책: LUA 스크립트

Redis에서는 Lua 스크립트를 사용하면 하나의 명령어처럼 실행되는 원자적 연산(Atomic Operation) 을 만들 수 있습니다. 이를 활용하여 동시성 문제를 해결할 수 있습니다.

개선된 코드 (Lua 스크립트 적용)

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Collections;

@Component
@RequiredArgsConstructor
public class LuaCounterRedisOperation implements CounterRedisOperation{
    private final StringRedisTemplate redisTemplate;
    private static final String KEY = "counter";
    private static final String LUA_SCRIPT = """
        local current = redis.call('GET', KEYS[1])
        if not current then
            current = 0
        else
            current = tonumber(current)
        end
        current = current + 1
        redis.call('SET', KEYS[1], current)
        return current
    """;

    @Override
    public void increment() {
        redisTemplate.execute(new DefaultRedisScript<>(LUA_SCRIPT, Long.class), Collections.singletonList(KEY));
    }
}

""" (텍스트 블록)

  • """ (텍스트 블록)은 Java 17부터 지원되는 기능으로, 여러 줄 문자열을 간결하게 표현할 수 있습니다.
  • 이스케이프 문자(\n) 없이도 줄바꿈이 자동 적용됩니다.
  • """ 내부에서 "를 직접 사용할 수 있어 가독성이 높아집니다.
  • 코드 가독성을 높이고, 기존의 + 연산자로 연결하는 방식보다 유지보수가 쉽습니다.

테스트 코드 실행 결과

Lua 스크립트를 적용한 후 동일한 테스트 코드를 실행하면, 결과가 기대값인 10,000이 정확하게 출력됩니다.

최종 결과값: 10000

이를 통해 Lua 스크립트를 사용하면 동시성 문제가 해결됨을 확인할 수 있습니다.


마무리

Redis를 사용할 때 동시성 문제가 발생하는 주요 원인은 get 후 set의 비원자적 실행 때문입니다. 이를 해결하기 위해 Redis의 Lua 스크립트를 활용하면 원자적으로 실행할 수 있어 Race Condition을 방지할 수 있습니다.

profile
고민을 많이 할지도 모르는 Backend Software Engineer 입니다.

0개의 댓글