Redis의 원자적 트랜잭션 - 정확한 이해
결론부터
"어떤 의미의 원자성이냐"에 따라 답이 다릅니다.
원자성(Atomicity)의 두 가지 의미
원자성의 의미 1: "중단 없음 (Non-interruptible)"
→ 실행 도중 다른 연산이 끼어들 수 없다
→ Redis ✅ 보장
원자성의 의미 2: "All or Nothing"
→ 전체 성공 OR 전체 실패 (중간 상태 없음)
→ Redis ❌ 보장 안 함 (롤백 없음)
RDBMS vs Redis 원자성 비교
[RDBMS - 완전한 원자성]
BEGIN
UPDATE balance SET amount = amount - 200 ← 성공
UPDATE log SET ... ← 실패
ROLLBACK
→ 첫 번째 UPDATE도 취소됨 ✅ (All or Nothing)
[Redis MULTI/EXEC - 제한적 원자성]
MULTI
DECRBY balance 200 ← 성공
INCR string_key ← 런타임 에러 (string에 INCR)
EXEC
→ DECRBY는 그대로 반영됨 ❌ (롤백 없음)
그럼 Redis에서 "강한 원자성"은 어떻게 구현하나?
✅ Lua Script가 가장 강력한 원자성 제공
-- 이 스크립트 전체가 하나의 원자 단위
-- 실행 중 끼어들기 불가 + 에러 시 즉시 중단 (이후 명령 미실행)
local balance = tonumber(redis.call('GET', KEYS[1]))
if balance < tonumber(ARGV[1]) then
return redis.error_reply("잔액 부족") -- 여기서 중단, 아래 실행 안 됨
end
redis.call('DECRBY', KEYS[1], ARGV[1]) -- 위 조건 통과 시만 실행
redis.call('LPUSH', KEYS[2], ARGV[2]) -- 로그 기록
return 1
Lua는 에러 발생 시 이후 명령을 실행하지 않습니다. 단, 이미 실행된 명령은 롤백되지 않습니다.
세 가지 방식의 원자성 수준 비교
강한 원자성
▲
│ RDBMS Transaction All or Nothing + 롤백 보장
│
│ Redis Lua Script 끼어들기 방지 + 에러 시 중단
│ (이미 실행된 건 롤백 안 됨)
│
│ Redis MULTI/EXEC 끼어들기 방지만
│ (런타임 에러 시 부분 실행)
│
▼
약한 원자성
왜 Redis는 롤백을 지원하지 않나요?
Redis 공식 문서의 설명:
"Redis commands can fail only if called with a wrong syntax, or against keys holding the wrong data type. This means that a failing command is the result of a programming error, and a kind of error that is very likely to be noticed during development."
- Redis 설계 철학 상 런타임 에러 = 프로그래머 실수
- 롤백 구현 비용 vs 실제 필요성 트레이드오프에서 성능/단순성 우선 선택
- 롤백을 위한 복잡한 undo log 유지가 Redis의 고속 처리 철학에 맞지 않음
실무 결론
Redis가 구조적으로 "완전한 원자성"이 불가능한 이유
핵심 질문: RDBMS는 어떻게 롤백이 가능한가?
Redis를 이해하려면 먼저 RDBMS가 롤백을 어떻게 구현하는지 알아야 합니다.
[RDBMS 트랜잭션 내부 구조]
BEGIN
UPDATE balance = 800 ──→ ① Undo Log에 이전 값(1000) 기록
② Redo Log(WAL)에 변경 내용 기록
③ 실제 데이터 변경
UPDATE log = ... ──→ ① Undo Log에 이전 값 기록
② ...실패!
ROLLBACK
└→ Undo Log 역순으로 읽어서 이전 값으로 복원
RDBMS가 롤백 가능한 이유:
✅ Undo Log : "변경 전 값"을 별도로 보관
✅ Redo Log : "무엇을 변경했는지" 기록 (WAL)
✅ 트랜잭션 관리자 : 로그 기반으로 복원 수행
Redis가 이를 구현하지 않은 이유
1. 설계 철학: 단순성과 속도 우선
RDBMS 트랜잭션 실행 흐름:
명령 실행
→ Undo Log 기록 ← I/O
→ Redo Log(WAL) 기록 ← I/O
→ 실제 데이터 변경
→ (실패 시) Undo Log 읽기 → 복원
Redis 명령 실행 흐름:
명령 실행
→ 즉시 메모리 반영 ← 끝
로그 기록 없음 = 오버헤드 없음 = 초고속
Redis의 핵심 가치는 "초저지연 인메모리 처리" 입니다. Undo Log 유지는 이 철학과 정면으로 충돌합니다.
2. 구조적 이유: 인메모리 + 로그 없음
┌─────────────────────────────────────────────────────┐
│ RDBMS 구조 │
│ │
│ 명령 → [Undo Log] → [Redo Log/WAL] → [Buffer Pool] │
│ ↑ ↑ │
│ (롤백 근거) (복구 근거) │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Redis 구조 │
│ │
│ 명령 ──────────────────────────→ [메모리 직접 반영] │
│ │
│ Undo Log 없음 → 롤백 근거 없음 │
│ 트랜잭션 관리자 없음 → 복원 주체 없음 │
└─────────────────────────────────────────────────────┘
3. 단일 스레드 모델과의 관계
Redis 단일 스레드 처리 구조:
[Event Loop]
│
├→ 명령1 수신 → 즉시 메모리 반영 → 응답
├→ 명령2 수신 → 즉시 메모리 반영 → 응답
└→ ...
"즉시 반영" 모델에서 롤백 구현의 문제:
명령A 실행 → 메모리 반영
명령B 실행 → 메모리 반영
명령C 실행 → 실패
→ 롤백하려면 A, B의 "이전 상태"를 어딘가 보관해야 함
→ 근데 그 보관 비용이 Redis가 포기한 것
4. Redis 공식 입장 (설계 의도)
"Redis does not support rollbacks of transactions since supporting rollbacks would have a significant impact on the simplicity and performance of Redis."
— Redis 공식 문서
Redis의 판단:
런타임 에러 = 프로그래머 실수 (잘못된 타입 사용 등)
→ 프로덕션에서 발생하면 안 되는 버그
→ 이를 위해 모든 명령에 Undo 오버헤드 추가 = 비합리적
구조적 차이 한눈에 비교
┌──────────────────┬───────────────────┬───────────────────┐
│ │ RDBMS │ Redis │
├──────────────────┼───────────────────┼───────────────────┤
│ 저장 위치 │ 디스크 (+ 버퍼) │ 메모리 │
│ Undo Log │ ✅ 있음 │ ❌ 없음 │
│ Redo Log (WAL) │ ✅ 있음 │ AOF (다른 목적) │
│ 트랜잭션 관리자 │ ✅ 있음 │ ❌ 없음 │
│ 롤백 │ ✅ 가능 │ ❌ 불가 │
│ 명령 실행 방식 │ 로그 → 반영 │ 즉시 반영 │
│ 쓰기 속도 │ 상대적으로 느림 │ 초고속 │
└──────────────────┴───────────────────┴───────────────────┘
AOF는 롤백에 쓰면 안 되나?
Redis에도 AOF(Append Only File)라는 로그가 있는데 왜 롤백에 못 쓰나?
[RDBMS Undo Log] vs [Redis AOF]
목적: 트랜잭션 롤백용 목적: 서버 재시작 시 데이터 복구용
시점: 명령 실행 전 이전값 기록 시점: 명령 실행 후 결과 기록
내용: "변경 전 상태" 내용: "실행된 명령어 목록"
→ AOF는 "무슨 명령이 실행됐나" 기록
→ Undo Log는 "실행 전 값이 뭐였나" 기록
AOF로는 "이전 상태 복원" 불가능
(명령의 역연산을 계산하는 건 별도 복잡한 구현 필요)
최종 요약
Lua Script가 원자성이 더 강한 이유
핵심 차이: 누가 로직을 처리하는가
[MULTI/EXEC]
로직 판단 → 클라이언트
명령 실행 → 서버
[Lua Script]
로직 판단 → 서버
명령 실행 → 서버
클라이언트 ↔ 서버 왕복이 없으므로 "판단 ~ 실행" 사이에 틈이 존재하지 않습니다.
구체적으로 왜 강한가?
이유 1: 실행 중 다른 명령 끼어들기 절대 불가
[MULTI/EXEC]
Client A: MULTI → SET k1 → INCR k2 → EXEC
↑
여기서만 원자적
(EXEC 실행 순간에만)
MULTI ~ EXEC 사이(큐잉 중)는 다른 클라이언트 실행 가능!
[Lua Script]
Client A: EVAL script 시작
┌──────────────────────┐
│ GET balance │ ← 이 구간 전체가
│ if balance >= 200 │ 완전히 원자적
│ DECRBY balance 200 │ (다른 명령 불가)
└──────────────────────┘
EVAL script 종료
타임라인으로 보면:
MULTI/EXEC:
──[MULTI]──[QUEUED]──[QUEUED]──[EXEC: 원자구간]──
↑
여기서 다른 클라이언트 실행 가능
Lua Script:
──[EVAL: ←────────── 원자구간 ──────────────→]──
(시작부터 끝까지 단 한 번도 안 끊김)
이유 2: 조건부 로직도 원자적으로 처리
[MULTI/EXEC의 한계]
① Client: GET balance → 1000 (값 읽기)
↑ 여기서 다른 클라이언트가 balance 를 0으로 만들 수 있음
② Client: 조건 판단 (1000 >= 200 → true)
③ Client: MULTI
④ Client: DECRBY balance 200 (이미 0인데 차감됨!)
⑤ Client: EXEC
[Lua Script]
EVAL 시작 (이 순간부터 외부 차단)
① GET balance → 1000
(다른 클라이언트 개입 불가)
② if 1000 >= 200 → true
(다른 클라이언트 개입 불가)
③ DECRBY balance 200
EVAL 종료
-- 이 전체 흐름이 단 하나의 원자 단위
local balance = tonumber(redis.call('GET', KEYS[1]))
-- 이 판단과 실행 사이에 아무것도 끼어들 수 없음
if balance >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
end
return 0
이유 3: 에러 시 이후 명령 차단 (MULTI/EXEC와의 차이)
[MULTI/EXEC - 런타임 에러]
SET k1 "hello" → ✅ 성공 (반영됨)
INCR k1 → ❌ 실패 (string에 INCR)
SET k2 "world" → ✅ 성공 (계속 실행됨!)
→ 에러를 "무시하고" 다음 명령 실행
→ k1, k2 모두 저장된 부분 실행 상태
[Lua Script - redis.call()]
redis.call('SET', k1, 'hello') → ✅ 성공 (반영됨)
redis.call('INCR', k1) → ❌ 에러 → 즉시 중단!
redis.call('SET', k2, 'world') → 🚫 실행조차 안 됨
→ 에러 이후 명령은 실행하지 않음
→ k1만 저장된 상태 (k2는 건드리지 않음)
MULTI/EXEC vs Lua 원자성 수준 비교
┌───────────────────────┬──────────────────┬──────────────────┐
│ │ MULTI/EXEC │ Lua Script │
├───────────────────────┼──────────────────┼──────────────────┤
│ EXEC 실행 중 끼어들기 │ ✅ 차단 │ ✅ 차단 │
│ 큐잉 중 끼어들기 │ ❌ 가능 │ ✅ 차단 │
│ 조건 판단도 원자적 │ ❌ 불가 │ ✅ 가능 │
│ 에러 시 이후 명령 │ ❌ 계속 실행 │ ✅ 즉시 중단 │
│ 중간 결과 활용 │ ❌ 불가 │ ✅ 가능 │
└───────────────────────┴──────────────────┴──────────────────┘
그럼에도 Lua가 완전한 원자성이 아닌 이유
Lua Script도 극복 못 하는 한계:
EVAL 시작
redis.call('DECRBY', KEYS[1], ARGV[1]) → ✅ 성공 (이미 메모리 반영!)
redis.call('LPUSH', KEYS[2], ARGV[2]) → ❌ 에러 → 스크립트 중단
DECRBY는 이미 반영된 상태 → 되돌릴 방법 없음
"강한 원자성"의 진짜 의미:
Lua Script가 보장하는 것:
✅ 스크립트 실행 중 외부 개입 없음
✅ 에러 발생 시 이후 명령 실행 안 함
✅ 조건 판단 + 실행이 하나의 단위
Lua Script가 보장 못 하는 것:
❌ 이미 실행된 명령의 롤백
❌ All or Nothing (RDBMS 수준)