백엔드 데이터 인프라 57편. Redis Pipelining — 여러 명령을 한 왕복으로 묶어 보내 N×RTT를 1×RTT로 줄이는 기술. MULTI/EXEC 트랜잭션과의 정확한 차이, Jedis·Lettuce·Spring Data Redis 구현, 100배 성능 차이가 나는 자리까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 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 절약)
- atomic — X (다른 클라이언트 명령이 사이에 끼어들 수 있음)
- 순서 보장 — ◯ (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(), Jedispipelined(), LettucesetAutoFlushCommands(false), SpringexecutePipelined - Lettuce 는 비동기 기반 — Pipelining 모델 다름
- 자주 쓰는 자리 = batch insert·bulk read·캡 리스트 갱신·캐시 warming
- 묶음 크기 = 수십~수천 명령 권장 (너무 크면 메모리 폭증)
- 큰 작업 = 여러 묶음으로 chunk 처리
- Cluster 환경 = 같은 슬롯이어야 함, 다른 슬롯은 클라이언트가 쪼개거나 에러
- 슬롯 보장 = hash tag (
{user42}:name) - 한계 — 읽은 값 기반 분기 는 Pipelining 만으로 안 됨 → Lua 또는 WATCH+MULTI
- MGET 으로 묶이는 read = MGET 이 더 단순
- 다른 타입 키 섞이면 = Pipelining 만 가능
- 응답 = 명령 보낸 순서대로 묶음 리스트
- 한 명령 실패 = 나머지는 그대로 진행 (응답 리스트에 에러 객체 섞임)
공식 문서: Redis Pipelining 에서 자세한 사양과 클라이언트별 예제를 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 52편 — Redis Set + 집합 연산 패턴
- 53편 — Redis Sorted Set + 랭킹·Sliding Window Rate Limiter 패턴
- 54편 — Redis Stream + Consumer Group + Kafka 비교
- 55편 — Redis TTL + Eviction Policy 8가지
- 56편 — Redis Keyspace Notifications + 세션 만료 패턴
다음 글: