1. 동시성 문제 발견
대기열 진입 API의 부하 테스트 진행 도중에 한가지 이상한 점을 발견하였다.
작업열 인원 제한을 100으로 지정했음에도 불구하고 동시 테스트 시에는 다음과 같이 결과 값이 100이 넘는 것을 확인할 수 있었는데..


동시에 많은 요청을 받게 되었을 때 발생하는 동시성 문제라는 것을 파악할 수 있었다. 문제의 원인은 코드에서 바로 찾을 수 있었는데, 현재 Redis에 저장된 작업열 인원수 Counter 조회를 통해 작업열 인원 여유를 확인하고 그에 따라 사용자를 대기열 혹은 작업열로 진입시키고 있다. 이때 여러 스레드가 동시에 작업열 인원 가능 여부를 확인하는 분기문을 통과함에 따라, 결과적으로 제한 개수 이상의 작업열 토큰이 저장되는 것이었다.
@Override
public GeneralQueueTokenResponse enterWaitingQueue(String userId, String performanceId) {
// 작업열 인원 여유 확인
AvailableSlots availableSlots = workingQueueRepository.countAvailableSlots(CountAvailableSlotsCommand.create(performanceId));
if (availableSlots.isLimited()) {
return getWaitingTokenResponse(userId, performanceId);
}
return getWorkingTokenResponse(userId, performanceId);
}
@Override
public AvailableSlots countAvailableSlots(CountAvailableSlotsCommand command) {
RAtomicLong counter = redisRepository.getAtomicLong(command.getQueueName());
return AvailableSlots.from(counter.get());
}
2. Redis에서의 트랜잭션
알다시피 Redis는 싱글 스레드 기반으로 연산을 Atomic하게 처리한다. 하지만 이것이 일련의 연산들을 하나의 트랜잭션처럼 실행하는 것을 의미하는 것은 아니다.
2.1. SessionCallBack
SessionCallback 인터페이스를 통해 직접적으로 Redis 명령어를 사용하여 트랜잭션 경계를 설정할 수 있다. multi()로 시작하고 exec()로 실행하는 방식이며, 모든 명령어는 큐에 저장되었다가 한 번에 실행된다.
장점
- Java 코드로 직관적인 구현 가능
- 디버깅 쉬움
단점
- 각 명령이 별도의 네트워크 통신 발생
- 복잡한 로직 구현시 성능 저하
- 클러스터 모드에선 MULTI/EXEC 트랜잭션 미지원
2.2. Lua 스크립트
Lua 스크립트는 Redis 서버에서 단일 트랜잭션으로 실행된다. 따라서 작성한 스크립트가 실행될 때 중간에 다른 명령이 끼어들 수 없게 되어 일련의 연산들을 Atomic하게 처리할 수 있다.
장점
- 단일 네트워크 호출로 처리
- 복잡한 로직도 하나의 단위로 실행 가능
- Redis 서버에서 직접 실행되어 빠름
단점
- 디버깅 어려움
- 스크립트 유지보수 어려움
선택 - Lua 스크립트
대기열 진입은 우리 서비스에서 가장 큰 트래픽을 받아야하는 기능이다.
따라서 가장 중요한 것은 연산 속도이다. 또한 대기열 서비스의 트래픽이 너무 많을 경우 Redis를 수평적으로 확장하는 클러스터 모드를 사용할 상황이 올 수 있기 때문에 Lua 스크립트를 사용하고자 한다.
비록 디버깅과 유지보수가 어렵다는 단점이 있지만, 한 번 작성되면 이후 로직 자체는 크게 변경될 여지가 없다 판단되어 충분히 합리적인 선택이라고 한다.
3. 스크립트 작성
기존의 작업 인원 조회 및 분기에 의한 처리는 다음과 같이 스크립트로 작성할 수 있다.
saveTokenScript.lua
-- 변수 선언
local waitingQueueName = KEYS[1]
local workingQueueName = KEYS[2]
local workingQueueTokenKey = KEYS[3]
local maxSlots = tonumber(ARGV[1])
local tokenValue = ARGV[2]
local enterTime = ARGV[3]
local cacheValue = ARGV[4]
local ttl = tonumber(ARGV[5])
-- 작업열 여유 인원 조회
local currentWorkers = tonumber(redis.call('GET', workingQueueName) or 0)
local availableSlots = maxSlots - currentWorkers
-- 작업열 저장
if availableSlots > 0 then
if redis.call('EXISTS', workingQueueTokenKey) == 0 then
redis.call('SET', workingQueueTokenKey, cacheValue, 'EX', ttl)
redis.call('INCR', workingQueueName)
end
return 1
-- 대기열 저장
else
if redis.call('EXISTS', workingQueueTokenKey) == 0 then
redis.call('ZADD', waitingQueueName, enterTime, tokenValue)
return 0
end
return 1
end
4. 테스트 결과
Lua 스크립트 적용 후에 작업열 인원 제한 100으로 500개의 스레드 실행 시에 다음과 같이 제대로 Counter 값이 100으로 조회되는 것을 확인할 수 있다!


'Project > 티켓핑' 카테고리의 다른 글
| Redis Cluster 구축하기 (0) | 2025.06.03 |
|---|---|
| WebFlux 전환하기 (0) | 2025.05.12 |
| 작업열 토큰의 만료 이벤트 처리하기 2 (0) | 2025.05.10 |
| 작업열 토큰의 만료 이벤트 처리하기 (0) | 2025.05.10 |
| 대기열 시스템 구상하기 (0) | 2025.05.10 |
