계좌 A에서 계좌 B로 돈을 이체합니다. A에서 차감하는 도중 서버가 죽으면 B에 입금이 안 됩니다. 이 두 연산은 반드시 함께 성공하거나 함께 실패해야 합니다. Redis 트랜잭션이 이를 보장합니다.
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는 특정 키를 감시합니다. 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): 충돌이 드물다고 가정하고, 먼저 작업을 시도한 뒤 충돌이 발생했을 때만 재시도하는 방식. 비관적 락(항상 락을 거는 방식)보다 동시성이 높습니다.
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 트랜잭션은 롤백을 지원하지 않습니다.
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는 원자성을 보장하지만 파이프라인보다 약간 느립니다.
복잡한 조건부 로직이 필요하다면 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 스크립트는 실행 중 단 하나의 스레드에서 실행되므로 완전한 원자성이 보장됩니다. 트랜잭션보다 유연하지만 디버깅이 어렵습니다.