백엔드 데이터 인프라 59편. Redis Transactions — MULTI/EXEC로 명령 묶기, WATCH로 optimistic locking 패턴, 원자성은 보장하되 롤백 없음의 함정, Lua script 와 어떻게 다른지까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 130편 중 59편이에요. 58편 으로 Pub/Sub(발행-구독 메시징) 까지 잡았다면, 이번 59편은 Redis Transactions — MULTI·EXEC·WATCH 로 명령을 원자적으로 묶는 기능이에요. 57편 Pipelining 과 헷갈리는 자리고, RDBMS(관계형 DB) 트랜잭션과 어떻게 다른지 정확히 잡아두지 않으면 운영 사고가 납니다.
Redis Transactions가 어렵게 느껴지는 이유
이름이 Transactions 라 RDBMS 트랜잭션과 같은 거라고 오해하기 쉬워요. 두 자리에서 차이를 놓치면 그대로 운영 사고로 번집니다.
첫째, 롤백이 없습니다. MULTI/EXEC 안에서 한 명령이 런타임 에러 (예: 문자열 키에 INCR) 가 나도 나머지 명령은 그대로 실행돼요. RDBMS 의 all-or-nothing 과 완전히 다릅니다. "transaction 이니 안전하겠지" 하고 박아뒀다가 반쪽 결과 가 남는 사고가 자주 일어나요.
둘째, WATCH 의 동작이 처음 보면 헷갈립니다. "watch 한 키가 EXEC 전에 다른 클라이언트에 의해 바뀌면 transaction abort" — 이 optimistic locking (낙관적 잠금) 패턴은 재시도 루프와 함께 써야 하는데, 재시도 없이 한 번만 시도하면 동시성 환경에서 조용히 실패합니다.
이 글에서는 Redis Transactions 의 정확한 보장 모델, MULTI/EXEC 흐름, WATCH 패턴, 롤백 없음 함정, Lua script(서버 측 스크립트) 와의 비교까지 한 번에 정리할게요.
핵심 명령어 — MULTI · EXEC · DISCARD
MULTI — 트랜잭션 시작
> MULTI
OK
이후 모든 명령이 큐에 쌓임 (실제 실행 X, QUEUED 응답만).
명령 큐잉
> SET balance:user42 1000
QUEUED
> DECRBY balance:user42 100
QUEUED
> INCRBY balance:user99 100
QUEUED
EXEC — 큐 일괄 실행
> EXEC
1) OK
2) (integer) 900
3) (integer) 1100
큐에 쌓인 명령이 모두 sequential 하게 실행되고 결과 리스트 로 반환.
DISCARD — 큐 취소
> MULTI
> SET key1 v1
QUEUED
> DISCARD
OK # 큐 비워짐, 아무것도 실행 안 됨
원자성·격리 — 정확한 보장 모델
여기가 가장 중요한 자리예요. Redis 트랜잭션이 보장하는 것과 보장하지 않는 것을 정확히 가르겠습니다.
보장하는 것 (◯)
- 격리 (Isolation) — MULTI~EXEC 사이의 명령은 다른 클라이언트 명령이 끼어들지 않고 순차 실행
- 묶음 응답 — 모든 결과가 한 번에 반환
보장 안 하는 것 (X)
- 롤백 (Rollback) — 없음. 런타임 에러 발생해도 나머지 진행
- 원자성 (Atomicity, RDBMS 의미) — all-or-nothing 보장 안 됨
두 가지 에러 종류 차이
여기서 시험 함정이 가장 자주 나옵니다 — 에러 종류에 따라 동작이 달라요.
Syntax 에러 (큐잉 단계)
> MULTI
> INVALIDCMD foo bar
(error) ERR unknown command
> SET key1 v1
QUEUED
> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
큐잉 단계에서 문법 에러 가 나면 전체 transaction abort, 아무것도 실행되지 않아요. 그나마 이쪽은 안전한 편입니다.
Runtime 에러 (실행 단계)
> SET counter "hello"
OK
> MULTI
> SET key1 v1
QUEUED
> INCR counter # 문자열에 INCR — 런타임 에러
QUEUED
> SET key2 v2
QUEUED
> EXEC
1) OK # key1 = v1 ✓
2) (error) ERR value is not an integer or out of range # 실패
3) OK # key2 = v2 ✓
핵심 — 런타임 에러는 해당 명령만 실패, 나머지 명령은 그대로 진행. 롤백 없음.
"잔액 차감 + 주문 기록" 처럼 둘 다 성공해야 의미 있는 작업을 MULTI/EXEC 로 묶었는데 차감만 실패하면, 주문은 기록되고 잔액은 안 빠진 상태로 남아요. Redis 트랜잭션에서 가장 큰 함정이 바로 이 자리입니다.
한 줄 정리 — Redis 트랜잭션 = 격리 + 묶음 응답만 보장, 원자성·롤백 X. all-or-nothing 이 필요하면 Lua script.
WATCH — Optimistic Locking
여기서 Redis 트랜잭션이 빛나는 자리예요. "읽고 → 판단 → 쓰는" 흐름을 동시성 환경에서 안전하게 처리하는 길입니다.
문제 — Read-Modify-Write 의 race
# 두 클라이언트가 동시에:
balance = r.get("balance:user42") # 둘 다 1000 읽음
new_balance = int(balance) - 100 # 둘 다 900 계산
r.set("balance:user42", new_balance) # 둘 다 900 박음
# 결과: -200 차감되어야 하는데 -100 만 차감됨!
WATCH 로 해결
while True:
pipe = r.pipeline()
try:
pipe.watch("balance:user42")
balance = int(pipe.get("balance:user42"))
if balance < 100:
pipe.unwatch()
return False
pipe.multi()
pipe.set("balance:user42", balance - 100)
pipe.execute()
return True
except redis.WatchError:
continue # 다른 클라이언트가 먼저 갱신 → 재시도
흐름:
- WATCH key — 그 키를 감시
- 읽고 판단
- MULTI → 명령 큐잉
- EXEC — WATCH 한 키가 그 사이에 다른 클라이언트에 의해 바뀌었으면 transaction abort (
nil반환) - abort 되면 재시도 루프
Optimistic vs Pessimistic
- Pessimistic locking (RDBMS
SELECT FOR UPDATE) — 읽을 때부터 락 잡고 내가 끝낼 때까지 다른 사람 못 건드림. 충돌 적은 환경에서 오버헤드 - Optimistic locking (
WATCH) — 낙관적으로 진행 하다가 충돌 감지되면 재시도. 충돌 적은 환경에서 효율적
여기서 정말 중요한 시험 함정 하나 — WATCH 는 재시도 루프와 함께 써야 정상 동작합니다. 한 번만 시도하고 abort 되면 그냥 실패로 처리해 버리면, 동시성 환경에서 조용히 실패율이 올라가요.
클라이언트별 구현
Python (redis-py)
pipe = r.pipeline() # 기본 transaction=True → MULTI/EXEC 자동
pipe.set("k1", "v1")
pipe.set("k2", "v2")
results = pipe.execute()
transaction=False 를 명시하면 순수 Pipelining (57편) 으로 동작해요.
Java (Jedis)
try (Jedis jedis = pool.getResource()) {
jedis.watch("balance:user42");
String balance = jedis.get("balance:user42");
Transaction tx = jedis.multi();
tx.set("balance:user42", String.valueOf(Integer.parseInt(balance) - 100));
List<Object> results = tx.exec();
if (results == null) {
// WATCH 한 키가 바뀜 → 재시도
}
}
Spring Data Redis
List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> execute(RedisOperations operations) {
operations.watch("balance:user42");
Object balance = operations.opsForValue().get("balance:user42");
operations.multi();
operations.opsForValue().set("balance:user42", String.valueOf(...));
return operations.exec();
}
});
SessionCallback 으로 같은 connection 을 유지합니다 (WATCH 가 connection 단위라서요).
MULTI/EXEC vs Lua Script — 언제 어느 쪽?
진짜 all-or-nothing 이 필요하면 Lua script 쪽이 더 강력해요 (60편에서 깊이 다룹니다).
| 항목 | MULTI/EXEC + WATCH | Lua Script |
|---|---|---|
| 격리 | ◯ | ◯ |
| 원자성 (롤백) | X | ◯ (스크립트 자체가 원자) |
| 조건부 로직 | △ (WATCH 재시도) | ◯ (스크립트 안에서 if 가능) |
| 코드 가독성 | △ | ◯ |
| 재시도 필요 | ◯ | X |
| 네트워크 왕복 | 2~N | 1 |
| 학습 곡선 | 낮음 | 약간 높음 (Lua 기초) |
자주 쓰는 선택 가이드:
- 단순 묶음 (3~5 명령, 조건 없음) → MULTI/EXEC
- read-modify-write 한 키 → WATCH + MULTI/EXEC 또는 Lua
- 복잡한 조건부 로직 → Lua script
- 분산 락 해제 (
check-and-delete) → Lua (49편 참고)
한 줄 암기 — 진짜 트랜잭션이 필요하면 Lua. MULTI/EXEC 는 격리·묶음 응답 만 필요할 때.
한계·주의사항
1. EXEC 중간에는 다른 명령 불가
EXEC 가 시작되면 Redis 가 그 transaction 을 끝낼 때까지 다른 명령을 처리하지 않아요. 단일 스레드 모델 때문입니다. 수백~수천 명령을 한 transaction 에 묶으면 그 시간 동안 Redis 서버 전체가 멈춥니다.
권장 — transaction 안의 명령 수는 수십 개 이내. 큰 묶음은 Pipelining (transaction=False) 쪽으로 보내세요.
2. WATCH 는 connection 단위
같은 connection 에서 WATCH → MULTI → EXEC 가 일관되게 흘러야 해요. connection pool(연결 풀) 환경에서 다른 connection 으로 갈아타면 WATCH 가 무효화됩니다.
3. Cluster 환경 — 같은 slot
MULTI/EXEC 안의 모든 키가 같은 slot 에 있어야 해요. 다른 slot 이면 CROSSSLOT 에러 (슬롯 교차 차단) 가 납니다. hash tag ({user42}:balance) — 같은 슬롯으로 묶는 키 표기 — 로 보장하세요.
시험 직전 한 번 더 — Redis Transactions 함정 압축 노트
- MULTI = 트랜잭션 시작, 이후 명령은 QUEUED 응답
- EXEC = 큐 일괄 실행, 결과 리스트 반환
- DISCARD = 큐 취소
- 보장 = 격리 + 묶음 응답 만
- 보장 X = 롤백·all-or-nothing 원자성
- Syntax 에러 (큐잉) = 전체 abort, 아무것도 실행 안 됨
- Runtime 에러 (실행) = 해당 명령만 실패, 나머지 진행 ← 가장 큰 함정
- "잔액 차감 + 주문 기록" 같이 둘 다 성공해야 하는 작업 → MULTI/EXEC 만으로 부족
WATCH= optimistic locking, 그 키가 EXEC 전 바뀌면 abortWATCH는 재시도 루프와 함께 써야 정상- Optimistic (
WATCH) = 충돌 적은 환경에 효율적 - Pessimistic (
SELECT FOR UPDATE) = 충돌 잦은 환경에 안정 UNWATCH= 모든 WATCH 해제- 클라이언트 API — redis-py
pipeline(transaction=True), Jedismulti(), SpringSessionCallback - MULTI/EXEC vs Lua script — 진짜 원자성은 Lua
- 단순 묶음·조건 없음 → MULTI/EXEC
- read-modify-write·복잡한 조건 → Lua
- 분산 락 해제·check-and-delete → Lua
- EXEC 실행 중 = 다른 명령 처리 X (전체 멈춤) → 묶음 크기 수십 개 이내
- 큰 묶음 = Pipelining (transaction=False)
WATCH는 connection 단위 — pool 환경 주의- Cluster 환경 = 모든 키가 같은 slot 이어야 (CROSSSLOT 에러)
- 같은 slot 보장 = hash tag (
{user42}:balance)
공식 문서: Redis Transactions 에서 MULTI/EXEC·WATCH 자세한 사양을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 54편 — Redis Stream + Consumer Group + Kafka 비교
- 55편 — Redis TTL + Eviction Policy 8가지
- 56편 — Redis Keyspace Notifications + 세션 만료 패턴
- 57편 — Redis Pipelining + MULTI/EXEC와의 차이
- 58편 — Redis Pub/Sub + Sharded Pub/Sub
다음 글: