Theme:

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

Pipeline과 Transaction이란

Pipeline은 여러 Redis 명령을 한 번의 네트워크 왕복으로 처리하는 기법이고, Transaction(MULTI/EXEC)은 여러 명령을 원자적으로 실행하는 기능입니다. 둘 다 "명령을 묶어서 처리"하지만 목적이 다릅니다.

왜 필요한가

네트워크 왕복의 비용

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;

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

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;
        }
    }
);

Pipeline vs Transaction vs Lua 비교

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

정리

  • Pipeline은 네트워크 왕복을 줄여 대량 명령의 처리 속도를 극적으로 개선합니다
  • MULTI/EXEC는 명령 묶음이 다른 클라이언트에 의해 방해받지 않도록 보장합니다
  • WATCH는 낙관적 락(CAS)으로 동시성 문제를 해결하지만, 충돌이 많으면 재시도 비용이 큽니다
  • 조건 분기가 필요한 복잡한 원자적 로직은 Lua 스크립트가 더 적합합니다
  • Pipeline과 Transaction은 조합하여 사용할 수 있습니다
댓글 로딩 중...