Redis 명령 10개를 보낼 때 10번 왕복하는 대신, 한 번에 묶어서 보낼 수는 없을까요?

개념 정의

Pipeline 은 여러 Redis 명령을 한 번의 네트워크 왕복으로 처리하는 기법이고, Transaction(MULTI/EXEC)은 여러 명령이 다른 클라이언트의 명령과 섞이지 않도록 원자적으로 실행하는 기능입니다. 둘 다 "명령을 묶어서 처리"하지만 Pipeline은 네트워크 최적화, Transaction은 실행 격리가 목적입니다.

왜 필요한가

네트워크 왕복의 비용

Redis 명령 자체는 마이크로초 단위로 빠르지만, 네트워크 왕복(RTT)이 0.1~1ms 걸립니다.

PLAINTEXT
명령 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 — 네트워크 최적화

기본 동작

PLAINTEXT
일반 방식:
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

BASH
# 파일에서 명령 읽어서 Pipeline 실행
cat commands.txt | redis-cli --pipe

# commands.txt 내용
SET key1 value1
SET key2 value2
SET key3 value3

Java(Lettuce) Pipeline

JAVA
// 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

JAVA
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 주의사항

  1. ** 원자성 없음 **: Pipeline의 명령 사이에 다른 클라이언트의 명령이 끼어들 수 있습니다
  2. ** 메모리 사용 **: 모든 응답을 메모리에 쌓아두므로 너무 많은 명령을 한 번에 보내면 안 됩니다
  3. ** 적절한 크기 **: 보통 100~1000개 단위로 나눠서 보냅니다
  4. ** 에러 처리 **: 개별 명령의 성공/실패를 각각 확인해야 합니다
JAVA
// 적절한 배치 크기로 분할
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

기본 동작

BASH
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의 트랜잭션과는 다릅니다.

PLAINTEXT
Redis 트랜잭션의 보장:
✅ 격리성 — EXEC 전에 다른 명령이 끼어들지 않음
✅ 원자적 실행 — 전부 실행되거나 전부 실행되지 않음

Redis 트랜잭션에 없는 것:
❌ 롤백 — 중간에 명령 실패해도 나머지는 실행됨
❌ 조건부 실행 — 이전 명령 결과를 보고 다음 명령을 결정할 수 없음

롤백이 없는 이유

BASH
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 — 트랜잭션 취소

BASH
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 실행 에러

BASH
# 명령 에러 (큐에 넣기 전 에러) → 트랜잭션 전체 거부
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)

기본 사용법

BASH
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

JAVA
// 잔액 차감 — 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을 반환하므로 재시도합니다.

JAVA
                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를 사용하면 네트워크 최적화와 원자성을 동시에 얻을 수 있습니다.

JAVA
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 비교

특성PipelineTransactionLua Script
네트워크 최적화핵심 목적부가적부가적
원자성없음있음있음
중간 결과 사용불가불가가능
롤백없음없음없음
조건 분기불가불가가능
사용 사례대량 읽기/쓰기간단한 원자적 처리복잡한 원자적 로직

정리

항목핵심 내용
Pipeline네트워크 왕복(RTT)을 줄여 대량 명령 처리 속도 극적 개선
MULTI/EXEC명령 묶음의 격리성 보장, 단 롤백 없음
WATCH낙관적 락(CAS), 충돌 많으면 Lua 스크립트로 전환
Lua Script조건 분기 + 원자성이 필요한 복잡한 로직에 적합
조합 사용Pipeline 안에서 MULTI/EXEC 사용 가능
댓글 로딩 중...