개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

Redis 트랜잭션 — MULTI/EXEC로 여러 명령 원자적 실행

2026년 2월 8일
RedisTransactionMULTIEXEC원자성

계좌 A에서 계좌 B로 돈을 이체합니다. A에서 차감하는 도중 서버가 죽으면 B에 입금이 안 됩니다. 이 두 연산은 반드시 함께 성공하거나 함께 실패해야 합니다. Redis 트랜잭션이 이를 보장합니다.

MULTI / EXEC

MULTI로 트랜잭션을 시작하고, EXEC로 일괄 실행합니다.

MULTI
SET account:A 900
SET account:B 1100
EXEC

MULTI와 EXEC 사이의 명령은 큐에 쌓입니다. EXEC를 호출하면 큐에 있는 명령을 순서대로 실행합니다. 실행 중 다른 클라이언트의 명령이 끼어들 수 없습니다.

원자적 실행(Atomic Execution): 여러 명령이 하나의 단위로 처리되는 것. 중간 상태가 외부에 노출되지 않습니다.

트랜잭션 취소

DISCARD로 큐를 비우고 트랜잭션을 취소합니다.

MULTI
SET account:A 900
DISCARD   # 취소, 아무것도 실행 안 됨

큐에 쌓인 명령 확인

MULTI
SET key1 "value1"
# "QUEUED" 반환 — 즉시 실행되지 않고 큐에 쌓임
GET key2
# "QUEUED"
EXEC
# 1) OK
# 2) (nil)

WATCH — 낙관적 락

WATCH는 특정 키를 감시합니다. EXEC 전에 감시 중인 키가 변경되면 트랜잭션이 실패합니다.

WATCH account:A

# 현재 잔액 확인
GET account:A   # 1000

MULTI
DECRBY account:A 100
INCRBY account:B 100
EXEC
# 성공: [[OK], [OK]]
# 실패(A가 변경됨): nil

EXEC가 nil을 반환하면 트랜잭션 실패입니다. 재시도해야 합니다.

낙관적 락(Optimistic Lock): 충돌이 드물다고 가정하고, 먼저 작업을 시도한 뒤 충돌이 발생했을 때만 재시도하는 방식. 비관적 락(항상 락을 거는 방식)보다 동시성이 높습니다.

Node.js 구현 예시

async function transfer(
  fromId: string,
  toId: string,
  amount: number
): Promise<boolean> {
  while (true) {
    // 감시 시작
    await redis.watch(`account:${fromId}`);

    const balance = parseInt(await redis.get(`account:${fromId}`) ?? "0");
    if (balance < amount) {
      await redis.unwatch();
      throw new Error("잔액 부족");
    }

    // 트랜잭션 실행
    const result = await redis
      .multi()
      .decrby(`account:${fromId}`, amount)
      .incrby(`account:${toId}`, amount)
      .exec();

    if (result === null) {
      // 누군가 fromId를 변경함 → 재시도
      continue;
    }

    return true;
  }
}

Redis 트랜잭션의 특이점

롤백이 없다

관계형 데이터베이스와 달리 Redis 트랜잭션은 롤백을 지원하지 않습니다.

MULTI
SET key1 "ok"
INCR not_a_number   # 에러: 숫자가 아닌 키에 INCR
SET key3 "ok"
EXEC

# 결과
# 1) OK          ← key1 성공
# 2) ERR         ← 에러 발생
# 3) OK          ← key3 성공 (롤백 안 됨!)

트랜잭션 실행 중 에러가 발생해도 나머지 명령은 계속 실행됩니다. 이는 설계 결정입니다. Redis는 "프로그래밍 에러는 개발자가 미리 방지해야 한다"는 입장입니다.

단, MULTI 후 큐에 쌓는 시점에 문법 에러가 발생하면 전체 트랜잭션을 취소합니다.

MULTI
SET key1 "ok"
NOTACOMMAND   # 문법 에러
SET key3 "ok"
EXEC
# EXECABORT — 전체 취소

파이프라인과의 차이

파이프라인은 여러 명령을 네트워크 왕복 없이 한 번에 보내는 최적화입니다. 원자성은 보장하지 않습니다. MULTI/EXEC는 원자성을 보장하지만 파이프라인보다 약간 느립니다.

Lua 스크립트와 비교

복잡한 조건부 로직이 필요하다면 MULTI/EXEC 대신 Lua 스크립트를 씁니다.

EVAL "
  local balance = tonumber(redis.call('GET', KEYS[1]))
  if balance >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    redis.call('INCRBY', KEYS[2], ARGV[1])
    return 1
  end
  return 0
" 2 account:A account:B 100

Lua 스크립트는 실행 중 단 하나의 스레드에서 실행되므로 완전한 원자성이 보장됩니다. 트랜잭션보다 유연하지만 디버깅이 어렵습니다.

← 이전 글Redis Streams — 신뢰성 있는 메시지 큐
다음 글 →Redis 모니터링 — INFO, SLOWLOG, LATENCY로 상태 파악하기