본문 바로가기
Research Notes (개인)

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 수준)

최종 요약