🛒 경쟁 상태: 재고가 마이너스가 됐어요!
온라인 쇼핑몰에서 마지막 남은 티켓 1장에 두 명이 동시에 "구매" 버튼을 눌렀습니다. 두 요청이 거의 동시에 처리되면 어떻게 될까요? 첫 번째 요청이 재고를 읽고(1개), 두 번째 요청도 재고를 읽고(여전히 1개), 첫 번째가 1을 빼고(0개), 두 번째도 1을 빼면 재고가 -1이 됩니다.
이것이 경쟁 상태(Race Condition)입니다. 두 요청이 서로의 존재를 모르고 같은 데이터를 동시에 수정하면서 데이터 무결성이 깨지는 상황이죠. 일반적인 데이터베이스에서는 트랜잭션과 잠금(Lock)으로 이를 해결합니다.
Redis도 트랜잭션을 지원합니다. MULTI로 트랜잭션을 시작하고 EXEC로 실행합니다. 이 사이의 모든 명령은 원자적으로 실행됩니다. 즉, 다른 클라이언트의 명령이 중간에 끼어들 수 없습니다.
하지만 Redis 트랜잭션은 SQL 데이터베이스의 트랜잭션과 다릅니다. 중요한 차이점이 있고, 이를 모르면 예상치 못한 버그가 생깁니다. 이번 편에서 그 차이를 명확히 이해하고 WATCH를 활용한 낙관적 락 패턴까지 배워봅니다.
🔐 핵심 개념 1: MULTI/EXEC — 원자적 실행
Redis 트랜잭션의 핵심은 "중간에 끼어들기 방지"입니다. MULTI와 EXEC 사이의 명령들은 큐에 쌓였다가 EXEC 시점에 한꺼번에 실행됩니다. 실행 중에는 다른 클라이언트의 명령이 끼어들 수 없습니다.
# Lab 7 — MULTI/EXEC 기본 실습
DEL foo bar
MULTI # 트랜잭션 시작
INCR foo # → QUEUED (바로 실행 안 됨)
INCR foo # → QUEUED
INCR bar # → QUEUED
EXEC # 실행!
# 결과:
# 1) (integer) 1 ← INCR foo 결과
# 2) (integer) 2 ← INCR foo 결과
# 3) (integer) 1 ← INCR bar 결과
# DISCARD: 트랜잭션 취소
MULTI
SET key1 "값1"
DISCARD # 큐 전체 취소 → 아무것도 실행되지 않음
🚨 Redis 트랜잭션 ≠ SQL 트랜잭션!
SQL의 ROLLBACK과 달리, Redis 트랜잭션에서 EXEC 후 일부 명령이 실패해도 나머지는 계속 실행됩니다. 예를 들어 INCR을 문자열 키에 실행하면 오류가 나지만, 트랜잭션 내 다른 명령은 정상 실행됩니다. 롤백이 없습니다!
SET string_key "hello"
MULTI
INCR string_key # → QUEUED (오류는 EXEC 시점에 발생)
SET normal_key "ok" # → QUEUED
EXEC
# 결과:
# 1) (error) ERR value is not an integer ← INCR 실패
# 2) OK ← SET은 성공!
# 트랜잭션 전체가 취소되지 않음!
GET normal_key # → "ok" (정상 실행됨)
👁️ 핵심 개념 2: WATCH — 낙관적 락(CAS)
MULTI/EXEC만으로는 경쟁 상태를 완전히 막지 못합니다. 재고를 읽고(GET), 계산하고, 쓰는(SET) 과정 사이에 다른 클라이언트가 재고를 변경할 수 있거든요. 이를 해결하는 것이 WATCH입니다.
WATCH는 특정 키를 감시합니다. 감시 중에 다른 클라이언트가 그 키를 변경하면, 이후 EXEC는 nil을 반환하며 트랜잭션 전체를 취소합니다. 이를 감지하고 다시 시도하는 패턴이 낙관적 락(Optimistic Locking)입니다.
# WATCH 기반 재고 차감 패턴
SET stock:item1 5 # 초기 재고 5개
WATCH stock:item1 # 감시 시작
# (이 시점에 다른 클라이언트가 stock:item1을 변경하면 EXEC 실패)
MULTI
DECRBY stock:item1 1 # → QUEUED
EXEC
# 아무도 변경하지 않았다면 → 정상 실행, 재고 4
# 다른 클라이언트가 변경했다면 → (nil) 반환, 재시도 필요
🐍 Lab 7: Python으로 WATCH 재시도 루프 구현
실제 코드에서는 EXEC가 nil을 반환하면 처음부터 다시 시도하는 루프를 작성합니다.
# pip install redis
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def purchase_item(item_key: str, quantity: int = 1, max_retries: int = 10) -> bool:
"""
WATCH + MULTI/EXEC 낙관적 락으로 재고 차감
재시도 최대 max_retries번
"""
for attempt in range(max_retries):
with r.pipeline() as pipe:
try:
# 1. 키 감시 시작
pipe.watch(item_key)
# 2. 현재 재고 확인 (WATCH 모드에서는 일반 명령 실행 가능)
current_stock = int(pipe.get(item_key) or 0)
if current_stock < quantity:
pipe.reset() # WATCH 해제
print(f"[재고 부족] 현재: {current_stock}, 요청: {quantity}")
return False
# 3. 트랜잭션 시작
pipe.multi()
pipe.decrby(item_key, quantity)
# 4. EXEC: 감시 중 변경이 없었다면 실행, 있었다면 WatchError 발생
pipe.execute()
print(f"[구매 성공] 재고 차감: {current_stock} → {current_stock - quantity}")
return True
except redis.WatchError:
# 다른 클라이언트가 변경 → 재시도
print(f"[경쟁 감지] 재시도 중... (시도 {attempt + 1}/{max_retries})")
continue
print("[실패] 최대 재시도 초과")
return False
# ── 실행 ──
if __name__ == "__main__":
r.set("stock:item1", 5) # 재고 초기화
# 단순 구매
purchase_item("stock:item1", quantity=1)
purchase_item("stock:item1", quantity=2)
print(f"\n남은 재고: {r.get('stock:item1')}")
# 재고 부족 테스트
purchase_item("stock:item1", quantity=10) # 재고 2인데 10개 요청
redis-py에서는
pipe.watch(key)로 감시하고, pipe.multi()로 MULTI를 시작합니다. WatchError가 발생하면 재시도합니다. with 문은 자동으로 UNWATCH/RESET을 처리합니다.🟢 Lab 7: Node.js로 WATCH 구현
// npm install redis
import { createClient } from "redis";
const client = createClient();
client.on("error", (err) => console.error("Redis Client Error", err));
await client.connect();
async function purchaseItem(itemKey, quantity = 1, maxRetries = 10) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
// Node-redis에서는 격리된 클라이언트로 WATCH 처리
const watchClient = client.duplicate();
await watchClient.connect();
try {
await watchClient.watch(itemKey);
const currentStock = parseInt(await watchClient.get(itemKey) || "0");
if (currentStock < quantity) {
await watchClient.unwatch();
console.log(`[재고 부족] 현재: ${currentStock}, 요청: ${quantity}`);
return false;
}
// MULTI/EXEC
const result = await watchClient
.multi()
.decrBy(itemKey, quantity)
.exec();
if (result === null) {
// EXEC가 null 반환 = WatchError (다른 클라이언트가 변경)
console.log(`[경쟁 감지] 재시도... (${attempt + 1}/${maxRetries})`);
continue;
}
console.log(`[구매 성공] 남은 재고: ${result[0]}`);
return true;
} finally {
await watchClient.quit();
}
}
console.log("[실패] 최대 재시도 초과");
return false;
}
// 실행
await client.set("stock:item1", "5");
await purchaseItem("stock:item1", 1);
await purchaseItem("stock:item1", 2);
console.log(`남은 재고: ${await client.get("stock:item1")}`);
await client.quit();
⚠️ 실전 팁: 분산 락(Redlock)은 선택 학습
WATCH 패턴은 단일 Redis 인스턴스에서의 낙관적 락입니다. 여러 Redis 인스턴스에 걸친 분산 환경에서는 Redlock이라는 알고리즘이 사용됩니다. 하지만 Redlock은 논쟁이 있는 주제입니다.
Redis 창시자 Salvatore Sanfilippo가 Redlock을 제안했고, 분산 시스템 전문가 Martin Kleppmann이 "네트워크 지연·GC 정지 시나리오에서 안전하지 않을 수 있다"고 비판했습니다. Redis 공식 문서도 이 논쟁을 언급합니다. correctness-critical 시스템(결제, 재고 등)에서는 Redlock보다 ZooKeeper나 etcd 같은 전용 분산 락 서비스를 검토하는 것이 더 안전할 수 있습니다.
MULTI/EXEC 사용 시 주의사항 정리
| 상황 | 동작 |
|---|---|
| MULTI 후 명령 구문 오류 | EXEC 시 전체 트랜잭션 취소 (EXECABORT) |
| MULTI 후 명령 실행 오류 (예: INCR on string) | 해당 명령만 오류, 나머지는 정상 실행 (롤백 없음!) |
| WATCH 중 다른 클라이언트가 키 변경 | EXEC 반환 nil, 트랜잭션 취소 → 재시도 필요 |
| Redis 재시작 (AOF 없이) | EXEC 전 상태 복구 불가 (퍼시스턴스 필요) |
📌 8편 핵심 요약
- MULTI/EXEC: 중간 끼어들기 방지 보장, 하지만 롤백은 없음
- 트랜잭션 내 오류: 일부 명령 실패 시 나머지는 계속 실행 → SQL과 다름!
- WATCH: 낙관적 락 구현 — 감시 중 변경 감지 시 EXEC가 nil 반환
- 재시도 루프: WatchError 발생 시 처음부터 다시 시도하는 패턴 필수
- Redlock: 분산 락 알고리즘, 논쟁 있음 → 크리티컬 시스템엔 전용 솔루션 검토
✅ 8편 체크리스트
- MULTI/EXEC로 여러 명령을 원자적으로 실행했다
- 트랜잭션 내 일부 명령 실패 시 나머지는 실행됨을 확인했다 (롤백 없음)
- DISCARD로 트랜잭션을 취소했다
- WATCH로 키를 감시하고 변경 감지 시 EXEC가 nil을 반환함을 확인했다
- Python 또는 Node.js로 WATCH 재시도 루프를 구현했다
- Redis 트랜잭션과 SQL 트랜잭션의 차이를 설명할 수 있다
🧠 퀴즈 3문항
Q1. MULTI/EXEC 트랜잭션에서 하나의 명령이 오류나면 전체가 취소되나요?
Q2. WATCH 키를 감시 중에 다른 클라이언트가 해당 키를 변경하면?
Q3. 경쟁 상태가 매우 심할 때 WATCH 재시도 루프의 단점은?
📝 과제 3가지
- 포인트 이체: MULTI/EXEC로 user_A의 포인트를 100 감소, user_B의 포인트를 100 증가하는 트랜잭션을 만드세요.
- 충돌 시뮬레이션: WATCH 감시 후 MULTI 전에 다른 창에서 직접 키를 변경하고, EXEC가 nil을 반환하는 것을 확인하세요.
- Lua 스크립트 탐구: EVAL 명령으로 간단한 Lua 스크립트를 실행해보고, MULTI/EXEC와의 차이점을 정리하세요.
'개발 > REDIS' 카테고리의 다른 글
| #10 🔄 복제·고가용성: Replication·Sentinel (0) | 2026.03.19 |
|---|---|
| #9 💾 퍼시스턴스: RDB·AOF·복구 체크 (0) | 2026.03.19 |
| #7 📡 Pub/Sub vs Streams: 실시간 메시징 (0) | 2026.03.19 |
| #6🏆 Sorted Sets: 랭킹/리더보드 (1) | 2026.03.19 |
| #5 📬 Lists·Sets: 큐와 중복 방지 (0) | 2026.03.19 |