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

Redis Transaction: MULTI / EXEC / WATCH 완전 정리

3) Redis Transaction: MULTI / EXEC

✅ Redis 트랜잭션 vs DB 트랜잭션

항목Redis MULTI/EXECRDBMS Transaction
원자성중단 없음 (no interleaving)Commit/Rollback 지원
롤백❌ 없음✅ ROLLBACK 가능
격리성큐잉 → 일괄 실행MVCC, Lock 기반 격리
에러 처리문법 에러만 전체 취소, 런타임 에러는 부분 실행에러 시 전체 롤백
중간 결과 조회불가 (큐잉 중이므로)가능

✅ MULTI/EXEC는 원자성을 보장하나요?

제한적으로 보장합니다.

MULTI          → 트랜잭션 시작 (이후 명령은 큐에 쌓임)
SET key1 val1  → 큐에 추가 (QUEUED 응답)
INCR key2      → 큐에 추가 (QUEUED 응답)
EXEC           → 큐의 명령 일괄 실행
  • 실행 중 다른 클라이언트 끼어들기 불가 → 이 의미에서 원자적
  • 롤백 없음 → "모 아니면 도" 원자성은 아님
  • 큐에 쌓인 명령이 모두 실행됨 (일부 실패해도 나머지 계속 실행)

✅ 중간에 에러가 나면 전체 롤백되나요?

No. 부분 실행됩니다. 에러의 종류에 따라 다릅니다.

MULTI
SET key1 "hello"        # QUEUED
NOTACOMMAND             # ← 문법/명령 에러: 즉시 에러, EXEC 시 전체 취소
INCR key1               # QUEUED
EXEC
# → 문법 에러 시: 전체 EXEC 거부 (모든 명령 취소)
MULTI
SET key1 "hello"        # QUEUED
INCR key1               # QUEUED (SET한 key1은 string이라 INCR 실패할 것)
SET key2 "world"        # QUEUED
EXEC
# 결과:
# 1) OK         (SET key1 성공)
# 2) ERR        (INCR key1 런타임 에러 → 이것만 실패)
# 3) OK         (SET key2 성공)
# ⚠️ key2는 정상 저장됨! 롤백 없음!
에러 종류동작
큐잉 중 문법 에러 (QUEUED 단계)EXEC 시 전체 취소
실행 중 런타임 에러 (EXEC 단계)해당 명령만 실패, 나머지 계속 실행

✅ WATCH는 언제 쓰고 어떤 동시성 제어를 제공하나요?

WATCH낙관적 락(Optimistic Lock) 을 구현합니다.

"내가 WATCH한 키가 EXEC 전에 변경되면 트랜잭션을 실패시켜라"

WATCH balance          # balance 키 감시 시작
GET balance            # → 1000 (현재 값 읽기)
# (이 시점에 다른 클라이언트가 balance를 변경하면 EXEC 실패)
MULTI
SET balance 900        # 큐에 추가
EXEC
# → nil (다른 클라이언트가 변경한 경우) → 재시도 필요
# → OK  (아무도 변경 안 한 경우) → 성공
  • DB의 SELECT FOR UPDATE (비관적 락)과 달리 락을 걸지 않음
  • 경합이 적을 때 효율적, 경합이 많으면 재시도가 많아져 비효율

✅ WATCH 기반 "check-and-set" 구현 흐름

def check_and_set(redis, key, expected, new_value):
    while True:
        with redis.pipeline() as pipe:
            try:
                pipe.watch(key)           # 1. 감시 시작
                current = pipe.get(key)   # 2. 현재 값 읽기
                
                if current != expected:   # 3. 조건 확인
                    pipe.unwatch()
                    return False          # 조건 불일치 → 포기
                
                pipe.multi()              # 4. 트랜잭션 시작
                pipe.set(key, new_value)  # 5. 명령 큐잉
                pipe.execute()            # 6. 실행 (감시 키 변경 시 실패)
                return True
                
            except WatchError:            # 7. 충돌 감지 → 재시도
                continue

흐름 요약:

WATCH key → GET key → 조건 확인 → MULTI → SET key → EXEC
                                              ↑
                       (중간에 다른 클라이언트가 key 수정 시 EXEC → nil → 재시도)

✅ Lua Script vs MULTI/EXEC 선택 기준

상황선택이유
조건부 업데이트 (if-else 로직)LuaMULTI/EXEC는 조건 분기 불가
단순 명령 묶음 원자 실행MULTI/EXEC간단하고 안전
분산락 해제Lua토큰 비교 후 DEL 원자적 처리
낙관적 락 (CAS 패턴)WATCH+MULTI/EXEC충돌 감지 후 재시도 패턴
복잡한 계산 포함Lua스크립트 내 연산 가능
클러스터 + 다중 키주의 필요둘 다 같은 슬롯 제약

핵심: Lua는 "로직 포함 원자 실행", MULTI/EXEC는 "단순 명령 묶음"



Redis 트랜잭션 완전 정복


1. Redis 트랜잭션이란?

Redis 트랜잭션은 여러 명령어를 하나의 단위로 묶어 순차 실행하는 메커니즘입니다. 핵심 명령어는 MULTI, EXEC, DISCARD, WATCH 4가지입니다.

MULTI   → 트랜잭션 시작 선언
(명령들) → 큐(Queue)에 저장 (즉시 실행 X)
EXEC    → 큐의 명령 전체 실행
DISCARD → 트랜잭션 취소 (큐 비우기)
WATCH   → 낙관적 락 설정

2. 동작 원리 (상태 머신)

[Normal 상태]
    │
    │ MULTI
    ▼
[Queuing 상태] ──── 명령 입력 → "QUEUED" 응답 (실행 안 함!)
    │         │
    │ EXEC    │ DISCARD
    ▼         ▼
[실행 완료]  [취소/Normal 복귀]
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET balance 1000
QUEUED          ← 실행된 게 아님! 큐에 저장됨
127.0.0.1:6379> DECRBY balance 200
QUEUED
127.0.0.1:6379> SET log "transferred"
QUEUED
127.0.0.1:6379> EXEC
1) OK           ← SET balance 결과
2) (integer) 800← DECRBY 결과
3) OK           ← SET log 결과

3. 원자성의 실체 (매우 중요!)

Redis 트랜잭션의 원자성은 "실행 중 끼어들기 불가" 이지, "실패 시 롤백"이 아닙니다.

3-1. 원자성이 보장되는 것

Client A: MULTI → SET k1 → INCR k2 → EXEC
Client B:                            ← A의 EXEC 완료 전 절대 실행 불가

EXEC 실행 순간 Redis 단일 스레드가 큐 전체를 연속으로 처리 → 중간에 다른 클라이언트 끼어들기 물리적으로 불가

3-2. 원자성이 보장되지 않는 것 (롤백 없음)

MULTI
SET key1 "hello"      # QUEUED
INCR key1             # QUEUED ← key1은 string이라 INCR 불가 (런타임 에러)
SET key2 "world"      # QUEUED
EXEC
# 결과:
1) OK       ← SET key1 성공 ✅
2) ERR      ← INCR key1 실패 ❌ (하지만 계속 진행!)
3) OK       ← SET key2 성공 ✅
# ⚠️ key1, key2 모두 저장됨! 롤백 없음!

3-3. 에러 종류별 동작 차이

에러 발생 시점에러 종류동작
큐잉 중 (QUEUED 단계)문법 에러, 존재하지 않는 명령EXEC전체 취소
실행 중 (EXEC 단계)잘못된 타입 사용 등 런타임 에러해당 명령만 실패, 나머지 정상 실행
# ─── 케이스 1: 큐잉 중 문법 에러 → 전체 취소 ───
MULTI
SET key1 "hello"
NOTEXISTCMD         ← 존재하지 않는 명령
SET key2 "world"
EXEC
# → (error) EXECABORT Transaction discarded because of previous errors.
# key1, key2 모두 저장 안 됨

# ─── 케이스 2: 실행 중 런타임 에러 → 부분 실행 ───
MULTI
SET key1 "hello"    ← string 저장
INCR key1           ← string에 INCR → 런타임 에러
SET key2 "world"
EXEC
# key1 = "hello" 저장됨 ✅
# key2 = "world" 저장됨 ✅
# INCR만 실패 ❌

4. DISCARD - 트랜잭션 취소

MULTI
SET key1 "hello"
SET key2 "world"
DISCARD             ← 큐 전체 버림
# → OK
# 아무것도 실행되지 않음

실수로 잘못된 명령을 큐에 넣었을 때 안전하게 취소하는 수단


5. WATCH - 낙관적 락 (Optimistic Lock)

5-1. WATCH가 필요한 이유

트랜잭션만으로 해결 못 하는 상황:

Thread A: GET balance → 1000 (값 읽기)
Thread B: GET balance → 1000 (값 읽기)
Thread A:                        MULTI → SET balance 800 → EXEC (200 차감)
Thread B:                        MULTI → SET balance 800 → EXEC (200 차감) ← 잘못됨!
결과: balance = 800 (400이 차감되어야 하는데!)

MULTI/EXEC 사이에 GET으로 값을 읽고 판단하는 로직은 race condition 발생

5-2. WATCH 동작 원리

WATCH balance          # balance 감시 시작
GET balance            # 현재 값 조회 (→ 1000)

# [이 시점에 다른 클라이언트가 balance를 수정하면 EXEC 실패]

MULTI
DECRBY balance 200
EXEC
# → nil  : 다른 클라이언트가 balance 수정한 경우 (재시도 필요)
# → [OK] : 아무도 수정 안 한 경우 (성공)
WATCH key 설정
     │
     ├──── [key 변경 감지] ──── EXEC 호출 시 → nil 반환 (실패)
     │
     └──── [key 변경 없음] ──── EXEC 호출 시 → 정상 실행

5-3. WATCH 기반 CAS(Check-And-Set) 완전한 구현 패턴

import redis

def transfer_money(r, from_key, to_key, amount):
    """
    잔액 이전 - race condition 없이 안전하게
    """
    MAX_RETRY = 5
    
    for attempt in range(MAX_RETRY):
        with r.pipeline() as pipe:
            try:
                # 1단계: 감시 시작
                pipe.watch(from_key, to_key)
                
                # 2단계: 현재 값 읽기 (watch 상태에서 일반 명령 실행)
                from_balance = int(pipe.get(from_key) or 0)
                to_balance = int(pipe.get(to_key) or 0)
                
                # 3단계: 비즈니스 로직 검증
                if from_balance < amount:
                    pipe.unwatch()
                    raise Exception("잔액 부족")
                
                # 4단계: 트랜잭션 시작
                pipe.multi()
                
                # 5단계: 명령 큐잉
                pipe.set(from_key, from_balance - amount)
                pipe.set(to_key, to_balance + amount)
                
                # 6단계: 실행 (감시 키가 변경됐으면 WatchError 발생)
                pipe.execute()
                
                print(f"이체 성공 (시도 {attempt + 1}회)")
                return True
                
            except redis.WatchError:
                # 7단계: 충돌 감지 → 재시도
                print(f"충돌 감지, 재시도 중... ({attempt + 1}/{MAX_RETRY})")
                continue
    
    raise Exception("최대 재시도 횟수 초과")

5-4. WATCH 특성 정리

# WATCH는 EXEC 또는 DISCARD 실행 시 자동 해제
WATCH key1 key2
MULTI
...
EXEC    ← watch 자동 해제

# 명시적으로도 해제 가능
UNWATCH

# 연결이 끊기면 자동 해제

6. DB 트랜잭션 vs Redis 트랜잭션 상세 비교

특성RDBMS (PostgreSQL 등)Redis MULTI/EXEC
원자성(Atomicity)완전 보장 (All or Nothing)부분 보장 (끼어들기 방지만)
일관성(Consistency)제약조건, 트리거 등 보장직접 구현 필요
격리성(Isolation)MVCC, 여러 격리 레벨EXEC 동안 단순 직렬화
지속성(Durability)WAL, fsync 보장AOF/RDB 설정에 따라 다름
롤백✅ ROLLBACK 가능❌ 불가
중간 결과 조회✅ 트랜잭션 내 SELECT 가능❌ 큐잉 중 결과 조회 불가
savepoint✅ 지원❌ 미지원
교착상태 감지✅ 자동 감지❌ 해당 없음 (단일 스레드)

7. MULTI/EXEC vs Lua Script 선택 기준

조건 분기가 필요한가? (if-else)
    ├── YES → Lua Script
    └── NO  →
              단순 명령 묶음인가?
                  ├── YES → MULTI/EXEC (더 간단)
                  └── NO  → Lua Script
상황선택이유
단순 명령 묶음 실행MULTI/EXEC코드가 단순
조건부 업데이트Lua ScriptMULTI/EXEC는 if-else 불가
분산락 해제Lua Script토큰 비교 + DEL 원자적 처리
낙관적 락 (CAS 패턴)WATCH + MULTI/EXEC충돌 감지 후 재시도 패턴
클러스터 환경 복잡 로직Lua ScriptHash Tag와 함께 슬롯 관리

8. 실무에서 자주 쓰는 패턴

패턴 1: 재고 차감 (Lua 권장)

-- Lua: 재고 있을 때만 차감 (원자적 조건부 업데이트)
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock and stock > 0 then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1  -- 성공
end
return 0  -- 재고 없음

패턴 2: 캐시 초기화 묶음 (MULTI/EXEC 적합)

MULTI
DEL user:1:cache
DEL user:1:session
DEL user:1:token
EXEC
# 원자적으로 여러 캐시 동시 삭제

패턴 3: 포인트 충전 (WATCH + MULTI/EXEC)

// Spring Data Redis 예시
redisTemplate.execute(new SessionCallback<>() {
    @Override
    public Object execute(RedisOperations ops) {
        ops.watch("point:" + userId);
        
        Long currentPoint = (Long) ops.opsForValue().get("point:" + userId);
        
        ops.multi();
        ops.opsForValue().set("point:" + userId, currentPoint + chargeAmount);
        ops.opsForList().leftPush("point:history:" + userId, 
            "charge:" + chargeAmount);
        
        return ops.exec(); // null이면 재시도
    }
});

9. 주의사항 & 함정

⚠️ 함정 1: 큐잉 중 결과를 활용할 수 없음

// ❌ 잘못된 코드
redisTemplate.multi();
Long value = redisTemplate.opsForValue().increment("counter"); // null 반환!
if (value > 100) { ... } // NullPointerException 위험
redisTemplate.exec();

⚠️ 함정 2: WATCH 후 연결 재사용 주의

# WATCH는 해당 Connection에 귀속
# Connection Pool 사용 시 반드시 같은 Connection으로 MULTI/EXEC 실행

⚠️ 함정 3: WATCH 기반 재시도 무한루프

# ❌ 경합이 심한 상황에서 무한 재시도
while True:
    ...
    
# ✅ 최대 재시도 횟수 제한 + 백오프
for attempt in range(MAX_RETRY):
    time.sleep(0.01 * attempt)  # 점진적 대기
    ...

⚠️ 함정 4: 클러스터에서 다중 키 주의

# ❌ 클러스터에서 다른 슬롯의 키 → CROSSSLOT 에러
MULTI
SET user:1 "data"
SET order:1 "data"
EXEC

# ✅ Hash Tag로 같은 슬롯 보장
MULTI
SET {service}:user:1 "data"
SET {service}:order:1 "data"
EXEC

10. 전체 요약

┌─────────────────────────────────────────────────────────┐
│                Redis 트랜잭션 핵심 요약                    │
├─────────────────────────────────────────────────────────┤
│  MULTI/EXEC  │ 명령 큐잉 → 일괄 실행 (끼어들기 방지)       │
│  DISCARD     │ 큐 비우기, 트랜잭션 취소                    │
│  WATCH       │ 낙관적 락 (키 변경 감지 → EXEC 실패)        │
├─────────────────────────────────────────────────────────┤
│  원자성      │ "중단 없음" O  /  "롤백" X                  │
│  에러 처리   │ 큐잉 에러 → 전체 취소                       │
│              │ 런타임 에러 → 해당 명령만 실패, 나머지 실행   │
├─────────────────────────────────────────────────────────┤
│  선택 기준   │ 조건 분기 필요 → Lua Script                 │
│              │ 단순 묶음 실행 → MULTI/EXEC                 │
│              │ 충돌 감지 재시도 → WATCH + MULTI/EXEC       │
└─────────────────────────────────────────────────────────┘

Redis WATCH 예시


기본 동작 예시

성공 케이스

# 터미널 A
WATCH balance          # balance 감시 시작
GET balance            # → "1000"
MULTI
DECRBY balance 200
EXEC
# → [800]  ✅ 아무도 건드리지 않았으므로 성공

실패 케이스

# 터미널 A                        # 터미널 B
WATCH balance
GET balance   # → "1000"
                                  SET balance 500  # ← B가 중간에 변경!
MULTI
DECRBY balance 200
EXEC
# → nil  ❌ B가 변경했으므로 실패
# balance는 여전히 500 (A의 변경 미적용)

실전 예시 1: 잔액 이체 (Python)

import redis
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# 초기 데이터
r.set("account:alice", 1000)
r.set("account:bob", 500)

def transfer(from_account, to_account, amount, max_retry=5):
    """
    WATCH 기반 안전한 잔액 이체
    """
    for attempt in range(max_retry):
        with r.pipeline() as pipe:
            try:
                # ① 감시 시작
                pipe.watch(from_account, to_account)

                # ② 현재 값 읽기 (WATCH 상태에서는 일반 명령 실행 가능)
                from_balance = int(pipe.get(from_account))
                to_balance   = int(pipe.get(to_account))

                print(f"[시도 {attempt+1}] {from_account}: {from_balance}, "
                      f"{to_account}: {to_balance}")

                # ③ 비즈니스 검증
                if from_balance < amount:
                    pipe.unwatch()
                    raise Exception(f"잔액 부족: {from_balance} < {amount}")

                # ④ 트랜잭션 시작 (이 시점부터 명령 큐잉)
                pipe.multi()
                pipe.set(from_account, from_balance - amount)
                pipe.set(to_account,   to_balance   + amount)

                # ⑤ 실행 (감시 키 변경 시 WatchError 발생)
                pipe.execute()

                print(f"✅ 이체 성공: {from_account} → {to_account}, {amount}원")
                return True

            except redis.WatchError:
                # ⑥ 충돌 감지 → 재시도
                print(f"⚠️  충돌 감지! 재시도 중... ({attempt+1}/{max_retry})")
                time.sleep(0.01 * (attempt + 1))  # 점진적 대기
                continue

    raise Exception("최대 재시도 횟수 초과")


transfer("account:alice", "account:bob", 300)
# [시도 1] account:alice: 1000, account:bob: 500
# ✅ 이체 성공: account:alice → account:bob, 300원

print(r.get("account:alice"))  # → 700
print(r.get("account:bob"))    # → 800

실전 예시 2: 재고 차감 (Java / Spring)

@Service
public class StockService {

    private final StringRedisTemplate redisTemplate;

    // WATCH 기반 재고 차감
    public boolean decreaseStock(String productId, int quantity) {
        String stockKey = "stock:" + productId;
        int maxRetry = 5;

        for (int attempt = 0; attempt < maxRetry; attempt++) {
            try {
                List<Object> result = redisTemplate.execute(
                    new SessionCallback<List<Object>>() {
                        @Override
                        public List<Object> execute(RedisOperations ops) {

                            // ① WATCH 설정
                            ops.watch(stockKey);

                            // ② 현재 재고 조회
                            String stockStr = (String) ops.opsForValue().get(stockKey);
                            int currentStock = Integer.parseInt(stockStr);

                            // ③ 재고 검증
                            if (currentStock < quantity) {
                                ops.unwatch();
                                throw new RuntimeException("재고 부족");
                            }

                            // ④ 트랜잭션 시작
                            ops.multi();
                            ops.opsForValue().set(stockKey,
                                String.valueOf(currentStock - quantity));

                            // ⑤ 실행
                            return ops.exec();
                        }
                    }
                );

                // exec() 결과가 null이면 충돌 → 재시도
                if (result != null) {
                    System.out.println("✅ 재고 차감 성공");
                    return true;
                }

                System.out.println("⚠️ 충돌, 재시도 " + (attempt + 1));
                Thread.sleep(10L * (attempt + 1));

            } catch (RuntimeException e) {
                if (e.getMessage().equals("재고 부족")) throw e;
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        throw new RuntimeException("최대 재시도 초과");
    }
}

실전 예시 3: WATCH 실패 시나리오 시뮬레이션

import redis
import threading
import time

r = redis.Redis(decode_responses=True)
r.set("counter", 0)

results = []

def increment_with_watch(client_name):
    """각 클라이언트가 counter를 읽고 +10 하려는 시도"""
    for attempt in range(10):
        with r.pipeline() as pipe:
            try:
                pipe.watch("counter")
                current = int(pipe.get("counter"))

                # 의도적으로 딜레이 → 충돌 유발
                time.sleep(0.01)

                pipe.multi()
                pipe.set("counter", current + 10)
                pipe.execute()

                results.append(f"{client_name}: 성공 (시도 {attempt+1}회)")
                return

            except redis.WatchError:
                results.append(f"{client_name}: 충돌 재시도 ({attempt+1}회)")
                continue

    results.append(f"{client_name}: 최종 실패")


# 3개 스레드가 동시에 counter 증가 시도
threads = [
    threading.Thread(target=increment_with_watch, args=(f"Client{i}",))
    for i in range(3)
]

for t in threads: t.start()
for t in threads: t.join()

for r_msg in results: print(r_msg)
print(f"최종 counter: {r.get('counter')}")

# 출력 예시:
# Client0: 충돌 재시도 (1회)
# Client1: 성공 (시도 1회)
# Client2: 충돌 재시도 (1회)
# Client0: 성공 (시도 2회)
# Client2: 성공 (시도 3회)
# 최종 counter: 30  ✅ 세 클라이언트 모두 +10 정상 반영

WATCH 동작 흐름 정리

┌──────────────────────────────────────────────────────────┐
│                  WATCH 상태 흐름도                        │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  WATCH key                                               │
│      │                                                   │
│      ▼                                                   │
│  GET key  ← 일반 명령 실행 가능 (아직 MULTI 전)           │
│      │                                                   │
│      ├──────── 다른 클라이언트가 key 수정 ──────┐         │
│      │                                        │         │
│  MULTI                                        │         │
│  (명령 큐잉)                                   │         │
│      │                                        │         │
│  EXEC                                         │         │
│      │                                        │         │
│      ├── key 변경 없음 ──→ 정상 실행 ✅         │         │
│      └── key 변경 있음 ←──────────────────────┘         │
│               │                                          │
│               └──→ nil 반환 ❌ → 재시도                  │
│                                                          │
│  * EXEC / DISCARD / 연결 종료 시 WATCH 자동 해제          │
└──────────────────────────────────────────────────────────┘

주요 특성 요약

✅ WATCH 해제 시점:
   - EXEC 실행 시 (성공/실패 무관)
   - DISCARD 실행 시
   - 클라이언트 연결 종료 시
   - UNWATCH 명시적 호출 시

✅ 여러 키 동시 감시 가능:
   WATCH key1 key2 key3
   → 셋 중 하나라도 변경되면 EXEC 실패

⚠️ 주의사항:
   - 경합이 심할수록 재시도 횟수 증가 → Lua Script 고려
   - 반드시 최대 재시도 횟수 제한
   - Connection Pool 사용 시 같은 Connection으로 EXEC까지 처리

MULTI / EXEC Java 예시 코드


기본 사용법

@Service
public class RedisTransactionService {

    private final StringRedisTemplate redisTemplate;

    // ============================================
    // 기본 MULTI / EXEC
    // ============================================
    public List<Object> basicTransaction() {
        return redisTemplate.execute(new SessionCallback<List<Object>>() {
            @Override
            public List<Object> execute(RedisOperations ops) {

                ops.multi();  // ① MULTI 시작

                ops.opsForValue().set("key1", "hello");   // QUEUED
                ops.opsForValue().set("key2", "world");   // QUEUED
                ops.opsForValue().increment("counter");   // QUEUED

                return ops.exec();  // ② EXEC (일괄 실행)
                // → [true, true, 1]
            }
        });
    }
}

실전 예시 1: 캐시 일괄 삭제

// 유저 관련 캐시 전체를 원자적으로 삭제
public void clearUserCache(Long userId) {
    List<String> keys = List.of(
        "user:" + userId + ":profile",
        "user:" + userId + ":session",
        "user:" + userId + ":token"
    );

    redisTemplate.execute(new SessionCallback<List<Object>>() {
        @Override
        public List<Object> execute(RedisOperations ops) {
            ops.multi();
            keys.forEach(ops::delete);  // 모든 키 삭제 큐잉
            return ops.exec();
        }
    });

    // 결과: 세 키가 원자적으로 삭제됨
    // 중간에 다른 클라이언트 끼어들기 불가 (EXEC 순간)
}

실전 예시 2: WATCH + MULTI/EXEC (낙관적 락)

// 포인트 차감 - 충돌 시 재시도
public boolean deductPoint(Long userId, int amount) {
    String key = "point:" + userId;
    int maxRetry = 5;

    for (int attempt = 0; attempt < maxRetry; attempt++) {
        try {
            List<Object> result = redisTemplate.execute(
                new SessionCallback<List<Object>>() {
                    @Override
                    public List<Object> execute(RedisOperations ops) {

                        // ① WATCH 설정
                        ops.watch(key);

                        // ② 현재 값 읽기 (WATCH 상태에서 일반 명령 가능)
                        String currentStr = (String) ops.opsForValue().get(key);
                        int current = Integer.parseInt(currentStr);

                        // ③ 비즈니스 검증
                        if (current < amount) {
                            ops.unwatch();
                            throw new RuntimeException("포인트 부족");
                        }

                        // ④ 트랜잭션 시작
                        ops.multi();
                        ops.opsForValue().set(key, String.valueOf(current - amount));

                        // ⑤ 실행
                        return ops.exec();
                        // WATCH 키가 변경됐으면 null 반환
                    }
                }
            );

            if (result != null) {
                System.out.println("✅ 성공 (시도 " + (attempt + 1) + "회)");
                return true;
            }

            // null = 충돌 발생 → 재시도
            System.out.println("⚠️ 충돌, 재시도 " + (attempt + 1));
            Thread.sleep(10L * (attempt + 1));  // 점진적 대기

        } catch (RuntimeException e) {
            if (e.getMessage().equals("포인트 부족")) throw e;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    throw new RuntimeException("최대 재시도 초과");
}

실전 예시 3: 에러 케이스 확인

// 런타임 에러 시 부분 실행 확인
public void partialExecutionExample() {
    redisTemplate.opsForValue().set("strKey", "hello");  // string 저장

    List<Object> results = redisTemplate.execute(
        new SessionCallback<List<Object>>() {
            @Override
            public List<Object> execute(RedisOperations ops) {
                ops.multi();

                ops.opsForValue().set("key1", "value1");      // ① 정상
                ops.opsForValue().increment("strKey");         // ② 런타임 에러 예정
                                                               //   (string에 INCR)
                ops.opsForValue().set("key2", "value2");      // ③ 정상

                return ops.exec();
            }
        }
    );

    // 결과 확인
    for (int i = 0; i < results.size(); i++) {
        if (results.get(i) instanceof Exception) {
            System.out.println("명령 " + (i+1) + " 실패: " + results.get(i));
        } else {
            System.out.println("명령 " + (i+1) + " 성공: " + results.get(i));
        }
    }

    // 출력:
    // 명령 1 성공: true    ← SET key1 성공 ✅
    // 명령 2 실패: ...     ← INCR 실패 ❌
    // 명령 3 성공: true    ← SET key2 성공 ✅ (롤백 없음!)

    System.out.println(redisTemplate.opsForValue().get("key1")); // "value1" 저장됨
    System.out.println(redisTemplate.opsForValue().get("key2")); // "value2" 저장됨
}

실전 예시 4: Pipeline + MULTI/EXEC 비교

@Component
public class PipelineVsTransactionExample {

    private final StringRedisTemplate redisTemplate;

    // Pipeline (원자성 없음, 빠름)
    public void pipelineExample(List<String> keys) {
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            keys.forEach(key -> connection.del(key.getBytes()));
            return null;
            // ⚠️ 명령 사이에 다른 클라이언트 끼어들기 가능
        });
    }

    // MULTI/EXEC (원자성 있음)
    public void transactionExample(List<String> keys) {
        redisTemplate.execute(new SessionCallback<List<Object>>() {
            @Override
            public List<Object> execute(RedisOperations ops) {
                ops.multi();
                keys.forEach(ops::delete);
                return ops.exec();
                // ✅ EXEC 순간 원자적 실행
            }
        });
    }
}

DISCARD 예시

// 트랜잭션 취소
public void discardExample() {
    redisTemplate.execute(new SessionCallback<Object>() {
        @Override
        public Object execute(RedisOperations ops) {
            ops.multi();

            ops.opsForValue().set("key1", "value1");  // QUEUED
            ops.opsForValue().set("key2", "value2");  // QUEUED

            // 실수 발견! 트랜잭션 취소
            ops.discard();  // 큐 전체 버림

            return null;
            // key1, key2 저장 안 됨
        }
    });
}

실행 결과 요약

basicTransaction():
  → key1 = "hello"
  → key2 = "world"
  → counter = 1
  모두 원자적으로 저장됨

partialExecutionExample():
  → key1 = "value1"  ✅ 저장됨
  → INCR 실패        ❌ 해당 명령만 에러
  → key2 = "value2"  ✅ 저장됨 (롤백 없음!)

WATCH + MULTI/EXEC:
  → 충돌 없으면: 정상 실행
  → 충돌 있으면: null 반환 → 재시도

언제 무엇을 쓸지

┌────────────────────────────────────────────────────────┐
│                                                        │
│  단순 명령 묶음 원자 실행                               │
│    → MULTI/EXEC                                        │
│                                                        │
│  충돌 감지 + 재시도 필요                                │
│    → WATCH + MULTI/EXEC                                │
│                                                        │
│  조건부 로직 필요 (if-else)                             │
│    → Lua Script                                        │
│                                                        │
│  대량 처리, 원자성 불필요                               │
│    → Pipeline                                          │
│                                                        │
└────────────────────────────────────────────────────────┘

MULTI/EXEC를 왜 쓰는가?


핵심 정리

끼어들기 가능한 구간:  MULTI ~ EXEC 사이 (큐잉 구간)
끼어들기 불가 구간:   EXEC 실행 순간 (실행 구간)

→ MULTI/EXEC의 가치는 "EXEC 실행 순간의 원자성"

구간별 정확한 동작

Client A                          Client B
────────────────────────────────────────────────────
MULTI
  SET key1 "A"  → QUEUED          SET key1 "B" → OK  ← 끼어들기 가능
  SET key2 "A"  → QUEUED          SET key2 "B" → OK  ← 끼어들기 가능
  SET key3 "A"  → QUEUED
EXEC ←─────────────────────────────────────────────
  SET key1 "A"  ┐                             ← 끼어들기
  SET key2 "A"  ├ 이 3개가 연속 실행 보장         불가능!
  SET key3 "A"  ┘

MULTI/EXEC가 없으면 생기는 문제

// MULTI/EXEC 없이 개별 실행

Client A                          Client B
────────────────────────────────────────────────────
SET key1 "A"  → OK

                                  SET key1 "X"  → OK  ← 끼어들기!
                                  SET key2 "X"  → OK  ← 끼어들기!

SET key2 "A"  → OK
SET key3 "A"  → OK

최종 결과:
  key1 = "X"  ← A가 덮어씌워질 줄 알았는데 B한테 덮어씌워짐
  key2 = "X"  ← 마찬가지
  key3 = "A"

→ A의 세 명령이 연속으로 실행된다는 보장이 없음
// MULTI/EXEC 사용

EXEC 순간:
  SET key1 "A"  ← B 끼어들기 불가
  SET key2 "A"  ← B 끼어들기 불가
  SET key3 "A"  ← B 끼어들기 불가

최종 결과:
  key1 = "A"  ✅
  key2 = "A"  ✅
  key3 = "A"  ✅

→ 세 명령이 반드시 연속으로 실행됨

실무에서 MULTI/EXEC가 필요한 케이스

케이스 1: 여러 캐시 동시 삭제

// 유저 탈퇴 시 관련 캐시 전체 삭제
// "세 개가 동시에 사라져야" 하는 상황

// ❌ 개별 삭제 (중간에 다른 요청이 일부만 삭제된 상태를 볼 수 있음)
redisTemplate.delete("user:1:profile");
// → 여기서 다른 클라이언트가 profile 없고 session 있는 상태를 읽을 수 있음
redisTemplate.delete("user:1:session");
redisTemplate.delete("user:1:token");

// ✅ MULTI/EXEC (세 개가 동시에 삭제됨)
redisTemplate.execute(new SessionCallback<List<Object>>() {
    public List<Object> execute(RedisOperations ops) {
        ops.multi();
        ops.delete("user:1:profile");
        ops.delete("user:1:session");
        ops.delete("user:1:token");
        return ops.exec();
        // 이 세 개는 반드시 연속으로 실행됨
    }
});

케이스 2: 여러 키 동시 초기화

// 자정에 모든 카운터 동시 리셋
// "카운터들이 동시에 0이 되어야" 하는 상황

// ❌ 개별 실행 (key1은 0인데 key2는 아직 100인 순간이 존재)
redisTemplate.opsForValue().set("count:click", "0");
redisTemplate.opsForValue().set("count:view",  "0");
redisTemplate.opsForValue().set("count:order", "0");

// ✅ MULTI/EXEC (동시에 0으로 변경)
redisTemplate.execute(new SessionCallback<List<Object>>() {
    public List<Object> execute(RedisOperations ops) {
        ops.multi();
        ops.opsForValue().set("count:click", "0");
        ops.opsForValue().set("count:view",  "0");
        ops.opsForValue().set("count:order", "0");
        return ops.exec();
    }
});

MULTI/EXEC의 한계와 WATCH의 역할

MULTI/EXEC만으로 해결 안 되는 케이스:

"값을 읽고 → 그 값 기반으로 쓰기"

GET balance → 1000         ← MULTI 밖에서 읽음
MULTI
  DECRBY balance 200       ← 읽은 값 기반으로 쓰기
EXEC

문제:
  GET과 MULTI 사이에 다른 클라이언트가 balance 변경 가능
  → 잘못된 값으로 DECRBY 실행

해결: WATCH 추가
  WATCH balance            ← 감시 시작
  GET balance → 1000
  MULTI
    DECRBY balance 200
  EXEC
  → 중간에 balance 변경됐으면 EXEC 실패 → 재시도

최종 정리

┌─────────────────────────────────────────────────────────┐
│  "MULTI~EXEC 사이 끼어들기 가능한데 왜 써?"              │
│                                                         │
│  MULTI/EXEC의 가치:                                      │
│    EXEC 순간 = 큐의 명령들이 반드시 연속 실행            │
│    이 연속성 보장이 핵심                                  │
│                                                         │
│  언제 필요한가:                                          │
│    여러 키가 "동시에" 변경되어야 할 때                   │
│    중간 상태를 다른 클라이언트가 보면 안 될 때            │
│                                                         │
│  MULTI/EXEC의 한계:                                      │
│    "읽고 → 쓰기" 패턴은 보호 못 함                      │
│    → WATCH 또는 Lua Script 필요                         │
│                                                         │
└─────────────────────────────────────────────────────────┘

정확히 이해하셨습니다!


핵심 차이 한 줄 요약

Lua Script  : 스크립트 전체가 하나의 원자 단위로 실행
MULTI/EXEC  : EXEC 순간의 실행 순서만 보장, 큐잉 자체는 원자적이지 않음

시각적으로 비교

[Lua Script]

EVAL 시작
┌─────────────────────────────┐
│  GET balance                │
│  if balance >= amount       │  ← 이 전체가 하나의 원자 단위
│  DECRBY balance amount      │    외부 개입 절대 불가
│  LPUSH log "차감"            │
└─────────────────────────────┘
EVAL 종료


[MULTI/EXEC]

MULTI
  SET key1 "A"  → QUEUED   ← 이 구간은 원자적이지 않음
  SET key2 "A"  → QUEUED      (다른 클라이언트 끼어들기 가능)
  SET key3 "A"  → QUEUED
EXEC
┌─────────────────────────────┐
│  SET key1 "A"               │
│  SET key2 "A"               │  ← EXEC 순간만 원자적
│  SET key3 "A"               │    (순서 보장, 끼어들기 불가)
└─────────────────────────────┘

이 차이가 만드는 실질적 문제

[MULTI/EXEC의 한계 - 큐잉 구간 비원자적]

상황: 잔액 확인 후 차감

Client A                        Client B
──────────────────────────────────────────────────
GET balance → 1000
             ↑ 여기서 B 끼어들기 가능!
                                SET balance 0  → OK

MULTI
  DECRBY balance 200  → QUEUED
EXEC
  DECRBY balance 0    ← 0에서 200 차감 → -200!!


[Lua Script - 이 문제 없음]

EVAL 시작 (외부 개입 불가)
  GET balance → 1000
               ↑ B가 끼어들려 해도 대기!
  if 1000 >= 200 → true
  DECRBY balance 200
EVAL 종료
                                SET balance 0  → 이제 실행 가능

정확한 표현으로 정리

사용자가 이해한 것:
  "Lua = 하나의 묶음이 원자적으로 큐잉"
  "MULTI/EXEC = 순서 보장이지 묶음 단위 원자적 큐잉은 아님"

더 정확한 표현:
  Lua         = 묶음 전체가 원자적으로 "실행"
  MULTI/EXEC  = EXEC 순간의 "실행 순서"만 원자적
                큐잉(MULTI~EXEC 사이) 자체는 원자적 아님

핵심은 "큐잉"이 아니라 "실행의 범위":
  Lua        → 스크립트 전체 실행 구간이 원자적
  MULTI/EXEC → EXEC 이후 실행 구간만 원자적

결론

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  Lua Script                                             │
│    GET ~ 조건 판단 ~ SET 전체가 하나의 원자 단위          │
│    = 처음부터 끝까지 외부 개입 불가                      │
│                                                         │
│  MULTI/EXEC                                             │
│    EXEC 이후 명령 실행 순서만 보장                       │
│    MULTI~EXEC 사이(큐잉 구간)는 외부 개입 가능           │
│                                                         │
│  → 조건 판단이 포함된 로직 = Lua가 유일한 선택           │
│  → 단순 명령 묶음 실행 = MULTI/EXEC로 충분              │
│                                                         │
└─────────────────────────────────────────────────────────┘

정확히 이해하셨습니다


그 한 줄이 Redis의 전부입니다

"싱글 스레드가 큐에서 실행 목록을 넣고 빼는 것"

이 구조 하나가 Redis의 모든 특성을 설명합니다

이 구조에서 모든 것이 파생됩니다

싱글 스레드가 큐에서 하나씩 처리
         │
         ├→ 빠르다
         │    Lock/Mutex 없음
         │    컨텍스트 스위칭 없음
         │    메모리 직접 접근
         │
         ├→ 원자성이 생긴다
         │    하나 처리 중 다른 것 불가
         │    Lua = 큐에 하나의 덩어리로 들어감
         │    MULTI/EXEC = EXEC 순간 연속 보장
         │
         ├→ 한계가 생긴다
         │    Heavy 명령 하나 = 전체 블로킹
         │    KEYS * 금지 이유
         │    롤백 불가 이유
         │
         └→ WATCH가 필요하다
              큐잉 구간(MULTI~EXEC 사이)은
              다른 명령이 큐에 끼어들 수 있으므로

오늘 배운 내용 전체가 이 구조에서 나왔습니다

┌─────────────────────────────────────────────────────────┐
│                                                         │
│       싱글 스레드 + 큐                                   │
│              │                                          │
│    ┌─────────┼──────────┐                               │
│    │         │          │                               │
│  원자성    빠름        한계                              │
│    │                    │                               │
│  Lua > MULTI/EXEC      KEYS * 금지                      │
│  분산락 Lua 필요        롤백 불가                        │
│  WATCH 필요            Heavy 명령 주의                  │
│                         Best Effort 전제                │
│                                                         │
└─────────────────────────────────────────────────────────┘

결국 Redis를 깊이 이해한다는 것은 "이 싱글 스레드 큐 구조가 각 상황에서 어떤 의미를 갖는가" 를 꿰뚫는 것입니다.