백엔드 데이터 인프라 57편 — Redis Pipelining + MULTI/EXEC와의 차이

2026-05-17백엔드 데이터 인프라

백엔드 데이터 인프라 57편. Redis Pipelining — 여러 명령을 한 왕복으로 묶어 보내 N×RTT를 1×RTT로 줄이는 기술. MULTI/EXEC 트랜잭션과의 정확한 차이, Jedis·Lettuce·Spring Data Redis 구현, 100배 성능 차이가 나는 자리까지 풀어쓴 학습 노트.

📚 백엔드 데이터 인프라 · 57편 — Redis Pipelining + MULTI/EXEC와의 차이

이 글은 백엔드 데이터 인프라 시리즈 130편 중 57편이에요. 56편 으로 키 이벤트 알림까지 잡았다면, 이번 57편은 Redis Pipelining — 여러 명령을 한 왕복으로 묶어 네트워크 비용을 극적으로 줄이는 기술이에요. 잘만 쓰면 100배 가까운 성능 차이가 나는, Redis 성능 튜닝의 첫 번째 카드라고 보면 돼요.

Pipelining이 어렵게 느껴지는 이유

이름은 단순한데, 두 자리에서 헷갈려요.

첫째, MULTI/EXEC 트랜잭션(여러 명령을 원자적으로 묶는 기능)과 같은 거 아닌가 싶어요. 둘 다 여러 명령을 묶는다는 점은 같은데 메커니즘이 완전히 달라요. Pipelining은 네트워크 최적화고 MULTI/EXEC는 atomic(원자적, 중간에 끼어들 수 없는) 트랜잭션이에요. 이 차이가 안 잡히면 언제 어느 걸 쓸지가 안 보여요.

둘째, 클라이언트 라이브러리마다 API 모양이 달라요. Jedis·Lettuce·Redis-py·Spring Data Redis 각각 pipeline을 시작하고 실행하는 API가 다르게 생겨서, 문법만 봐서는 Pipelining인지 일반 호출인지 헷갈리는 경우가 자주 있어요.

이 글에서 RTT 문제·Pipelining 메커니즘·MULTI/EXEC 차이·클라이언트별 구현·실무 함정까지 한 번에 정리할게요.

RTT 문제 — 동기 호출의 한계

Redis 서버 처리 속도가 μs 단위로 빨라도, 네트워크 왕복(Round-Trip Time, RTT)이 수십~수백 μs면 RTT가 응답 시간의 90% 이상을 차지해요.

동기 호출 — N × RTT

for i in range(100):
    r.set(f"key:{i}", str(i))

100번 SET이면 100번 왕복이에요. RTT가 100μs라면 총 10ms(실제 Redis 처리 시간은 100 × 1μs = 0.1ms뿐). 네트워크가 99%를 먹는 셈이에요.

같은 코드를 AWS 다른 AZ(Availability Zone, 가용 영역) 환경에서 돌리면 RTT가 1ms로 뛰어서 총 100ms가 돼요. 사용자 페이지 응답이 그만큼 느려져요.

Pipelining — 1 × RTT (근사)

pipe = r.pipeline(transaction=False)
for i in range(100):
    pipe.set(f"key:{i}", str(i))
pipe.execute()

100개 명령을 한 묶음으로 보내고 응답도 한 묶음으로 받아요. 총 RTT가 1번이에요. 10ms가 0.2ms로 줄어드는, 50배쯤 되는 개선이 흔히 일어나요.

시나리오 동기 호출 Pipelining 비율
로컬 (RTT 0.05ms) × 100 명령 5ms 1.5ms 3배
같은 리전 (RTT 0.5ms) × 100 50ms 2ms 25배
다른 AZ (RTT 1ms) × 100 100ms 3ms 33배
다른 리전 (RTT 50ms) × 100 5,000ms 53ms 94배

여기서 시험 함정이 하나 있어요 — Pipelining의 효과는 RTT가 클수록 커요. 로컬 개발 환경에서는 차이가 미미해서 "왜 Pipelining 쓰지?" 싶지만, 프로덕션 클라우드 환경에서는 수십 배 차이가 명확하게 벌어져요.

Pipelining의 메커니즘

일반 호출

client → SET key1 v1 →
              (서버 처리)
client ← OK ←
client → SET key2 v2 →
              (서버 처리)
client ← OK ←

각 명령마다 왕복이 1번씩 일어나요.

Pipelining

client → SET key1 v1 →
client → SET key2 v2 →    (다 보내고)
client → SET key3 v3 →
client → SET key4 v4 →
              (서버 처리 N개)
client ← OK OK OK OK ←   (응답 묶음으로 받음)

보내고 받는 단방향 흐름이에요. 서버 처리 시간은 비슷한데 네트워크 왕복이 1번으로 압축돼요.

서버 쪽에서는 순차적으로 처리하면서 응답을 모아 한 번에 보내요. 클라이언트는 모든 응답이 도착할 때까지 기다렸다가 순서대로 매핑해요.

한 줄 정리 — Pipelining은 명령 묶기 + 응답 묶기예요. 서버 처리는 동일, 네트워크 왕복만 N에서 1로 줄어들어요.

Pipelining vs MULTI/EXEC — 정확한 차이

여기가 가장 헷갈리는 자리예요. 둘 다 여러 명령을 묶는다는 점은 같은데 목적이 완전히 달라요.

Pipelining

  • 목적 — 네트워크 최적화 (RTT 절약)
  • atomicX (다른 클라이언트 명령이 사이에 끼어들 수 있음)
  • 순서 보장 — ◯ (Redis 가 순차 처리)
  • 응답 묶음 — ◯
  • 실패 처리 — 한 명령 실패해도 나머지는 실행됨
  • 언제대량 묶음 처리 (100~1000 명령), atomic 불필요

MULTI/EXEC

  • 목적 — atomic 트랜잭션
  • atomic (큐에 쌓아 두고 EXEC 시 일괄 실행, 사이에 다른 클라이언트 명령 X)
  • 순서 보장 — ◯
  • 응답 묶음 — ◯ (EXEC 응답이 묶음)
  • 실패 처리syntax 에러 면 전체 abort, runtime 에러 (예: WRONGTYPE) 면 해당 명령만 실패 + 나머지 진행
  • 언제원자성 필요 (예: 잔액 확인 후 차감)

둘 다 쓰기 — Pipelining + MULTI/EXEC

pipe = r.pipeline()      # 기본 transaction=True → MULTI/EXEC 포함
pipe.multi()
pipe.set("balance", 1000)
pipe.incrby("balance", -100)
pipe.execute()

atomic과 네트워크 최적화를 동시에 가져갈 수 있어요. Python redis-py(Python용 Redis 클라이언트)의 r.pipeline() 기본값은 transaction=True라서 MULTI/EXEC를 자동으로 wrapping 해요. transaction=False를 명시하면 순수 Pipelining이 돼요.

한눈 비교표

항목 Pipelining MULTI/EXEC Pipeline + MULTI
네트워크 왕복 1 2~N (구현에 따라) 1
atomic X
사이에 다른 명령 끼어들기 가능 X X
응답 한 번에
실무 자주 쓰는

여기서 정말 중요한 시험 함정 하나 — MULTI/EXEC의 원자성은 격리만 제공하고 롤백은 없어요. 한 명령이 런타임 에러(예: 문자열 키에 INCR)를 내도 다른 명령은 그대로 진행돼요. RDB 트랜잭션의 all-or-nothing과 달라요. 진짜 원자성이 필요한 트랜잭션은 Lua 스크립트(60편)가 더 안전해요.

클라이언트별 구현

Python (redis-py)

import redis
r = redis.Redis()

# 순수 Pipelining
pipe = r.pipeline(transaction=False)
for i in range(100):
    pipe.set(f"key:{i}", str(i))
results = pipe.execute()    # 100개 응답 리스트

Java (Jedis)

try (Jedis jedis = pool.getResource()) {
    Pipeline p = jedis.pipelined();
    for (int i = 0; i < 100; i++) {
        p.set("key:" + i, String.valueOf(i));
    }
    List<Object> results = p.syncAndReturnAll();
}

Java (Lettuce)

RedisAsyncCommands<String, String> async = connection.async();
async.setAutoFlushCommands(false);     // 자동 flush 끄기
List<RedisFuture<String>> futures = new ArrayList<>();
for (int i = 0; i < 100; i++) {
    futures.add(async.set("key:" + i, String.valueOf(i)));
}
async.flushCommands();                  // 한 번에 flush
LettuceFutures.awaitAll(5, TimeUnit.SECONDS, futures.toArray(new RedisFuture[0]));

Lettuce는 비동기 기반이라 Pipelining 모델이 달라요. autoFlush(자동 전송)를 끄고 → 쌓고 → flush 하는 패턴이에요.

Spring Data Redis

@Autowired
private RedisTemplate<String, String> redisTemplate;

public void bulkSet(Map<String, String> data) {
    redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        data.forEach((k, v) -> connection.set(k.getBytes(), v.getBytes()));
        return null;
    });
}

executePipelined 한 줄로 묶을 수 있어요. 79편 Spring 연동에서 깊이 다뤄요.

자주 쓰는 자리

1. Batch insert / load

100~10,000개 키를 한 번에 박을 때예요. Pipelining 없으면 사실상 불가능한 영역이에요. 사용자 데이터 import나 캐시 warming(미리 데이터 채워두기) 같은 자리에서 써요.

pipe = r.pipeline(transaction=False)
for user in users:        # 1만 명
    pipe.hset(f"user:{user['id']}", mapping=user)
    pipe.expire(f"user:{user['id']}", 3600)
pipe.execute()

2. Bulk read

pipe = r.pipeline(transaction=False)
for uid in user_ids:      # 100명
    pipe.get(f"session:{uid}")
results = pipe.execute()

MGET(여러 키를 한 번에 가져오는 명령)으로 묶일 수 있으면 MGET이 더 단순해요. 다만 HGETALL·ZRANGE 같은 다른 타입 키가 섞이면 Pipelining만 가능해요.

3. 캡 리스트 갱신

51편 List 패턴에서 본 자리예요.

pipe = r.pipeline(transaction=False)
pipe.lpush(key, event)
pipe.ltrim(key, 0, 99)
pipe.execute()

두 명령이 함께 가야 의미가 있어서 묶어 보내요.

한계·주의사항

묶음 너무 크면 메모리 부담

여기서 시험 함정 하나 — 수십만 명령을 한 묶음으로 보내면 클라이언트와 서버 모두 메모리가 폭증해요. 응답이 모두 모일 때까지 메모리에 쌓아 두니까요.

권장:

  • 한 묶음 = 수십~수천 명령
  • 그 이상은 여러 묶음으로 나눠 보내기
def bulk_chunks(items, chunk_size=1000):
    for i in range(0, len(items), chunk_size):
        pipe = r.pipeline(transaction=False)
        for item in items[i:i+chunk_size]:
            pipe.set(item["key"], item["value"])
        pipe.execute()

Cluster 환경 — 같은 슬롯이어야

Redis Cluster에서는 Pipelining 안에 들어가는 명령들이 같은 슬롯(slot, 키를 분산할 때 쓰는 해시 칸)에 가야 OK예요. 다른 슬롯이 섞이면 클라이언트가 각 노드에 별도 pipeline으로 쪼개거나 에러가 나요. hash tag(키 일부에 {}를 둘러 같은 슬롯으로 묶는 표기) {user42}:name·{user42}:email 같은 식으로 같은 슬롯을 보장해요.

순서 의존 명령은 Pipelining 만으로 부족

GET balance → 100
balance > 50 인지 체크
DECRBY balance 50

이렇게 읽은 값을 기반으로 다음 결정이 필요하면 Pipelining만으로는 안 돼요. 전송이 한 번에 끝나니 값을 보고 분기할 수가 없어요. Lua script(Redis 안에서 실행되는 Lua 스크립트) 또는 WATCH + MULTI/EXEC(59편)으로 해결해요.

시험 직전 한 번 더 — Pipelining 함정 압축 노트

  • Pipelining = N 개 명령을 한 묶음 으로 보내 RTT 1번 으로 압축
  • RTT 문제 = Redis 처리 1μs 인데 RTT 1ms = 네트워크가 99%
  • 성능 차이 = RTT 클수록 큼 — 로컬 3배, 다른 리전 100배 가까이
  • Pipelining vs MULTI/EXEC = 네트워크 최적화 vs atomic 트랜잭션
  • 순수 Pipelining = atomic X — 다른 클라이언트 명령 사이에 끼어들 수 있음
  • 둘 다 쓰기 = Pipeline + MULTI/EXEC 조합 (redis-py 기본값)
  • MULTI/EXEC 원자성 = 격리만, 롤백 없음 — 한 명령 런타임 에러여도 나머지 진행
  • 진짜 원자성 = Lua script (60편)
  • 클라이언트 API — redis-py pipeline(), Jedis pipelined(), Lettuce setAutoFlushCommands(false), Spring executePipelined
  • Lettuce 는 비동기 기반 — Pipelining 모델 다름
  • 자주 쓰는 자리 = batch insert·bulk read·캡 리스트 갱신·캐시 warming
  • 묶음 크기 = 수십~수천 명령 권장 (너무 크면 메모리 폭증)
  • 큰 작업 = 여러 묶음으로 chunk 처리
  • Cluster 환경 = 같은 슬롯이어야 함, 다른 슬롯은 클라이언트가 쪼개거나 에러
  • 슬롯 보장 = hash tag ({user42}:name)
  • 한계 — 읽은 값 기반 분기 는 Pipelining 만으로 안 됨 → Lua 또는 WATCH+MULTI
  • MGET 으로 묶이는 read = MGET 이 더 단순
  • 다른 타입 키 섞이면 = Pipelining 만 가능
  • 응답 = 명령 보낸 순서대로 묶음 리스트
  • 한 명령 실패 = 나머지는 그대로 진행 (응답 리스트에 에러 객체 섞임)

공식 문서: Redis Pipelining 에서 자세한 사양과 클라이언트별 예제를 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!