개발/REDIS

#8 ⚔️ 동시성 제어: 트랜잭션(MULTI/EXEC)·WATCH

cedis 2026. 3. 19. 10:10
재고 5개인데 동시 주문 10개가 들어왔다면? Redis 트랜잭션과 WATCH로 경쟁 상태(Race Condition)를 안전하게 처리하는 법을 배웁니다.

🛒 경쟁 상태: 재고가 마이너스가 됐어요!

온라인 쇼핑몰에서 마지막 남은 티켓 1장에 두 명이 동시에 "구매" 버튼을 눌렀습니다. 두 요청이 거의 동시에 처리되면 어떻게 될까요? 첫 번째 요청이 재고를 읽고(1개), 두 번째 요청도 재고를 읽고(여전히 1개), 첫 번째가 1을 빼고(0개), 두 번째도 1을 빼면 재고가 -1이 됩니다.

이것이 경쟁 상태(Race Condition)입니다. 두 요청이 서로의 존재를 모르고 같은 데이터를 동시에 수정하면서 데이터 무결성이 깨지는 상황이죠. 일반적인 데이터베이스에서는 트랜잭션과 잠금(Lock)으로 이를 해결합니다.

Redis도 트랜잭션을 지원합니다. MULTI로 트랜잭션을 시작하고 EXEC로 실행합니다. 이 사이의 모든 명령은 원자적으로 실행됩니다. 즉, 다른 클라이언트의 명령이 중간에 끼어들 수 없습니다.

하지만 Redis 트랜잭션은 SQL 데이터베이스의 트랜잭션과 다릅니다. 중요한 차이점이 있고, 이를 모르면 예상치 못한 버그가 생깁니다. 이번 편에서 그 차이를 명확히 이해하고 WATCH를 활용한 낙관적 락 패턴까지 배워봅니다.

🔐 핵심 개념 1: MULTI/EXEC — 원자적 실행

Redis 트랜잭션의 핵심은 "중간에 끼어들기 방지"입니다. MULTI와 EXEC 사이의 명령들은 큐에 쌓였다가 EXEC 시점에 한꺼번에 실행됩니다. 실행 중에는 다른 클라이언트의 명령이 끼어들 수 없습니다.

MULTI
명령 큐잉
EXEC
원자적 실행
redis-cli
# 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을 문자열 키에 실행하면 오류가 나지만, 트랜잭션 내 다른 명령은 정상 실행됩니다. 롤백이 없습니다!

redis-cli — 트랜잭션 내 오류 동작
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 key
GET key
MULTI
SET key
EXEC (nil이면 재시도)
redis-cli
# 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을 반환하면 처음부터 다시 시도하는 루프를 작성합니다.

Python
# 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의 pipeline().watch()
redis-py에서는 pipe.watch(key)로 감시하고, pipe.multi()로 MULTI를 시작합니다. WatchError가 발생하면 재시도합니다. with 문은 자동으로 UNWATCH/RESET을 처리합니다.

🟢 Lab 7: Node.js로 WATCH 구현

Node.js
// 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은 논쟁이 있는 주제입니다.

📖 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편 핵심 요약

  1. MULTI/EXEC: 중간 끼어들기 방지 보장, 하지만 롤백은 없음
  2. 트랜잭션 내 오류: 일부 명령 실패 시 나머지는 계속 실행 → SQL과 다름!
  3. WATCH: 낙관적 락 구현 — 감시 중 변경 감지 시 EXEC가 nil 반환
  4. 재시도 루프: WatchError 발생 시 처음부터 다시 시도하는 패턴 필수
  5. Redlock: 분산 락 알고리즘, 논쟁 있음 → 크리티컬 시스템엔 전용 솔루션 검토

✅ 8편 체크리스트

  • MULTI/EXEC로 여러 명령을 원자적으로 실행했다
  • 트랜잭션 내 일부 명령 실패 시 나머지는 실행됨을 확인했다 (롤백 없음)
  • DISCARD로 트랜잭션을 취소했다
  • WATCH로 키를 감시하고 변경 감지 시 EXEC가 nil을 반환함을 확인했다
  • Python 또는 Node.js로 WATCH 재시도 루프를 구현했다
  • Redis 트랜잭션과 SQL 트랜잭션의 차이를 설명할 수 있다

🧠 퀴즈 3문항

Q1. MULTI/EXEC 트랜잭션에서 하나의 명령이 오류나면 전체가 취소되나요?
아닙니다. Redis 트랜잭션은 실행 중 오류가 있어도 나머지 명령을 계속 실행합니다. SQL과 달리 롤백 기능이 없습니다. 단, MULTI 이후 큐잉 단계에서의 구문 오류는 EXEC 시 전체 취소됩니다.
Q2. WATCH 키를 감시 중에 다른 클라이언트가 해당 키를 변경하면?
🚫 EXEC가 nil을 반환하며 트랜잭션 전체가 취소됩니다. 애플리케이션은 이를 감지하고 처음부터 재시도해야 합니다 (낙관적 락 패턴).
Q3. 경쟁 상태가 매우 심할 때 WATCH 재시도 루프의 단점은?
♾️ 충돌이 자주 일어날수록 재시도 횟수가 증가해 성능이 저하됩니다. 경쟁이 매우 심한 상황에서는 WATCH보다 단순히 INCR/DECR(원자적 명령)을 사용하거나, Lua 스크립트를 활용하는 것이 더 효율적일 수 있습니다.

📝 과제 3가지

  1. 포인트 이체: MULTI/EXEC로 user_A의 포인트를 100 감소, user_B의 포인트를 100 증가하는 트랜잭션을 만드세요.
  2. 충돌 시뮬레이션: WATCH 감시 후 MULTI 전에 다른 창에서 직접 키를 변경하고, EXEC가 nil을 반환하는 것을 확인하세요.
  3. Lua 스크립트 탐구: EVAL 명령으로 간단한 Lua 스크립트를 실행해보고, MULTI/EXEC와의 차이점을 정리하세요.