Pipeline과 Transaction — 명령어 묶어서 처리하기
Redis 명령 10개를 보낼 때 10번 왕복하는 대신, 한 번에 묶어서 보낼 수는 없을까요?
Pipeline과 Transaction이란
Pipeline은 여러 Redis 명령을 한 번의 네트워크 왕복으로 처리하는 기법이고, Transaction(MULTI/EXEC)은 여러 명령을 원자적으로 실행하는 기능입니다. 둘 다 "명령을 묶어서 처리"하지만 목적이 다릅니다.
왜 필요한가
네트워크 왕복의 비용
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;
// WATCH + 재시도 루프
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;
}
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;
}
}
);
Pipeline vs Transaction vs Lua 비교
| 특성 | Pipeline | Transaction | Lua Script |
|---|---|---|---|
| 네트워크 최적화 | 핵심 목적 | 부가적 | 부가적 |
| 원자성 | 없음 | 있음 | 있음 |
| 중간 결과 사용 | 불가 | 불가 | 가능 |
| 롤백 | 없음 | 없음 | 없음 |
| 조건 분기 | 불가 | 불가 | 가능 |
| 사용 사례 | 대량 읽기/쓰기 | 간단한 원자적 처리 | 복잡한 원자적 로직 |
정리
- Pipeline은 네트워크 왕복을 줄여 대량 명령의 처리 속도를 극적으로 개선합니다
- MULTI/EXEC는 명령 묶음이 다른 클라이언트에 의해 방해받지 않도록 보장합니다
- WATCH는 낙관적 락(CAS)으로 동시성 문제를 해결하지만, 충돌이 많으면 재시도 비용이 큽니다
- 조건 분기가 필요한 복잡한 원자적 로직은 Lua 스크립트가 더 적합합니다
- Pipeline과 Transaction은 조합하여 사용할 수 있습니다
댓글 로딩 중...