Redis 통신 프로토콜 — RESP와 클라이언트-서버 통신
네트워크를 통해 Redis에 명령어를 보낼 때, 바이트 레벨에서는 정확히 어떤 일이 일어나고 있을까요?
개념 정의
RESP(REdis Serialization Protocol)는 Redis 클라이언트와 서버 사이의 통신 프로토콜입니다. 사람이 읽을 수 있을 정도로 단순하면서도, 파싱이 빠르도록 설계되어 있습니다. 현재 RESP2가 기본이고, Redis 6.0부터 RESP3가 선택적으로 사용 가능합니다.
왜 필요한가
Redis가 SET key value라는 명령어를 처리하려면, 클라이언트와 서버 사이에 명확한 약속이 필요합니다.
- 명령어의 시작과 끝을 어떻게 구분할 것인가
- 문자열의 길이를 어떻게 전달할 것인가
- 에러와 정상 응답을 어떻게 구분할 것인가
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
정리
RESP 프로토콜의 핵심을 정리하면 다음과 같습니다.
- RESP는 첫 바이트로 타입을 구분하는 단순한 텍스트 기반 프로토콜입니다
- RESP2는 5가지 타입(+, -, :, $, *), RESP3는 Map, Set, Boolean 등이 추가되었습니다
- 파이프라이닝은 프로토콜 레벨의 기능이 아니라, 응답을 기다리지 않고 연속 전송하는 기법입니다
- 클라이언트 출력 버퍼 관리가 중요합니다 — 특히 Pub/Sub에서 느린 소비자를 주의해야 합니다
CLIENT LIST와CLIENT INFO로 연결 상태를 모니터링할 수 있습니다