Redis Lua 스크립트를 활용한 선착순 쿠폰 발급 동시성 제어

송현진·2025년 4월 22일
0

Redis

목록 보기
2/5

이번에 Redis 기반 쿠폰 발급 시스템을 구현하면서, 동시에 수많은 사용자가 쿠폰을 요청하는 상황에서 동시성 이슈를 해결하기 위한 방법을 고민하게 되었다. 단순히 Redis의 INCR, SET, EXISTS 명령어를 순차적으로 호출하면 중간에 끼어드는 요청 때문에 비정상적인 처리가 발생할 수 있다.

지금 구현하고 있는 선착순 쿠폰 서비스는 다음 조건을 만족해야한다.

  1. 동시에 여러 요청이 들어와도 최대 발급 수량을 초과하지 않아야 한다.
  2. 한 사용자가 중복으로 쿠폰을 발급받지 못하도록 차단해야 한다.
  3. 이 모든 처리를 빠르고 정확하게 수행해야 한다.

그래서 비정상적인 처리 흐름을 방지하고 위 조건을 만족하기 위해 Redis의 Lua 스크립트 기능을 사용했다.

✏️ Lua 스크립트란?

Lua는 Redis에서 사용할 수 있는 경량 스크립트 언어로 Redis는 EVAL 명령어를 통해 Lua 코드를 내부적으로 실행할 수 있다. 중요한 점은 Redis에서 Lua 스크립트는 단일 트랜잭션처럼 실행되며 원자성(atomicity)이 보장된다는 것이다. 즉, 여러 Redis 명령어를 하나의 논리 단위로 묶어 실행하면 그 중간에 다른 명령이 끼어들지 못한다. 이 덕분에 race condition(경쟁 조건)을 피할 수 있다.

🔄️어떤 방식으로 동작하는가?

쿠폰 발급 로직은 다음의 순서로 진행된다.

  1. 사용자 중복 발급 여부 확인

    • coupon:{couponId}:user:{userId} 키가 이미 존재하는지 Redis에서 확인한다.
    • 이 키가 존재한다면 이미 쿠폰을 발급받은 사용자이므로 중복 발급으로 간주하여 실패 처리된다.
  2. 재고 수량 체크 및 증가

    • Redis의 INCR 명령어를 통해 coupon:{couponId}:count 값을 1 증가시킨다.
    • 증가된 값이 쿠폰의 총 수량을 초과하는 경우에는 이미 재고가 모두 소진된 상태이므로 발급 실패 처리한다.
  3. 정상 발급 처리

    • 위 조건을 모두 통과한 경우엔 해당 사용자에게 쿠폰이 발급되었음을 기록하기 위해 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);
    }
}

Lua 스크립트 문법 설명

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 명령어를 Lua에서 실행하려면 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분 설정하는 것이다.
  • 최종 성공 시 0을 반환한다.

이 전체 과정으로 하나의 Lua 스크립트 내부에서 원자적으로 실행되고 중간에 다른 명령이 끼어들 수 없다. 이로 인해 Redis 명령어들을 따로따로 호출했을 때 발생할 수 있는 경쟁 조건(Race Condition) 을 방지할 수 있고 일관성 있는 쿠폰 발급 처리가 가능해진다.

✅ 장점

  • 원자성 보장: 여러 Redis 명령어를 하나의 논리 단위로 묶어 실행하기 때문에 중간에 다른 요청이 끼어들 수 없고 트랜잭션처럼 처리된다.
  • 성능 최적화: Lua 스크립트는 Redis 서버 내부에서 실행되므로 클라이언트와의 불필요한 네트워크 왕복이 없다. 그래서 빠른 처리 속도를 유지할 수 있다.
  • 중복 방지 처리의 용이성: 사용자에 대한 발급 이력을 Redis에 기록하면서 TTL을 설정함으로써 일정 시간 동안 동일 사용자의 중복 요청을 자연스럽게 차단할 수 있다.

📝배운점

Lua 스크립트는 동시성 처리를 안전하고 빠르게 할 수 있다는 점에서 매우 유용하다는 걸 배웠다. 하지만 고민해보니 이 방식은 절대적인 선착순 보장을 하지는 않는다는 점도 인지해야 했다. Redis에 먼저 도달한 요청이 먼저 처리되기는 하지만 실제 사용자의 요청이 서버에 도달하는 순서, 스레드 스케줄링 등 외부 요인에 따라 순서가 바뀔 수 있다. 따라서 절대적인 순서를 보장하려면 다음 단계로는 Redisson의 분산 락 or Kafka 기반의 순서 처리 방식을 고려해 개선할 예정이다.

profile
개발자가 되고 싶은 취준생

0개의 댓글