Redis Transaction: MULTI / EXEC / WATCH 완전 정리
3) Redis Transaction: MULTI / EXEC
✅ Redis 트랜잭션 vs DB 트랜잭션
| 항목 | Redis MULTI/EXEC | RDBMS 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 로직) | Lua | MULTI/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 Script | MULTI/EXEC는 if-else 불가 |
| 분산락 해제 | Lua Script | 토큰 비교 + DEL 원자적 처리 |
| 낙관적 락 (CAS 패턴) | WATCH + MULTI/EXEC | 충돌 감지 후 재시도 패턴 |
| 클러스터 환경 복잡 로직 | Lua Script | Hash 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를 깊이 이해한다는 것은 "이 싱글 스레드 큐 구조가 각 상황에서 어떤 의미를 갖는가" 를 꿰뚫는 것입니다.