

대부분의 서비스 환경에서 동시성 이슈는 중요한 문제입니다. 특히 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 메서드는 다음과 같은 과정을 거칩니다.
이제 동시성 환경에서 이 코드가 정상적으로 동작하는지 확인하기 위해 테스트를 진행해 보겠습니다.
아래 테스트 코드는 멀티 스레드 환경에서 위의 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 때문입니다. 여러 스레드가 동시에 값을 읽고, 증가시키고, 저장하는 과정에서 데이터가 유실되는 문제가 발생합니다.
위의 동작을 시퀀스로 정리하면 다음과 같습니다.
get(key) 실행 → 값: 5000get(key) 실행 → 값: 5000set(key, 5001) 실행set(key, 5001) 실행 → 값이 덮어씌워짐즉, 여러 개의 스레드가 동일한 값을 읽고 처리하기 때문에 중간에 데이터가 유실되는 문제가 발생합니다. 이를 해결하려면 하나의 원자적(atomic) 연산으로 실행해야 합니다.
Redis에서는 Lua 스크립트를 사용하면 하나의 명령어처럼 실행되는 원자적 연산(Atomic Operation) 을 만들 수 있습니다. 이를 활용하여 동시성 문제를 해결할 수 있습니다.
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을 방지할 수 있습니다.