본문 바로가기
Research Notes (개인)

Redis Lua Script 완전 정리

2) Lua Script (원자성, 락, 조건부 업데이트)

✅ Redis에서 Lua Script를 쓰는 이유는?

Redis는 단일 스레드로 명령을 처리합니다. Lua Script는 여러 명령을 하나의 원자적 단위로 묶어 실행할 수 있게 해줍니다.

주요 사용 이유:

  • 원자적 조건부 업데이트: "값이 X일 때만 Y로 바꿔라" 같은 로직
  • 분산락 해제: 락 토큰 비교 후 DEL을 하나의 원자 단위로
  • 복잡한 비즈니스 로직: 여러 키 읽기/쓰기를 RTT 없이 처리
  • Race condition 방지: GET → 판단 → SET 사이에 다른 클라이언트 끼어드는 문제 해결
-- 예: 재고가 있을 때만 차감 (조건부 업데이트)
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock > 0 then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1
end
return 0

✅ Lua Script는 어떤 의미에서 "원자적"인가요?

Redis의 단일 스레드 특성 때문에 원자적입니다.

[Client A: Lua Script 실행 중]  ──────────────────────────────▶
[Client B: SET key val        ]  ──── 대기 ────▶ (Script 완료 후 실행)
  • Lua Script가 실행되는 동안 다른 명령은 절대 끼어들 수 없음
  • DB의 원자성(ACID)과는 다름 → 롤백 없음, 실패해도 이전 명령은 그대로 적용됨
  • 즉, "중단되지 않음(non-preemptive)"의 의미에서 원자적

⚠️ 주의: 원자성 = "다른 명령이 중간에 못 들어온다" 이지, "실패 시 롤백"이 아님


✅ Lua로 분산락 구현 시 주의할 점 (해제 검증, 토큰 비교)

분산락의 핵심 문제: 내가 건 락을 내가 해제해야 함

-- ❌ 잘못된 방식: 토큰 확인 없이 DEL
-- GET으로 확인 후 DEL 사이에 다른 클라이언트가 락을 가져갈 수 있음
GET lock_key
DEL lock_key  -- Race condition!

-- ✅ 올바른 방식: Lua로 원자적 비교 후 해제
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

구현 시 주의사항:

항목설명
토큰(UUID)SET 시 고유 토큰 저장, 해제 시 토큰 일치 여부 확인
TTL 필수락 획득 시 반드시 SET key val NX PX {ttl} 형태로 만료 설정
NX 옵션SET NX (Not eXists)로 원자적 락 획득
클럭 드리프트Redlock 알고리즘에서는 여러 노드의 시간 차이 고려 필요
// Spring 예시
String token = UUID.randomUUID().toString();
// 락 획득
Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent("lock:key", token, Duration.ofSeconds(30));

// 락 해제 (Lua)
String script = "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
                "return redis.call('DEL', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
    List.of("lock:key"), token);

✅ Lua Script 사용 시 성능/운영 리스크

리스크설명대응
Long-running Script실행 중 다른 명령 전부 블로킹 → 응답 지연lua-time-limit(기본 5초) 설정, 스크립트 경량화
SCRIPT KILL타임아웃 시 강제 종료 가능하나 write 명령 포함 시 불가쓰기 포함 스크립트는 특히 주의
무한루프버그로 무한루프 시 Redis 전체 Hang충분한 테스트, 반복 횟수 제한
에러 시 부분 실행롤백 없으므로 일부만 실행된 상태로 남을 수 있음멱등성(Idempotent) 설계
디버깅 어려움로그가 제한적redis.log() 활용, 스테이징 충분히 검증

✅ EVAL vs EVALSHA 차이 / 운영 선호 방식

# EVAL: 매번 스크립트 전체 전송
EVAL "return redis.call('GET', KEYS[1])" 1 mykey

# EVALSHA: 스크립트를 캐싱 후 SHA1 해시로만 호출
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# → 반환: "e0e1f9fabfa9d353e30b78b8fcb840df5e2ba1c9"
EVALSHA e0e1f9fabfa9d353e30b78b8fcb840df5e2ba1c9 1 mykey
항목EVALEVALSHA
전송 데이터매번 전체 스크립트SHA1(40bytes)만 전송
네트워크상대적으로 많음최소화
캐시 필요불필요서버에 SCRIPT LOAD 선행 필요
캐시 유실 시항상 동작NOSCRIPT 에러 → 폴백 처리 필요

운영 선호: EVALSHA + 폴백 패턴

// EVALSHA 실패 시 EVAL로 폴백
try {
    return redisTemplate.execute(new DefaultRedisScript<>(sha, Long.class), keys, args);
} catch (RedisSystemException e) {
    if (e.getCause() instanceof JedisNoScriptException) {
        // SCRIPT LOAD 후 재시도
    }
}

✅ 클러스터 모드에서 Lua Script의 제약

핵심 제약: Lua Script 내에서 접근하는 **모든 키는 동일한 슬롯(slot)**에 있어야 함

Redis Cluster는 키를 16384개의 슬롯으로 분산합니다.

# ❌ 오류: 서로 다른 슬롯의 키 접근
EVAL "redis.call('GET', KEYS[1]); redis.call('GET', KEYS[2])" 2 key1 key2
# → CROSSSLOT Keys in request don't hash to the same slot

# ✅ 해결: Hash Tag {} 사용으로 같은 슬롯 강제
SET {user:1}:lock "token"
SET {user:1}:profile "data"
# {user:1} 부분으로 슬롯 결정 → 같은 슬롯 보장
// Hash Tag 사용 예
String lockKey = "{userId:" + userId + "}:lock";
String dataKey = "{userId:" + userId + "}:data";
// 두 키 모두 같은 슬롯에 배치됨


Lua Script에서도 부분 실행은 존재합니다


결론

네, 가능합니다. Lua도 롤백은 없기 때문에 "에러 이전 명령은 이미 반영된 상태" 가 됩니다.


redis.call() vs redis.pcall() 차이가 핵심

Lua에서 Redis 명령을 호출하는 방법이 두 가지입니다.

redis.call()   -- 에러 발생 시 → 스크립트 즉시 중단 (이후 명령 미실행)
redis.pcall()  -- 에러 발생 시 → 에러 객체 반환, 스크립트 계속 진행

케이스별 동작 비교

✅ Case 1: redis.call() - 에러 시 즉시 중단

redis.call('SET',  KEYS[1], 'value1')  -- ✅ 실행됨 (반영됨)
redis.call('SET',  KEYS[2], 'value2')  -- ✅ 실행됨 (반영됨)
redis.call('INCR', KEYS[2])            -- ❌ 에러! (string에 INCR)
                                       --    → 스크립트 즉시 중단
redis.call('SET',  KEYS[3], 'value3')  -- 🚫 실행 안 됨
결과:
  KEYS[1] = 'value1'  ← 반영됨
  KEYS[2] = 'value2'  ← 반영됨
  KEYS[3] = 없음      ← 실행 안 됨
  ⚠️ 부분 적용 상태!

✅ Case 2: redis.pcall() - 에러 잡고 계속 진행

redis.call('SET',   KEYS[1], 'value1')  -- ✅ 실행됨
local result = redis.pcall('INCR', KEYS[1])  -- ❌ 에러지만 잡힘

if result['err'] then
    -- 에러 처리 가능하지만 KEYS[1] SET은 이미 반영된 상태
end

redis.call('SET', KEYS[2], 'value2')  -- ✅ 계속 실행됨

MULTI/EXEC vs Lua Script 에러 동작 비교

[MULTI/EXEC - 런타임 에러]

MULTI
  SET  key1 "hello"   → ✅ 성공
  INCR key1           → ❌ 실패 (string에 INCR)
  SET  key2 "world"   → ✅ 성공  ← 에러 무시하고 계속 실행!
EXEC

결과: key1="hello", key2="world" (에러 건너뛰고 전부 실행)


[Lua Script - redis.call() 에러]

redis.call('SET',  key1, 'hello')  → ✅ 성공
redis.call('INCR', key1)           → ❌ 에러 → 즉시 중단!
redis.call('SET',  key2, 'world')  → 🚫 실행 안 됨

결과: key1="hello" (이후 명령은 실행 안 됨)
MULTI/EXECLua (redis.call)Lua (redis.pcall)
에러 시 동작해당 명령 스킵, 나머지 실행스크립트 즉시 중단에러 캡처, 계속 실행
이전 명령 롤백
이후 명령 실행
부분 실행 가능?✅ 항상 가능✅ 가능✅ 가능

그럼 Lua가 MULTI/EXEC보다 나은 이유는?

롤백은 없지만 "에러 이후 명령은 실행 안 함" 을 보장할 수 있습니다.

-- ✅ 사전 검증 패턴으로 부분 실행 최소화
local balance = tonumber(redis.call('GET', KEYS[1]))

-- 실행 전에 모든 조건을 먼저 검증
if not balance then
    return redis.error_reply("키 없음")
end
if balance < tonumber(ARGV[1]) then
    return redis.error_reply("잔액 부족")
end

-- 검증 통과 후에만 실제 명령 실행
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('LPUSH',  KEYS[2], ARGV[2])
-- 여기까지 오면 둘 다 실행 보장
return 1

이렇게 "검증 먼저, 실행 나중" 패턴으로 설계하면 부분 실행 가능성을 최소화할 수 있습니다.


최종 정리

┌──────────────────────────────────────────────────────┐
│              Redis 원자성 현실                         │
├──────────────────────────────────────────────────────┤
│  어떤 방법을 써도 "롤백"은 없다                         │
│                                                      │
│  MULTI/EXEC  → 부분 실행 가능 (에러 스킵 후 계속)      │
│  Lua Script  → 부분 실행 가능 (에러 이전 명령은 반영)   │
│                                                      │
│  ✅ Lua의 장점:                                       │
│    - 사전 검증 로직으로 부분 실행 가능성 최소화 가능     │
│    - MULTI/EXEC처럼 에러 무시하고 계속 실행 안 함       │
│                                                      │
│  ❌ 공통 한계:                                        │
│    - 이미 실행된 명령의 롤백은 불가                     │
│    - 완전한 All or Nothing은 Redis만으로 불가           │
└──────────────────────────────────────────────────────┘

Lua Script가 길어지면 가용성 문제가 생기는가?


결론

네, 맞습니다.
Lua Script는 실행 중 Redis 전체를 블로킹합니다.
→ Script가 길수록 다른 요청들의 대기 시간 증가

왜 문제가 되는가

Redis 큐:

[Lua Script 실행 중 (50ms)]
       │
       │  이 동안 대기
       ├── GET key1      ← 50ms 대기
       ├── SET key2      ← 50ms 대기
       ├── INCR counter  ← 50ms 대기
       └── ... 수천 개 요청들

Lua 하나가 50ms면
→ 뒤에 쌓인 요청들 전부 50ms 지연
→ 트래픽 많을수록 타임아웃 폭발

Redis의 안전장치: lua-time-limit

# redis.conf 기본값
lua-time-limit 5000  # 5초

# 5초 초과 시:
#   ① Redis가 다른 명령 수신은 하지만
#   ② SCRIPT KILL 또는 SHUTDOWN NOSAVE 명령만 받음
#   ③ 나머지 명령은 BUSY 에러 반환
lua-time-limit 초과 시 흐름:

Lua Script 실행 중 (5초 초과)
       │
       ▼
다른 클라이언트 명령 → BUSY error 반환
       │
       ▼
관리자가 SCRIPT KILL 실행
       │
       ├── 쓰기 명령 없었으면 → 즉시 종료
       └── 쓰기 명령 있었으면 → KILL 불가!
           (SHUTDOWN NOSAVE 만 가능 → 서버 종료!)

쿠폰 Lua Script의 실제 실행 시간

현재 작성한 Lua Script:

GET   stock_key      → O(1)  ← 0.1ms 이하
SADD  issued_key     → O(1)  ← 0.1ms 이하
DECRBY stock_key     → O(1)  ← 0.1ms 이하

총 실행시간: < 1ms

→ 사실 쿠폰 케이스는 문제 없음
→ 문제가 되는 건 "복잡한 로직 + 반복문"이 있는 경우

실제로 위험한 Lua Script 패턴

-- ❌ 위험: 반복문으로 대량 데이터 처리
local keys = redis.call('KEYS', 'user:*')  -- 전체 키 스캔!
for i, key in ipairs(keys) do
    redis.call('DEL', key)  -- N번 실행
end
-- → 키가 100만개면 수 초 블로킹

-- ❌ 위험: 무한루프 가능성
while true do
    local val = redis.call('GET', 'key')
    if val == 'done' then break end
    -- break 조건이 안 맞으면 무한루프
end

-- ✅ 안전: 단순 명령 조합 (쿠폰 케이스)
local stock = redis.call('GET', KEYS[1])    -- 1번
redis.call('SADD',   KEYS[2], ARGV[1])      -- 1번
redis.call('DECRBY', KEYS[1], 1)            -- 1번
-- → 항상 3번만 실행, 예측 가능

대안들과 트레이드오프

대안 1: Lua Script 유지 (현재 방식) - 권장

장점:
  ✅ 원자성 완전 보장
  ✅ 단순 명령 조합이면 < 1ms
  ✅ 쿠폰처럼 빠른 연산은 문제 없음

단점:
  ❌ 잘못 작성하면 전체 블로킹
  → 컨벤션으로 Lua 복잡도 제한 필요

대안 2: WATCH + MULTI/EXEC

// Lua 대신 WATCH로 대체
// 원자성은 약하지만 블로킹 시간 분산

for (int attempt = 0; attempt < 5; attempt++) {
    List<Object> result = redisTemplate.execute(
        new SessionCallback<>() {
            public List<Object> execute(RedisOperations ops) {
                ops.watch(List.of(stockKey, issuedKey));

                String stock = (String) ops.opsForValue().get(stockKey);
                if (Integer.parseInt(stock) <= 0) return null;

                Boolean alreadyIssued = ops.opsForSet()
                    .isMember(issuedKey, userId.toString());
                if (Boolean.TRUE.equals(alreadyIssued)) return null;

                ops.multi();
                ops.opsForValue().decrement(stockKey);
                ops.opsForSet().add(issuedKey, userId.toString());
                return ops.exec();
            }
        }
    );

    if (result != null) break;  // 성공
    // 충돌 시 재시도
}

// 단점: 충돌 많으면 재시도 폭발
//       원자성이 Lua보다 약함

대안 3: Redis SET NX (가장 단순)

// 재고를 Sorted Set으로 관리
// 발급 = 미리 생성된 쿠폰 코드를 SPOP으로 꺼내기

// 쿠폰 생성 시 미리 코드 생성해서 Set에 넣기
redisTemplate.opsForSet().add("coupon:pool:1",
    "CODE-001", "CODE-002", "CODE-003" ...
);

// 발급 = SPOP (원자적, Lua 불필요)
String couponCode = redisTemplate.opsForSet().pop("coupon:pool:1");
if (couponCode == null) throw new CouponException(OUT_OF_STOCK);
// → 단일 명령이라 블로킹 최소화
// → 재고 = Set 크기

// 단점: 쿠폰 코드 미리 생성 필요
//       중복 발급 방지 별도 처리 필요

Lua Script 블로킹 방어 가이드라인

✅ Lua Script 작성 규칙:

1. 반복문 금지 (또는 횟수 제한)
   -- ❌
   for i = 1, 1000000 do ... end

   -- ✅ 횟수 제한
   for i = 1, math.min(#items, 100) do ... end

2. KEYS * 금지
   -- ❌
   redis.call('KEYS', '*')

3. 명령 수 최소화
   -- 3~5개 이내 권장

4. 실행 시간 목표: < 1ms
   -- 단순 GET/SET/SADD 조합

5. lua-time-limit 설정
   lua-time-limit 1000  -- 5초 → 1초로 단축

쿠폰 케이스 실제 측정

현재 Lua Script 실행 시간 (로컬 Redis 기준):

명령 3개 (GET, SADD, DECRBY):
  평균: 0.1 ~ 0.3ms
  p99:  0.5ms

초당 처리량 (TPS):
  Lua Script: ~50,000 TPS
  일반 명령:  ~100,000 TPS

결론:
  쿠폰 발급 Lua는 충분히 안전한 수준
  트래픽이 초당 수만 건이어도 문제 없음

최종 판단

┌─────────────────────────────────────────────────────────────┐
│              Lua Script 블로킹 문제 결론                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  가용성 문제가 생기는 경우:                                  │
│    복잡한 로직 + 반복문 + 대량 데이터 처리                  │
│    → 수십 ms ~ 수 초 블로킹 가능                            │
│                                                             │
│  가용성 문제가 없는 경우:                                    │
│    단순 명령 조합 (3~5개)                                   │
│    → < 1ms, 실용적으로 안전                                 │
│                                                             │
│  쿠폰 발급 케이스:                                           │
│    GET + SADD + DECRBY = 3개 명령                           │
│    → 0.3ms 수준, 가용성 문제 없음                           │
│                                                             │
│  원칙:                                                       │
│    Lua는 "단순하고 짧게" 유지                               │
│    복잡한 로직 → 애플리케이션 레이어에서 처리               │
│    Lua에는 원자성이 필요한 핵심 명령만                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Lua Script 에러 시 동작 방식


핵심: redis.call vs redis.pcall

redis.call()  → 에러 시 즉시 중단 (스크립트 종료)
redis.pcall() → 에러 시 에러 객체 반환, 계속 실행

현재 코드 동작 방식

-- ② SADD (pcall 사용)
local sadd_result = redis.pcall('SADD', issued_key, user_id)
--                  ↑ pcall이므로 실패해도 스크립트 계속 실행

if sadd_result['err'] then
    return -2  -- 에러 확인 후 "직접" 중단 (자동 중단 아님!)
end

if sadd_result == 0 then
    return -1
end

-- ③ DECRBY (pcall 사용)
local decrby_result = redis.pcall('DECRBY', stock_key, 1)
--                    ↑ pcall이므로 실패해도 스크립트 계속 실행

if decrby_result['err'] then
    redis.call('SREM', issued_key, user_id)  -- 롤백 실행
    return -2  -- 에러 확인 후 "직접" 중단
end
즉, 현재 코드는:

에러 발생 → 자동 중단 X
           → pcall이 에러를 객체로 반환
           → if문으로 개발자가 직접 판단
           → return으로 직접 중단

call vs pcall 비교 예시

-- ❌ redis.call() 사용 시
redis.call('SADD',   issued_key, user_id)  -- 에러 발생!
redis.call('DECRBY', stock_key,  1)        -- 실행 안 됨 (자동 중단)
redis.call('SREM',   issued_key, user_id)  -- 실행 안 됨


-- ✅ redis.pcall() 사용 시 (현재 코드)
local result = redis.pcall('SADD', issued_key, user_id)  -- 에러 발생
-- 에러가 result에 담김 → 스크립트는 계속 실행됨!

if result['err'] then
    return -2  -- 여기서 개발자가 직접 중단
end

redis.call('DECRBY', stock_key, 1)  -- 위에서 return 했으므로 실행 안 됨

현재 코드의 정확한 흐름

[SADD 실패 시]

redis.pcall('SADD') → 에러 객체 반환
        │
        ▼
sadd_result['err'] 존재? → YES
        │
        ▼
return -2  ← 개발자가 직접 중단
        │
DECRBY 실행 안 됨  ✅ (return으로 빠져나갔으므로)
재고 변경 없음     ✅


[SADD 성공 → DECRBY 실패 시]

redis.pcall('SADD')   → 성공 (1 반환)
redis.pcall('DECRBY') → 에러 객체 반환
        │
        ▼
decrby_result['err'] 존재? → YES
        │
        ▼
redis.call('SREM', issued_key, user_id)  ← SADD 롤백
        │
        ▼
return -2  ← 개발자가 직접 중단

정리

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  질문: "에러가 나도 끝까지 실행하려 하나?"               │
│                                                         │
│  답:                                                    │
│    redis.call()  → 에러 시 자동 즉시 중단               │
│    redis.pcall() → 에러를 객체로 받고 계속 실행          │
│                   개발자가 if문으로 직접 판단/중단        │
│                                                         │
│  현재 코드는 pcall 사용                                  │
│    → 에러가 나도 자동 중단 아님                          │
│    → if(err) return -2 로 개발자가 직접 중단            │
│    → 덕분에 DECRBY 실패 시 SREM 롤백 로직 실행 가능     │
│                                                         │
└─────────────────────────────────────────────────────────┘

Redis Lua Script 실무 예시 완전 정리


1. 분산락 (Distributed Lock)

가장 많이 쓰이는 패턴

@Component
@Slf4j
@RequiredArgsConstructor
public class DistributedLockService {

    private final StringRedisTemplate redisTemplate;

    // 락 획득
    private static final String ACQUIRE_SCRIPT = """
        if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
            return 1   -- 획득 성공
        else
            return 0   -- 이미 락 존재
        end
        """;

    // 락 해제 (내 토큰인지 확인 후 삭제)
    private static final String RELEASE_SCRIPT = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        else
            return 0   -- 내 락이 아님 (이미 만료 or 다른 소유자)
        end
        """;

    // 락 연장 (작업이 길어질 때)
    private static final String EXTEND_SCRIPT = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('PEXPIRE', KEYS[1], ARGV[2])
        else
            return 0   -- 내 락이 아님
        end
        """;

    public boolean acquire(String key, String token, long ttlMs) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(ACQUIRE_SCRIPT, Long.class),
            List.of(key),
            token,
            String.valueOf(ttlMs)
        );
        return Long.valueOf(1L).equals(result);
    }

    public boolean release(String key, String token) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(RELEASE_SCRIPT, Long.class),
            List.of(key),
            token
        );
        return Long.valueOf(1L).equals(result);
    }

    public boolean extend(String key, String token, long ttlMs) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(EXTEND_SCRIPT, Long.class),
            List.of(key),
            token,
            String.valueOf(ttlMs)
        );
        return Long.valueOf(1L).equals(result);
    }
}

분산락 사용 예시

@Service
@RequiredArgsConstructor
public class OrderService {

    private final DistributedLockService lockService;

    public void processOrder(Long orderId) {
        String lockKey = "lock:order:" + orderId;
        String token   = UUID.randomUUID().toString();
        long   ttlMs   = 30_000L;  // 30초

        // 락 획득 재시도
        boolean acquired = false;
        for (int i = 0; i < 3; i++) {
            if (lockService.acquire(lockKey, token, ttlMs)) {
                acquired = true;
                break;
            }
            Thread.sleep(100L * (i + 1));
        }

        if (!acquired) throw new RuntimeException("락 획득 실패");

        try {
            // 비즈니스 로직 실행 중 락 연장
            doBusinessLogic(orderId, () -> {
                lockService.extend(lockKey, token, ttlMs);  // 주기적 연장
            });
        } finally {
            lockService.release(lockKey, token);  // 반드시 해제
        }
    }
}

2. 재고 관리 (Rate Limiting과 결합)

@Component
@RequiredArgsConstructor
public class StockService {

    private final StringRedisTemplate redisTemplate;

    // 재고 차감 + 이력 기록 원자적 처리
    private static final String DECREASE_STOCK_SCRIPT = """
        local stock_key   = KEYS[1]
        local history_key = KEYS[2]
        local quantity    = tonumber(ARGV[1])
        local order_id    = ARGV[2]

        -- 현재 재고 확인
        local stock = tonumber(redis.call('GET', stock_key))

        if not stock then
            return -1  -- 키 없음
        end

        if stock < quantity then
            return 0   -- 재고 부족
        end

        -- 재고 차감
        local remaining = redis.call('DECRBY', stock_key, quantity)

        -- 이력 기록 (최근 100개만 유지)
        redis.call('LPUSH', history_key, order_id .. ':' .. quantity)
        redis.call('LTRIM', history_key, 0, 99)

        return remaining  -- 남은 재고 반환
        """;

    // 재고 복구 (주문 취소 시)
    private static final String RESTORE_STOCK_SCRIPT = """
        local stock_key   = KEYS[1]
        local history_key = KEYS[2]
        local quantity    = tonumber(ARGV[1])
        local order_id    = ARGV[2]

        -- 재고 복구
        local restored = redis.call('INCRBY', stock_key, quantity)

        -- 취소 이력 기록
        redis.call('LPUSH', history_key, 'cancel:' .. order_id)
        redis.call('LTRIM', history_key, 0, 99)

        return restored
        """;

    public Long decreaseStock(Long productId, int quantity, Long orderId) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(DECREASE_STOCK_SCRIPT, Long.class),
            List.of(
                "stock:" + productId,
                "stock:history:" + productId
            ),
            String.valueOf(quantity),
            String.valueOf(orderId)
        );

        if (result == null || result == -1)
            throw new RuntimeException("재고 키 없음");
        if (result == 0)
            throw new RuntimeException("재고 부족");

        return result;  // 남은 재고
    }
}

3. API Rate Limiting (핵심 실무 패턴)

@Component
@RequiredArgsConstructor
public class RateLimiterService {

    private final StringRedisTemplate redisTemplate;

    // 슬라이딩 윈도우 방식 Rate Limit
    private static final String RATE_LIMIT_SCRIPT = """
        local key        = KEYS[1]
        local now        = tonumber(ARGV[1])  -- 현재 시간 (ms)
        local window     = tonumber(ARGV[2])  -- 윈도우 크기 (ms)
        local max_count  = tonumber(ARGV[3])  -- 최대 요청 수

        -- 윈도우 밖의 오래된 요청 제거
        redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

        -- 현재 윈도우 내 요청 수 확인
        local count = redis.call('ZCARD', key)

        if count >= max_count then
            return 0  -- 한도 초과
        end

        -- 현재 요청 기록
        redis.call('ZADD', key, now, now .. '-' .. math.random(100000))
        redis.call('PEXPIRE', key, window)

        return max_count - count - 1  -- 남은 요청 횟수
        """;

    public RateLimitResult checkLimit(String userId, int maxCount, long windowMs) {
        long now = System.currentTimeMillis();

        Long remaining = redisTemplate.execute(
            new DefaultRedisScript<>(RATE_LIMIT_SCRIPT, Long.class),
            List.of("rate_limit:" + userId),
            String.valueOf(now),
            String.valueOf(windowMs),
            String.valueOf(maxCount)
        );

        if (Long.valueOf(0L).equals(remaining)) {
            return RateLimitResult.exceeded();
        }

        return RateLimitResult.allowed(remaining);
    }

    @Getter
    @AllArgsConstructor
    public static class RateLimitResult {
        private final boolean allowed;
        private final long    remaining;

        public static RateLimitResult allowed(long remaining) {
            return new RateLimitResult(true, remaining);
        }
        public static RateLimitResult exceeded() {
            return new RateLimitResult(false, 0);
        }
    }
}
// Rate Limit AOP 적용
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {

    private final RateLimiterService rateLimiterService;

    @Around("@annotation(rateLimit)")
    public Object checkRateLimit(ProceedingJoinPoint pjp,
                                  RateLimit rateLimit) throws Throwable {

        String userId = SecurityContextHolder.getContext()
            .getAuthentication().getName();

        RateLimiterService.RateLimitResult result =
            rateLimiterService.checkLimit(
                userId,
                rateLimit.maxCount(),
                rateLimit.windowMs()
            );

        if (!result.isAllowed()) {
            throw new RateLimitException("요청 한도 초과");
        }

        return pjp.proceed();
    }
}

// 사용
@RateLimit(maxCount = 10, windowMs = 60_000)  // 1분에 10번
@PostMapping("/orders")
public ResponseEntity<?> createOrder(...) { ... }

4. 포인트 트랜잭션 (조건부 원자 업데이트)

@Component
@RequiredArgsConstructor
public class PointService {

    private final StringRedisTemplate redisTemplate;

    // 포인트 사용 (잔액 확인 + 차감 + 이력 원자적)
    private static final String USE_POINT_SCRIPT = """
        local point_key   = KEYS[1]
        local history_key = KEYS[2]
        local amount      = tonumber(ARGV[1])
        local reason      = ARGV[2]
        local timestamp   = ARGV[3]

        -- 현재 잔액 확인
        local balance = tonumber(redis.call('GET', point_key)) or 0

        if balance < amount then
            return -1  -- 잔액 부족
        end

        -- 포인트 차감
        local new_balance = redis.call('DECRBY', point_key, amount)

        -- 이력 기록 (Hash로 저장)
        local history_id = timestamp .. ':use:' .. amount
        redis.call('HSET', history_key,
            history_id,
            reason .. ':' .. amount .. ':' .. new_balance
        )
        redis.call('EXPIRE', history_key, 2592000)  -- 30일

        return new_balance  -- 차감 후 잔액
        """;

    // 포인트 적립 + 최대 한도 체크
    private static final String EARN_POINT_SCRIPT = """
        local point_key   = KEYS[1]
        local history_key = KEYS[2]
        local amount      = tonumber(ARGV[1])
        local max_point   = tonumber(ARGV[2])
        local reason      = ARGV[3]
        local timestamp   = ARGV[4]

        local balance = tonumber(redis.call('GET', point_key)) or 0

        -- 최대 보유 한도 체크
        if balance + amount > max_point then
            local possible = max_point - balance
            if possible <= 0 then
                return -1  -- 한도 초과
            end
            amount = possible  -- 가능한 만큼만 적립
        end

        local new_balance = redis.call('INCRBY', point_key, amount)

        local history_id = timestamp .. ':earn:' .. amount
        redis.call('HSET', history_key,
            history_id,
            reason .. ':' .. amount .. ':' .. new_balance
        )
        redis.call('EXPIRE', history_key, 2592000)

        return new_balance
        """;

    public Long usePoint(Long userId, int amount, String reason) {
        String timestamp = String.valueOf(System.currentTimeMillis());

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(USE_POINT_SCRIPT, Long.class),
            List.of(
                "point:" + userId,
                "point:history:" + userId
            ),
            String.valueOf(amount),
            reason,
            timestamp
        );

        if (Long.valueOf(-1L).equals(result))
            throw new RuntimeException("포인트 잔액 부족");

        return result;
    }

    public Long earnPoint(Long userId, int amount, String reason) {
        String timestamp = String.valueOf(System.currentTimeMillis());

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(EARN_POINT_SCRIPT, Long.class),
            List.of(
                "point:" + userId,
                "point:history:" + userId
            ),
            String.valueOf(amount),
            "100000",  // 최대 보유 한도
            reason,
            timestamp
        );

        if (Long.valueOf(-1L).equals(result))
            throw new RuntimeException("포인트 한도 초과");

        return result;
    }
}

5. 실시간 랭킹 (Sorted Set 원자 조작)

@Component
@RequiredArgsConstructor
public class RankingService {

    private final StringRedisTemplate redisTemplate;

    // 점수 업데이트 + 순위 반환 + 이전 순위와 비교
    private static final String UPDATE_RANK_SCRIPT = """
        local rank_key    = KEYS[1]
        local user_id     = ARGV[1]
        local score       = tonumber(ARGV[2])
        local board_limit = tonumber(ARGV[3])  -- 랭킹 보드 최대 인원

        -- 이전 순위 저장
        local prev_rank = redis.call('ZREVRANK', rank_key, user_id)

        -- 점수 업데이트
        redis.call('ZADD', rank_key, score, user_id)

        -- 현재 순위 (0-based)
        local curr_rank = redis.call('ZREVRANK', rank_key, user_id)

        -- 보드 크기 제한 (상위 N명만 유지)
        local total = redis.call('ZCARD', rank_key)
        if total > board_limit then
            redis.call('ZREMRANGEBYRANK', rank_key, 0, total - board_limit - 1)
        end

        -- 이전 순위, 현재 순위, 점수 반환
        if prev_rank == false then
            prev_rank = -1  -- 새로 진입
        end

        return {curr_rank + 1, prev_rank + 1, score}
        """;

    public RankInfo updateRank(String boardId, Long userId, double score) {
        List<Long> result = redisTemplate.execute(
            new DefaultRedisScript<>(UPDATE_RANK_SCRIPT,
                (Class<List<Long>>) (Class<?>) List.class),
            List.of("ranking:" + boardId),
            String.valueOf(userId),
            String.valueOf((long) score),
            "1000"  // 상위 1000명
        );

        return RankInfo.builder()
            .currentRank(result.get(0))
            .previousRank(result.get(1))
            .score(score)
            .rankChange(result.get(1) - result.get(0))  // 양수 = 상승
            .build();
    }

    @Getter @Builder
    public static class RankInfo {
        private final long   currentRank;
        private final long   previousRank;
        private final double score;
        private final long   rankChange;   // 양수: 상승, 음수: 하락
    }
}

6. 세션 관리 (복합 데이터 원자 처리)

@Component
@RequiredArgsConstructor
public class SessionService {

    private final StringRedisTemplate redisTemplate;

    // 세션 갱신 + 동시 로그인 제한
    private static final String UPDATE_SESSION_SCRIPT = """
        local session_key     = KEYS[1]  -- 현재 세션
        local user_sessions   = KEYS[2]  -- 유저의 세션 목록
        local session_id      = ARGV[1]
        local user_id         = ARGV[2]
        local max_sessions     = tonumber(ARGV[3])
        local ttl             = tonumber(ARGV[4])

        -- 현재 세션 수 확인
        local session_count = redis.call('SCARD', user_sessions)

        -- 최대 세션 초과 시 가장 오래된 세션 제거
        if session_count >= max_sessions then
            local oldest = redis.call('SPOP', user_sessions)
            if oldest then
                redis.call('DEL', 'session:' .. oldest)
            end
        end

        -- 새 세션 등록
        redis.call('HSET',    session_key,
            'user_id',    user_id,
            'session_id', session_id,
            'created_at', ARGV[5]
        )
        redis.call('EXPIRE', session_key, ttl)
        redis.call('SADD',   user_sessions, session_id)
        redis.call('EXPIRE', user_sessions, ttl)

        return session_count + 1
        """;

    public void createSession(String sessionId, Long userId) {
        String createdAt = LocalDateTime.now().toString();

        redisTemplate.execute(
            new DefaultRedisScript<>(UPDATE_SESSION_SCRIPT, Long.class),
            List.of(
                "session:" + sessionId,
                "user:sessions:" + userId
            ),
            sessionId,
            String.valueOf(userId),
            "3",          // 최대 동시 로그인 3개
            "3600",       // TTL 1시간
            createdAt
        );
    }
}

7. EVALSHA 활용 (운영 최적화)

// 스크립트를 서버에 캐싱해서 SHA로만 호출
@Component
@RequiredArgsConstructor
@Slf4j
public class LuaScriptManager {

    private final StringRedisTemplate redisTemplate;

    // SHA 캐시
    private final Map<String, String> shaCache = new ConcurrentHashMap<>();

    // 스크립트 등록
    public String loadScript(String scriptName, String script) {
        String sha = redisTemplate.execute(
            (RedisCallback<String>) connection ->
                connection.scriptLoad(script.getBytes())
        );
        shaCache.put(scriptName, sha);
        log.info("Script loaded - name: {}, sha: {}", scriptName, sha);
        return sha;
    }

    // EVALSHA 실행 (캐시 미스 시 EVAL로 폴백)
    public <T> T execute(String scriptName, String script,
                          Class<T> returnType,
                          List<String> keys, String... args) {
        String sha = shaCache.get(scriptName);

        if (sha != null) {
            try {
                // EVALSHA 시도 (네트워크 절약)
                return redisTemplate.execute(
                    new DefaultRedisScript<>(script, returnType) {{
                        setSha1(sha);
                    }},
                    keys, (Object[]) args
                );
            } catch (Exception e) {
                if (e.getMessage().contains("NOSCRIPT")) {
                    // 캐시 유실 (서버 재시작 등) → 재등록 후 재시도
                    log.warn("Script 캐시 유실, 재등록: {}", scriptName);
                    loadScript(scriptName, script);
                }
            }
        }

        // EVAL 폴백
        return redisTemplate.execute(
            new DefaultRedisScript<>(script, returnType),
            keys, (Object[]) args
        );
    }
}

Lua Script 작성 원칙 요약

┌─────────────────────────────────────────────────────────────┐
│                  실무 Lua Script 원칙                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 복잡도 최소화                                            │
│     명령 3~5개 이내                                          │
│     반복문 있으면 횟수 제한                                   │
│                                                             │
│  2. 에러 처리                                                │
│     redis.call()  → 에러 시 즉시 중단 (단순할 때)           │
│     redis.pcall() → 에러 캡처 후 처리 (롤백 필요할 때)      │
│                                                             │
│  3. 검증 먼저, 실행 나중                                     │
│     모든 조건 확인 → 문제 없으면 실행                        │
│     부분 실행 가능성 최소화                                   │
│                                                             │
│  4. 명확한 반환값                                            │
│     성공/실패 코드를 명확하게 정의                           │
│     1: 성공, 0: 조건 불만족, -1: 에러 등                   │
│                                                             │
│  5. EVALSHA 운영 활용                                        │
│     스크립트 서버 캐싱으로 네트워크 절약                      │
│     NOSCRIPT 에러 폴백 처리 필수                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Lua Script pcall의 원자적 실행 보장 범위


결론

Redis가 살아있는 한
pcall로 감싼 Redis 명령 에러는 → 스크립트 계속 실행 ✅
Lua 코드 자체의 런타임 에러는  → 스크립트 중단 가능 ⚠️

중단 케이스 3가지

케이스 1: Redis 명령 에러 (pcall이 막아줌)

-- redis.pcall() → Redis 명령 에러 캡처, 스크립트 계속 실행
local result = redis.pcall('SADD', issued_key, user_id)

-- result = {err="WRONGTYPE ..."} 형태로 반환
-- 스크립트는 계속 실행됨 ✅
if result['err'] then
    return -2
end

케이스 2: Lua 코드 자체 런타임 에러 (pcall이 못 막음)

-- ⚠️ 현재 코드의 잠재적 문제
local sadd_result = redis.pcall('SADD', issued_key, user_id)

-- SADD 성공 시 sadd_result = 1 (숫자)
-- 숫자에 ['err'] 접근 → Lua 런타임 에러 발생!
if sadd_result['err'] then  -- ← 숫자를 테이블처럼 접근!
    return -2
end
실제 동작:

SADD 성공 (result = 1)
    │
    ▼
sadd_result['err']
    │
    ▼
Lua: "attempt to index a integer value"  ← Lua 런타임 에러!
    │
    ▼
스크립트 즉시 중단 ❌
DECRBY 실행 안 됨
하지만 SADD는 이미 실행됨 → 부분 실행 상태!

케이스 3: lua-time-limit 초과

기본값 5초 초과 시:
  → SCRIPT KILL 명령으로 외부에서 강제 종료 가능
  → 단, 쓰기 명령이 하나라도 실행됐으면 KILL 불가

현재 코드 수정

-- ❌ 현재 코드 (위험)
local sadd_result = redis.pcall('SADD', issued_key, user_id)
if sadd_result['err'] then  -- 성공 시 숫자에 접근 → Lua 에러!
    return -2
end
if sadd_result == 0 then
    return -1
end

-- ✅ 수정된 코드 (안전)
local sadd_result = redis.pcall('SADD', issued_key, user_id)

-- type 먼저 확인 → 테이블이면 에러 객체
if type(sadd_result) == 'table' and sadd_result['err'] then
    return -2  -- Redis 명령 에러
end

-- 숫자인 경우만 여기 도달
if sadd_result == 0 then
    return -1  -- 이미 발급됨
end
-- ✅ 전체 수정된 Lua Script
local ISSUE_SCRIPT = """
    local stock_key  = KEYS[1]
    local issued_key = KEYS[2]
    local user_id    = ARGV[1]

    -- ① 재고 확인
    local stock = tonumber(redis.call('GET', stock_key))
    if not stock or stock <= 0 then
        return 0
    end

    -- ② SADD (타입 체크로 안전하게)
    local sadd_result = redis.pcall('SADD', issued_key, user_id)

    if type(sadd_result) == 'table' and sadd_result['err'] then
        return -2  -- Redis 명령 에러
    end

    if sadd_result == 0 then
        return -1  -- 이미 발급됨
    end

    -- ③ DECRBY (타입 체크로 안전하게)
    local decrby_result = redis.pcall('DECRBY', stock_key, 1)

    if type(decrby_result) == 'table' and decrby_result['err'] then
        redis.call('SREM', issued_key, user_id)  -- SADD 롤백
        return -2
    end

    return 1
    """

에러 종류별 정리

┌────────────────────────┬────────────┬─────────────────────────┐
│ 에러 종류              │ pcall 효과 │ 스크립트 실행            │
├────────────────────────┼────────────┼─────────────────────────┤
│ Redis 명령 에러        │ ✅ 캡처    │ 계속 실행               │
│ (WRONGTYPE, OOM 등)    │            │                         │
├────────────────────────┼────────────┼─────────────────────────┤
│ Lua 런타임 에러        │ ❌ 못 막음 │ 즉시 중단               │
│ (nil 접근, 타입 오류)  │            │                         │
├────────────────────────┼────────────┼─────────────────────────┤
│ lua-time-limit 초과    │ ❌ 못 막음 │ SCRIPT KILL으로 중단    │
├────────────────────────┼────────────┼─────────────────────────┤
│ Redis 서버 다운        │ ❌ 불가    │ 즉시 중단               │
└────────────────────────┴────────────┴─────────────────────────┘

결론 요약

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  pcall이 보장하는 것:                                        │
│    Redis 명령 에러 → 캡처 → 스크립트 계속 실행 ✅           │
│                                                             │
│  pcall이 보장 못 하는 것:                                    │
│    Lua 코드 자체 에러 (타입 오류 등) → 중단 ⚠️             │
│                                                             │
│  핵심 교훈:                                                  │
│    pcall 결과값 사용 시                                      │
│    type(result) == 'table' 확인 필수                        │
│    그래야 진짜 "처음부터 끝까지" 실행 보장                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Lua Script + pcall + DB Unique Key로 해결되는가?


시나리오별 완전 분석

3중 방어선:
  1차: Lua Script (SADD + DECRBY 원자적)
  2차: pcall + Redis 롤백
  3차: DB Unique Key Constraint

시나리오 1: 정상 흐름 ✅

Lua: SADD(1) → DECRBY → 성공
DB:  INSERT → 성공
결과: 정상 발급

시나리오 2: 중복 발급 시도 ✅ 해결됨

1번 요청: Lua SADD → 1 (추가됨) → 발급 완료
2번 요청: Lua SADD → 0 (이미 존재) → return -1 (차단)

DB Unique도 최후 방어:
  만약 극히 드문 타이밍으로 Lua를 통과해도
  → DB UNIQUE(user_id, coupon_id) 위반 → 에러
  → Redis 롤백 실행
  → 중복 발급 방지 ✅

시나리오 3: Lua 내부 실패 ✅ 해결됨

SADD 실패:
  → DECRBY 실행 안 됨 (return -2)
  → 재고 변화 없음, 재시도 가능 ✅

SADD 성공 → DECRBY 실패:
  → SREM으로 SADD 롤백
  → 재고 변화 없음, 재시도 가능 ✅

시나리오 4: Lua 성공 → DB 실패 → Redis 롤백 성공 ✅

Lua: SADD ✅, DECRBY ✅
DB:  INSERT ❌
Redis 롤백: SREM ✅, INCRBY ✅

→ 모든 상태 원복, 재시도 가능 ✅

시나리오 5: Lua 성공 → DB 실패 → Redis 롤백 실패 ❌ 미해결

Lua: SADD ✅, DECRBY ✅
DB:  INSERT ❌
Redis 롤백: ❌ (Redis 순간 불안정 등)

최종 상태:
  Redis issued Set: user_id 존재 (발급된 것으로 처리)
  Redis stock:      감소된 상태
  DB:               발급 내역 없음

결과:
  유저: SISMEMBER=1 → 재시도해도 "이미 발급됨" 응답
  실제: DB에 쿠폰 없음 → 부족하게 발급된 상태 ❌

시나리오 5 해결: 복구 테이블 + 배치

// ============================================
// 1. 롤백 실패 시 복구 테이블에 기록
// ============================================
@Entity
@Table(name = "coupon_rollback_failure")
@Getter @Builder
public class CouponRollbackFailure {

    @Id @GeneratedValue
    private Long id;

    private Long          couponId;
    private Long          userId;
    private String        reason;
    private boolean       recovered;
    private LocalDateTime failedAt;
}


// ============================================
// 2. 롤백 실패 시 저장
// ============================================
private void saveRollbackFailureLog(Long couponId, Long userId, String reason) {
    rollbackFailureRepository.save(
        CouponRollbackFailure.builder()
            .couponId(couponId)
            .userId(userId)
            .reason(reason)
            .recovered(false)
            .failedAt(LocalDateTime.now())
            .build()
    );
    // 알람 발송 (Slack, PagerDuty 등)
    alertService.sendAlert(
        "[긴급] 쿠폰 롤백 실패 - couponId: " + couponId + ", userId: " + userId
    );
}


// ============================================
// 3. 배치로 복구
// ============================================
@Scheduled(fixedDelay = 60_000)  // 1분마다
@Transactional
public void recoverRollbackFailures() {
    List<CouponRollbackFailure> failures =
        rollbackFailureRepository.findByRecoveredFalse();

    failures.forEach(failure -> {
        try {
            // DB에 발급 내역 있는지 확인
            boolean issuedInDb = userCouponRepository
                .existsByUserIdAndCouponTemplateId(
                    failure.getUserId(), failure.getCouponId()
                );

            if (!issuedInDb) {
                // DB에 없으면 Redis도 롤백 재시도
                boolean rollbackSuccess = redisService.rollback(
                    failure.getCouponId(), failure.getUserId()
                );

                if (rollbackSuccess) {
                    failure.setRecovered(true);
                    log.info("롤백 복구 성공 - id: {}", failure.getId());
                }
            } else {
                // DB에 있으면 정상 발급된 것
                failure.setRecovered(true);
            }
        } catch (Exception e) {
            log.error("복구 배치 실패 - id: {}", failure.getId(), e);
        }
    });
}

더 근본적인 해결: 비동기 패턴

동기 방식의 근본 문제:
  Redis 성공 + DB 실패 → 두 저장소 정합성 불일치

해결: Redis → Queue → DB 비동기 저장

[요청]
  │
  ▼
Lua Script (Redis 선점)  ← 빠른 선착순 처리
  │
  ▼
Redis List에 발급 대기 추가
  │
  ▼
즉시 "발급 처리 중" 응답 반환

[백그라운드 워커]
Redis List에서 꺼내기
  │
  ▼
DB INSERT (실패 시 재시도)
  │
  ▼
발급 완료 상태 업데이트
// 발급 요청 시 Queue에 적재
public CouponIssueResponse issueCoupon(Long couponId, Long userId) {

    // Lua로 선점
    IssueResult result = redisService.tryIssue(couponId, userId);
    if (result != IssueResult.SUCCESS) {
        throw new CouponException(result.toErrorCode());
    }

    // DB 저장 대신 Queue에 적재
    String queueKey = "coupon:issue:queue";
    String payload  = couponId + ":" + userId + ":" + System.currentTimeMillis();
    redisTemplate.opsForList().leftPush(queueKey, payload);

    return CouponIssueResponse.pending();  // "처리 중" 응답
}


// 워커: Queue에서 꺼내서 DB 저장
@Scheduled(fixedDelay = 100)
public void processIssueQueue() {
    String queueKey = "coupon:issue:queue";
    String payload  = redisTemplate.opsForList().rightPop(queueKey);

    if (payload == null) return;

    String[] parts    = payload.split(":");
    Long     couponId = Long.parseLong(parts[0]);
    Long     userId   = Long.parseLong(parts[1]);

    try {
        saveToDatabase(couponId, userId);
    } catch (Exception e) {
        // 실패 시 다시 Queue에 넣기 (재시도)
        redisTemplate.opsForList().leftPush(
            "coupon:issue:queue:retry", payload
        );
    }
}

최종 정리

┌─────────────────────────────────────────────────────────────┐
│          3중 방어선으로 해결되는 것 / 안 되는 것              │
├──────────────────────────┬──────────────────────────────────┤
│ 중복 발급                │ ✅ 해결                          │
│                          │ Lua SADD + DB UNIQUE             │
├──────────────────────────┼──────────────────────────────────┤
│ 재고 초과 발급           │ ✅ 해결                          │
│                          │ Lua 재고 확인 + 원자적 차감      │
├──────────────────────────┼──────────────────────────────────┤
│ Lua 내부 부분 실패       │ ✅ 해결                          │
│                          │ pcall + 내부 롤백                │
├──────────────────────────┼──────────────────────────────────┤
│ DB 실패 + Redis 롤백 성공│ ✅ 해결                          │
│                          │ 보상 트랜잭션                    │
├──────────────────────────┼──────────────────────────────────┤
│ DB 실패 + Redis 롤백 실패│ ⚠️ 완전 해결 불가               │
│ (부족하게 발급)          │ → 복구 테이블 + 배치 복구        │
│                          │ → 비동기 큐 패턴으로 근본 해결   │
└──────────────────────────┴──────────────────────────────────┘

결론:
  두 저장소(Redis + DB)를 동기로 맞추는 건
  100% 완벽한 보장 불가
  (분산 시스템의 근본 한계)

  → 비동기 큐 패턴이 가장 안전한 설계
  → 동기 방식은 복구 배치로 보완