Redis 통신 프로토콜 — RESP와 클라이언트-서버 통신
네트워크를 통해 Redis에 명령어를 보낼 때, 바이트 레벨에서는 정확히 어떤 일이 일어나고 있을까요?
개념 정의
RESP(REdis Serialization Protocol)는 Redis 클라이언트와 서버 사이의 통신 프로토콜 입니다. 사람이 읽을 수 있을 정도로 단순하면서도, 파싱이 빠르도록 설계되어 있습니다. 현재 RESP2가 기본이고, Redis 6.0부터 RESP3가 선택적으로 사용 가능합니다.
왜 필요한가
- Redis는 네트워크를 통해 바이트 스트림을 주고받기 때문에, 명령어의 시작과 끝, 문자열 길이, 에러 여부를 명확하게 구분하는 약속 이 필요합니다.
- 이 약속이 복잡하면 파싱에 CPU를 많이 쓰게 되므로, Redis처럼 초당 수십만 건을 처리하는 시스템에서는 파싱이 최대한 빨라야 합니다.
- 따라서 RESP는 ** 첫 바이트 하나 **로 타입을 결정하는 극도로 단순한 프로토콜을 설계했습니다.
RESP2 데이터 타입
RESP2에서는 5가지 데이터 타입을 사용합니다. 각 타입은 ** 첫 번째 바이트 **로 구분됩니다.
1. Simple String (+)
+OK\r\n
에러가 아닌 짧은 응답에 사용됩니다. 바이너리 안전하지 않으므로(줄바꿈 포함 불가) 짧은 상태 메시지에만 쓰입니다.
2. Error (-)
-ERR unknown command 'foobar'\r\n
-WRONGTYPE Operation against a key holding the wrong kind of value\r\n
첫 단어가 에러 타입을 나타냅니다. 클라이언트 라이브러리는 이를 파싱하여 예외를 발생시킵니다.
3. Integer (:)
:1000\r\n
:0\r\n
INCR, LLEN, EXISTS 같은 명령어의 응답에 사용됩니다.
4. Bulk String ($)
$6\r\nfoobar\r\n // 6바이트 문자열 "foobar"
$0\r\n\r\n // 빈 문자열
$-1\r\n // NULL (키가 존재하지 않을 때)
바이너리 안전한 문자열입니다. 길이가 먼저 오므로 어떤 바이트든 포함할 수 있습니다.
5. Array (*)
*3\r\n // 3개 원소의 배열
$3\r\nSET\r\n // "SET"
$3\r\nkey\r\n // "key"
$5\r\nvalue\r\n // "value"
클라이언트가 보내는 명령어도, 서버가 돌려주는 복합 응답도 Array 타입입니다.
명령어 전송의 실제 모습
SET mykey "Hello World"를 Redis에 보낼 때 실제로 전송되는 바이트입니다.
*3\r\n // 3개 원소 배열
$3\r\n // 첫 번째 원소: 3바이트
SET\r\n // 명령어 이름
$5\r\n // 두 번째 원소: 5바이트
mykey\r\n // 키
$11\r\n // 세 번째 원소: 11바이트
Hello World\r\n // 값
응답은 다음과 같습니다.
+OK\r\n // Simple String으로 성공 응답
인라인 명령
RESP 외에 Redis는 인라인 명령 도 지원합니다. redis-cli에서 직접 타이핑할 때 사용되는 형식입니다.
PING\r\n
SET key value\r\n
GET key\r\n
인라인 명령은 사람이 telnet으로 직접 Redis에 접속할 때 편리합니다.
# telnet으로 직접 Redis에 명령 전송
$ telnet localhost 6379
PING
+PONG
SET greeting "hello"
+OK
GET greeting
$5
hello
하지만 인라인 명령은 바이너리 데이터를 전송할 수 없으므로, 실제 클라이언트 라이브러리는 항상 RESP 형식을 사용합니다.
RESP3 — 무엇이 달라졌나
Redis 6.0에서 도입된 RESP3는 더 풍부한 데이터 타입을 제공합니다.
새로운 타입들
| 접두사 | 타입 | 설명 |
|---|---|---|
_ | Null | RESP2의 $-1, *-1을 대체 |
# | Boolean | #t\r\n 또는 #f\r\n |
, | Double | 부동소수점 숫자 |
% | Map | 키-값 쌍의 맵 |
~ | Set | 순서 없는 집합 |
> | Push | 서버 → 클라이언트 푸시 메시지 |
( | Big Number | 임의 정밀도 정수 |
RESP2 vs RESP3 응답 비교
HGETALL user:1 명령어의 응답을 비교해봅니다.
RESP2 (배열로 반환 — 키와 값이 번갈아 나옴):
*4\r\n
$4\r\nname\r\n
$5\r\nAlice\r\n
$3\r\nage\r\n
$2\r\n30\r\n
RESP3 (Map으로 반환 — 구조가 명확):
%2\r\n
$4\r\nname\r\n
$5\r\nAlice\r\n
$3\r\nage\r\n
$2\r\n30\r\n
RESP3에서는 클라이언트가 별도의 매핑 로직 없이 바로 Map/Dictionary로 변환할 수 있습니다.
RESP3 전환 방법
# 연결 후 RESP3으로 전환
HELLO 3
# 응답 (RESP3 Map 형식)
%7
$6 server
$5 redis
$7 version
$5 7.2.4
...
파이프라이닝 프로토콜 레벨
파이프라이닝은 별도의 프로토콜 기능이 아닙니다. 클라이언트가 ** 응답을 기다리지 않고 연속으로 명령어를 전송 **하는 것일 뿐입니다.
프로토콜 레벨에서의 동작
[클라이언트 → 서버] 연속 전송:
*3\r\n$3\r\nSET\r\n$1\r\na\r\n$1\r\n1\r\n
*3\r\n$3\r\nSET\r\n$1\r\nb\r\n$1\r\n2\r\n
*2\r\n$3\r\nGET\r\n$1\r\na\r\n
[서버 → 클라이언트] 순서대로 응답:
+OK\r\n
+OK\r\n
$1\r\n1\r\n
서버 입장에서는 소켓 읽기 버퍼에 여러 명령어가 한 번에 들어와 있을 뿐, 특별한 처리가 필요하지 않습니다.
파이프라이닝의 메모리 주의점
# 주의: 너무 많은 명령어를 한 번에 파이프라이닝하면
# 서버의 출력 버퍼가 커질 수 있습니다
pipe = r.pipeline()
for i in range(1000000): # 100만 개는 너무 많음
pipe.set(f'key:{i}', i)
pipe.execute()
# 권장: 적절한 크기로 나누어 실행
BATCH_SIZE = 1000
pipe = r.pipeline()
for i in range(1000000):
pipe.set(f'key:{i}', i)
if (i + 1) % BATCH_SIZE == 0:
pipe.execute()
pipe = r.pipeline()
클라이언트 버퍼 관리
Redis는 각 클라이언트 연결에 대해 입력 버퍼와 출력 버퍼를 관리합니다.
입력 버퍼 (Query Buffer)
# 클라이언트당 최대 1GB (기본값, 변경 불가)
# CLIENT LIST에서 qbuf로 확인 가능
CLIENT LIST
# 출력 예시
id=5 addr=127.0.0.1:52345 ... qbuf=26 qbuf-free=32742 ...
qbuf: 현재 사용 중인 입력 버퍼 크기qbuf-free: 남은 입력 버퍼 크기
출력 버퍼 (Output Buffer)
출력 버퍼는 클라이언트 유형별로 제한을 설정할 수 있습니다.
# 설정 형식: client-output-buffer-limit <class> <hard> <soft> <seconds>
# 일반 클라이언트: 무제한 (기본)
client-output-buffer-limit normal 0 0 0
# Pub/Sub 클라이언트: 32MB 하드 리밋, 8MB가 60초 이상 지속되면 종료
client-output-buffer-limit pubsub 32mb 8mb 60
# 복제(replica) 클라이언트: 256MB 하드 리밋
client-output-buffer-limit replica 256mb 64mb 60
하드 리밋에 도달하면 즉시 연결이 끊기고, 소프트 리밋은 지정된 시간 동안 유지되면 연결이 끊깁니다.
위험 시나리오: Pub/Sub 느린 소비자
퍼블리셔 → Redis → [출력 버퍼 증가] → 느린 구독자
↓
버퍼 제한 초과
↓
연결 강제 종료
Pub/Sub 구독자가 메시지를 느리게 소비하면 출력 버퍼가 계속 쌓여 OOM 위험이 생깁니다. client-output-buffer-limit pubsub 설정이 중요한 이유입니다.
클라이언트 라이브러리의 연결 관리
대부분의 Redis 클라이언트 라이브러리는 연결 풀을 사용합니다.
// Java Jedis 연결 풀 설정
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(128); // 최대 연결 수
config.setMaxIdle(64); // 최대 유휴 연결
config.setMinIdle(16); // 최소 유휴 연결
config.setTestOnBorrow(true); // 사용 전 연결 유효성 검사
JedisPool pool = new JedisPool(config, "localhost", 6379);
try (Jedis jedis = pool.getResource()) {
jedis.set("key", "value");
}
# Python redis-py 연결 풀
import redis
pool = redis.ConnectionPool(
host='localhost',
port=6379,
max_connections=50,
decode_responses=True
)
r = redis.Redis(connection_pool=pool)
CLIENT 명령어로 디버깅
연결 문제가 발생했을 때 유용한 명령어입니다.
# 연결된 모든 클라이언트 조회
CLIENT LIST
# 특정 조건으로 필터링
CLIENT LIST TYPE normal # 일반 클라이언트만
CLIENT LIST ID 5 10 15 # 특정 ID만
# 현재 연결 정보
CLIENT INFO
# 연결 이름 설정 (디버깅용)
CLIENT SETNAME "order-service-1"
# 느린 클라이언트 강제 종료
CLIENT KILL ID 123
함정/Pitfall
1. 파이프라이닝에서 한 번에 너무 많은 명령을 보내면 OOM 위험이 있다
파이프라이닝으로 100만 개의 명령을 한 번에 전송하면, 서버의 출력 버퍼에 응답이 모두 쌓입니다. 이 버퍼가 client-output-buffer-limit을 초과하면 연결이 강제로 끊기거나 OOM이 발생할 수 있습니다. 1,000개 단위로 나누어 실행하는 것이 안전합니다.
2. Pub/Sub 느린 소비자는 서버 전체를 위험에 빠뜨린다
구독자가 메시지를 느리게 소비하면 해당 클라이언트의 출력 버퍼가 계속 커집니다. client-output-buffer-limit pubsub 설정이 없으면 메모리가 고갈될 수 있으므로 반드시 하드/소프트 리밋을 설정하세요.
3. RESP2에서 Null과 빈 문자열을 혼동하기 쉽다
$-1\r\n(Null)과 $0\r\n\r\n(빈 문자열)은 다릅니다. 키가 존재하지 않으면 Null이 반환되는데, 클라이언트 코드에서 이 둘을 구분하지 않으면 로직 오류가 발생할 수 있습니다.
정리
| 핵심 | 설명 |
|---|---|
| 타입 구분 | RESP는 첫 바이트 하나로 타입을 결정하는 단순한 텍스트 기반 프로토콜 |
| RESP2 vs RESP3 | RESP2는 5가지 타입(+, -, :, $, *), RESP3는 Map, Set, Boolean 등 추가 |
| 파이프라이닝 | 프로토콜 기능이 아니라 응답을 기다리지 않고 연속 전송하는 기법 |
| 버퍼 관리 | 클라이언트 출력 버퍼 제한 필수 — 특히 Pub/Sub 느린 소비자 주의 |
| 디버깅 | CLIENT LIST와 CLIENT INFO로 연결 상태 모니터링 |