이번에 Redis 기반 쿠폰 발급 시스템을 구현하면서, 동시에 수많은 사용자가 쿠폰을 요청하는 상황에서 동시성 이슈를 해결하기 위한 방법을 고민하게 되었다. 단순히 Redis의 INCR
, SET
, EXISTS
명령어를 순차적으로 호출하면 중간에 끼어드는 요청 때문에 비정상적인 처리가 발생할 수 있다.
지금 구현하고 있는 선착순 쿠폰 서비스는 다음 조건을 만족해야한다.
그래서 비정상적인 처리 흐름을 방지하고 위 조건을 만족하기 위해 Redis의 Lua 스크립트 기능을 사용했다.
Lua는 Redis에서 사용할 수 있는 경량 스크립트 언어로 Redis는 EVAL
명령어를 통해 Lua 코드를 내부적으로 실행할 수 있다. 중요한 점은 Redis에서 Lua 스크립트는 단일 트랜잭션처럼 실행되며 원자성(atomicity)이 보장된다는 것이다. 즉, 여러 Redis 명령어를 하나의 논리 단위로 묶어 실행하면 그 중간에 다른 명령이 끼어들지 못한다. 이 덕분에 race condition(경쟁 조건)을 피할 수 있다.
쿠폰 발급 로직은 다음의 순서로 진행된다.
사용자 중복 발급 여부 확인
coupon:{couponId}:user:{userId}
키가 이미 존재하는지 Redis에서 확인한다.재고 수량 체크 및 증가
INCR
명령어를 통해 coupon:{couponId}:count
값을 1 증가시킨다.정상 발급 처리
userKey
를 Redis에 저장한다.SET userKey true EX 300
형태로 저장하며, TTL(Time-To-Live)을 5분(300초)으로 설정한다.public class RedisCouponService {
private final StringRedisTemplate redisTemplate;
private static final String COUPON_COUNT_KEY_PREFIX = "coupon:%d:count";
private static final String USER_ISSUED_KEY_PREFIX = "coupon:%d:user:%d";
// Lua 스크립트 정의 (원자적 실행)
private static final String LUA_SCRIPT = """
local userKey = KEYS[1]
local countKey = KEYS[2]
local total = tonumber(ARGV[1])
if redis.call("EXISTS", userKey) == 1 then
return 1
end
local current = redis.call("INCR", countKey)
if current > total then
return 2
end
redis.call("SET", userKey, "true", "EX", 300)
return 0
""";
public CouponIssueEnum tryIssueCoupon(Long couponId, Long userId, int totalCount) {
String userKey = String.format(USER_ISSUED_KEY_PREFIX, couponId, userId);
String countKey = String.format(COUPON_COUNT_KEY_PREFIX, couponId);
DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
Long result = redisTemplate.execute(
script,
Arrays.asList(userKey, countKey),
String.valueOf(totalCount)
);
if (result == null) return CouponIssueEnum.FAIL;
log.info("[Redis] Lua 실행 결과: {}, couponId={}, userId={}", result, couponId, userId);
return switch (result.intValue()) {
case 1 -> CouponIssueEnum.ALREADY_ISSUED;
case 2 -> CouponIssueEnum.OUT_OF_STOCK;
default -> CouponIssueEnum.SUCCESS;
};
}
public void rollbackIssue(Long couponId, Long userId) {
String userKey = String.format(USER_ISSUED_KEY_PREFIX, couponId, userId);
redisTemplate.delete(userKey);
}
}
local userKey = KEYS[1]
local countKey = KEYS[2]
local total = tonumber(ARGV[1])
local
: 지역 변수 선언 키워드KEYS
: Java에서 redisTemplate.execute()
의 두 번째 파라미터(Arrays.asList(...)
)로 전달된 값이 이 배열로 매핑된다. 보통 Redis 키들을 의미한다.ARGV
: 세 번째 파라미터로 전달되는 추가 인자 목록이다. 위에선 쿠폰 수량(total
)을 의미한다.tonumber()
: 문자열로 들어온 ARGV를 숫자로 변환하는 Lua 함수이다.if redis.call("EXISTS", userKey) == 1 then
return 1
end
redis.call("명령어", 인자...)
형식을 사용한다.EXISTS
는 해당 키가 존재하면 1을 반환함. 이미 발급받은 유저라면 중복이므로 1을 반환하고 종료한다.local current = redis.call("INCR", countKey)
if current > total then
return 2
end
INCR
은 값을 1 증가시키는 명령이다. 증가된 값을 current
에 저장한다.current > total
이면 이미 재고를 초과한 것이므로 2를 반환한다.redis.call("SET", userKey, "true", "EX", 300)
return 0
SET
명령으로 사용자에게 쿠폰 발급 이력을 기록한다. "EX", 300
은 TTL 5분 설정하는 것이다.이 전체 과정으로 하나의 Lua 스크립트 내부에서 원자적으로 실행되고 중간에 다른 명령이 끼어들 수 없다. 이로 인해 Redis 명령어들을 따로따로 호출했을 때 발생할 수 있는 경쟁 조건(Race Condition) 을 방지할 수 있고 일관성 있는 쿠폰 발급 처리가 가능해진다.
Lua 스크립트는 동시성 처리를 안전하고 빠르게 할 수 있다는 점에서 매우 유용하다는 걸 배웠다. 하지만 고민해보니 이 방식은 절대적인 선착순 보장을 하지는 않는다는 점도 인지해야 했다. Redis에 먼저 도달한 요청이 먼저 처리되기는 하지만 실제 사용자의 요청이 서버에 도달하는 순서, 스레드 스케줄링 등 외부 요인에 따라 순서가 바뀔 수 있다. 따라서 절대적인 순서를 보장하려면 다음 단계로는 Redisson의 분산 락 or Kafka 기반의 순서 처리 방식을 고려해 개선할 예정이다.