Pipeline과 Transaction — 명령어 묶어서 처리하기
Redis 명령 10개를 보낼 때 10번 왕복하는 대신, 한 번에 묶어서 보낼 수는 없을까요?
개념 정의
Pipeline 은 여러 Redis 명령을 한 번의 네트워크 왕복으로 처리하는 기법이고, Transaction(MULTI/EXEC)은 여러 명령이 다른 클라이언트의 명령과 섞이지 않도록 원자적으로 실행하는 기능입니다. 둘 다 "명령을 묶어서 처리"하지만 Pipeline은 네트워크 최적화, Transaction은 실행 격리가 목적입니다.
왜 필요한가
네트워크 왕복의 비용
Redis 명령 자체는 마이크로초 단위로 빠르지만, 네트워크 왕복(RTT)이 0.1~1ms 걸립니다.
명령 1개: 실행 10μs + RTT 500μs = 510μs
명령 100개 (개별): 100 × 510μs = 51ms
명령 100개 (Pipeline): 100 × 10μs + RTT 500μs = 1.5ms → 34배 빠름
Pipeline — 네트워크 최적화
기본 동작
일반 방식:
Client → SET a 1 → Server → OK → Client
Client → SET b 2 → Server → OK → Client
Client → SET c 3 → Server → OK → Client
(3번의 왕복)
Pipeline 방식:
Client → SET a 1, SET b 2, SET c 3 → Server
Server → OK, OK, OK → Client
(1번의 왕복)
redis-cli에서 Pipeline
# 파일에서 명령 읽어서 Pipeline 실행
cat commands.txt | redis-cli --pipe
# commands.txt 내용
SET key1 value1
SET key2 value2
SET key3 value3
Java(Lettuce) Pipeline
// Lettuce — 기본적으로 자동 Pipeline (auto-flush)
RedisAsyncCommands<String, String> async = connection.async();
// auto-flush 비활성화로 수동 Pipeline
connection.setAutoFlushCommands(false);
List<RedisFuture<?>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
futures.add(async.set("key:" + i, "value:" + i));
}
// 한 번에 전송
connection.flushCommands();
// 결과 수집
for (RedisFuture<?> future : futures) {
future.get();
}
connection.setAutoFlushCommands(true);
Spring RedisTemplate Pipeline
List<Object> results = redisTemplate.executePipelined(
(RedisCallback<Object>) connection -> {
StringRedisConnection stringConn = (StringRedisConnection) connection;
for (int i = 0; i < 1000; i++) {
stringConn.set("key:" + i, "value:" + i);
}
return null; // Pipeline에서는 null 반환
}
);
Pipeline 주의사항
- ** 원자성 없음 **: Pipeline의 명령 사이에 다른 클라이언트의 명령이 끼어들 수 있습니다
- ** 메모리 사용 **: 모든 응답을 메모리에 쌓아두므로 너무 많은 명령을 한 번에 보내면 안 됩니다
- ** 적절한 크기 **: 보통 100~1000개 단위로 나눠서 보냅니다
- ** 에러 처리 **: 개별 명령의 성공/실패를 각각 확인해야 합니다
// 적절한 배치 크기로 분할
int batchSize = 500;
for (int i = 0; i < totalCommands; i += batchSize) {
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
int end = Math.min(i + batchSize, totalCommands);
for (int j = i; j < end; j++) {
connection.set(("key:" + j).getBytes(), ("value:" + j).getBytes());
}
return null;
});
}
Transaction — MULTI/EXEC
기본 동작
127.0.0.1:6379> MULTI # 트랜잭션 시작
OK
127.0.0.1:6379(TX)> SET a 1 # 큐에 추가
QUEUED
127.0.0.1:6379(TX)> SET b 2 # 큐에 추가
QUEUED
127.0.0.1:6379(TX)> INCR a # 큐에 추가
QUEUED
127.0.0.1:6379(TX)> EXEC # 모든 명령 원자적 실행
1) OK
2) OK
3) (integer) 2
원자성의 범위
MULTI/EXEC 사이의 명령은 다른 클라이언트의 명령이 끼어들지 못합니다. 하지만 RDBMS의 트랜잭션과는 다릅니다.
Redis 트랜잭션의 보장:
✅ 격리성 — EXEC 전에 다른 명령이 끼어들지 않음
✅ 원자적 실행 — 전부 실행되거나 전부 실행되지 않음
Redis 트랜잭션에 없는 것:
❌ 롤백 — 중간에 명령 실패해도 나머지는 실행됨
❌ 조건부 실행 — 이전 명령 결과를 보고 다음 명령을 결정할 수 없음
롤백이 없는 이유
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET a "hello"
QUEUED
127.0.0.1:6379(TX)> INCR a # String에 INCR → 에러 발생할 것
QUEUED
127.0.0.1:6379(TX)> SET b "world"
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (error) ERR value is not an integer # 이 명령만 실패
3) OK # 나머지는 정상 실행됨
Redis가 롤백을 지원하지 않는 이유는 성능 때문입니다. 롤백을 위해 로그를 유지하면 Redis의 단순함과 속도를 잃게 됩니다.
DISCARD — 트랜잭션 취소
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET a 1
QUEUED
127.0.0.1:6379(TX)> DISCARD # 트랜잭션 취소, 큐 비움
OK
명령 에러 vs 실행 에러
# 명령 에러 (큐에 넣기 전 에러) → 트랜잭션 전체 거부
127.0.0.1:6379> MULTI
127.0.0.1:6379(TX)> SET a 1
QUEUED
127.0.0.1:6379(TX)> INVALIDCOMMAND
(error) ERR unknown command
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors
# 실행 에러 (실행 중 에러) → 해당 명령만 실패
# 위의 INCR 예시 참고
WATCH — 낙관적 락 (CAS)
기본 사용법
127.0.0.1:6379> WATCH balance:1001 # 키 감시 시작
OK
127.0.0.1:6379> GET balance:1001
"1000"
# 이 사이에 다른 클라이언트가 balance:1001을 변경하면...
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECRBY balance:1001 100
QUEUED
127.0.0.1:6379(TX)> EXEC
(nil) # WATCH 키가 변경되었으므로 트랜잭션 실행 안 됨
Java 구현 — WATCH + MULTI/EXEC
// 잔액 차감 — CAS 방식
public boolean deductBalance(Long userId, int amount) {
String key = "balance:" + userId;
return redisTemplate.execute(new SessionCallback<Boolean>() {
@Override
public Boolean execute(RedisOperations operations) {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
operations.watch(key);
Integer balance = (Integer) operations.opsForValue().get(key);
if (balance == null || balance < amount) {
operations.unwatch();
return false;
}
WATCH로 키를 감시한 뒤 잔액을 확인하고, MULTI/EXEC로 차감을 시도합니다. WATCH 이후 다른 클라이언트가 키를 변경했다면 exec()가 null을 반환하므로 재시도합니다.
operations.multi();
operations.opsForValue().set(key, String.valueOf(balance - amount));
List<Object> results = operations.exec();
if (results != null && !results.isEmpty()) {
return true; // 성공
}
// results가 null이면 WATCH 키가 변경됨 → 재시도
}
return false;
}
});
}
WATCH의 한계
- 충돌이 많으면 재시도가 잦아져 성능 저하
- 복잡한 로직에는 Lua 스크립트가 더 적합
- EXEC 또는 DISCARD 후 WATCH가 자동 해제됨
Pipeline + Transaction 조합
Pipeline 안에서 MULTI/EXEC를 사용하면 네트워크 최적화와 원자성을 동시에 얻을 수 있습니다.
List<Object> results = redisTemplate.executePipelined(
new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) {
operations.multi();
operations.opsForValue().set("key1", "value1");
operations.opsForValue().set("key2", "value2");
operations.opsForValue().increment("counter");
operations.exec();
return null;
}
}
);
함정/Pitfall
1. Pipeline에서 명령 수천 개를 한 번에 보내면 안 된다
Pipeline은 모든 응답을 클라이언트 메모리에 쌓아둡니다. 명령 10만 개를 한 번에 보내면 응답 버퍼가 수백 MB까지 커질 수 있습니다. 100~1000개 단위로 배치를 나누는 것이 안전합니다.
2. MULTI/EXEC는 롤백이 없다
RDBMS 트랜잭션과 달리 Redis 트랜잭션은 중간 명령이 실패해도 나머지가 실행됩니다. Redis가 롤백을 지원하지 않는 이유는 성능 때문입니다. 롤백을 위한 undo 로그를 유지하면 Redis의 단순함과 속도를 잃게 되기 때문입니다.
3. WATCH 충돌이 잦으면 Lua 스크립트로 전환하라
WATCH 기반 CAS는 낙관적 락이므로, 동시 수정이 많은 키에서는 재시도가 반복되어 오히려 성능이 나빠집니다. 이런 경우 Lua 스크립트로 원자적 처리를 하는 것이 더 효율적입니다.
Pipeline vs Transaction vs Lua 비교
| 특성 | Pipeline | Transaction | Lua Script |
|---|---|---|---|
| 네트워크 최적화 | 핵심 목적 | 부가적 | 부가적 |
| 원자성 | 없음 | 있음 | 있음 |
| 중간 결과 사용 | 불가 | 불가 | 가능 |
| 롤백 | 없음 | 없음 | 없음 |
| 조건 분기 | 불가 | 불가 | 가능 |
| 사용 사례 | 대량 읽기/쓰기 | 간단한 원자적 처리 | 복잡한 원자적 로직 |
정리
| 항목 | 핵심 내용 |
|---|---|
| Pipeline | 네트워크 왕복(RTT)을 줄여 대량 명령 처리 속도 극적 개선 |
| MULTI/EXEC | 명령 묶음의 격리성 보장, 단 롤백 없음 |
| WATCH | 낙관적 락(CAS), 충돌 많으면 Lua 스크립트로 전환 |
| Lua Script | 조건 분기 + 원자성이 필요한 복잡한 로직에 적합 |
| 조합 사용 | Pipeline 안에서 MULTI/EXEC 사용 가능 |