Theme:

네트워크를 통해 Redis에 명령어를 보낼 때, 바이트 레벨에서는 정확히 어떤 일이 일어나고 있을까요?

개념 정의

RESP(REdis Serialization Protocol)는 Redis 클라이언트와 서버 사이의 통신 프로토콜입니다. 사람이 읽을 수 있을 정도로 단순하면서도, 파싱이 빠르도록 설계되어 있습니다. 현재 RESP2가 기본이고, Redis 6.0부터 RESP3가 선택적으로 사용 가능합니다.

왜 필요한가

Redis가 SET key value라는 명령어를 처리하려면, 클라이언트와 서버 사이에 명확한 약속이 필요합니다.

  • 명령어의 시작과 끝을 어떻게 구분할 것인가
  • 문자열의 길이를 어떻게 전달할 것인가
  • 에러와 정상 응답을 어떻게 구분할 것인가

RESP는 이 모든 것을 첫 바이트 하나로 결정하는 심플한 프로토콜입니다.

RESP2 데이터 타입

RESP2에서는 5가지 데이터 타입을 사용합니다. 각 타입은 첫 번째 바이트로 구분됩니다.

1. Simple String (+)

PLAINTEXT
+OK\r\n

에러가 아닌 짧은 응답에 사용됩니다. 바이너리 안전하지 않으므로(줄바꿈 포함 불가) 짧은 상태 메시지에만 쓰입니다.

2. Error (-)

PLAINTEXT
-ERR unknown command 'foobar'\r\n
-WRONGTYPE Operation against a key holding the wrong kind of value\r\n

첫 단어가 에러 타입을 나타냅니다. 클라이언트 라이브러리는 이를 파싱하여 예외를 발생시킵니다.

3. Integer (:)

PLAINTEXT
:1000\r\n
:0\r\n

INCR, LLEN, EXISTS 같은 명령어의 응답에 사용됩니다.

4. Bulk String ($)

PLAINTEXT
$6\r\nfoobar\r\n    // 6바이트 문자열 "foobar"
$0\r\n\r\n          // 빈 문자열
$-1\r\n             // NULL (키가 존재하지 않을 때)

바이너리 안전한 문자열입니다. 길이가 먼저 오므로 어떤 바이트든 포함할 수 있습니다.

5. Array (*)

PLAINTEXT
*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에 보낼 때 실제로 전송되는 바이트입니다.

PLAINTEXT
*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 // 값

응답은 다음과 같습니다.

PLAINTEXT
+OK\r\n         // Simple String으로 성공 응답

인라인 명령

RESP 외에 Redis는 인라인 명령도 지원합니다. redis-cli에서 직접 타이핑할 때 사용되는 형식입니다.

PLAINTEXT
PING\r\n
SET key value\r\n
GET key\r\n

인라인 명령은 사람이 telnet으로 직접 Redis에 접속할 때 편리합니다.

BASH
# telnet으로 직접 Redis에 명령 전송
$ telnet localhost 6379
PING
+PONG
SET greeting "hello"
+OK
GET greeting
$5
hello

하지만 인라인 명령은 바이너리 데이터를 전송할 수 없으므로, 실제 클라이언트 라이브러리는 항상 RESP 형식을 사용합니다.

RESP3 — 무엇이 달라졌나

Redis 6.0에서 도입된 RESP3는 더 풍부한 데이터 타입을 제공합니다.

새로운 타입들

접두사타입설명
_NullRESP2의 $-1, *-1을 대체
#Boolean#t\r\n 또는 #f\r\n
,Double부동소수점 숫자
%Map키-값 쌍의 맵
~Set순서 없는 집합
>Push서버 → 클라이언트 푸시 메시지
(Big Number임의 정밀도 정수

RESP2 vs RESP3 응답 비교

HGETALL user:1 명령어의 응답을 비교해봅니다.

RESP2 (배열로 반환 — 키와 값이 번갈아 나옴):

PLAINTEXT
*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으로 반환 — 구조가 명확):

PLAINTEXT
%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 전환 방법

BASH
# 연결 후 RESP3으로 전환
HELLO 3

# 응답 (RESP3 Map 형식)
%7
$6 server
$5 redis
$7 version
$5 7.2.4
...

파이프라이닝 프로토콜 레벨

파이프라이닝은 별도의 프로토콜 기능이 아닙니다. 클라이언트가 응답을 기다리지 않고 연속으로 명령어를 전송하는 것일 뿐입니다.

프로토콜 레벨에서의 동작

PLAINTEXT
[클라이언트 → 서버] 연속 전송:
*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

서버 입장에서는 소켓 읽기 버퍼에 여러 명령어가 한 번에 들어와 있을 뿐, 특별한 처리가 필요하지 않습니다.

파이프라이닝의 메모리 주의점

PYTHON
# 주의: 너무 많은 명령어를 한 번에 파이프라이닝하면
# 서버의 출력 버퍼가 커질 수 있습니다
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)

BASH
# 클라이언트당 최대 1GB (기본값, 변경 불가)
# CLIENT LIST에서 qbuf로 확인 가능
CLIENT LIST

# 출력 예시
id=5 addr=127.0.0.1:52345 ... qbuf=26 qbuf-free=32742 ...
  • qbuf: 현재 사용 중인 입력 버퍼 크기
  • qbuf-free: 남은 입력 버퍼 크기

출력 버퍼 (Output Buffer)

출력 버퍼는 클라이언트 유형별로 제한을 설정할 수 있습니다.

BASH
# 설정 형식: 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 느린 소비자

PLAINTEXT
퍼블리셔 → Redis → [출력 버퍼 증가] → 느린 구독자

              버퍼 제한 초과

              연결 강제 종료

Pub/Sub 구독자가 메시지를 느리게 소비하면 출력 버퍼가 계속 쌓여 OOM 위험이 생깁니다. client-output-buffer-limit pubsub 설정이 중요한 이유입니다.

클라이언트 라이브러리의 연결 관리

대부분의 Redis 클라이언트 라이브러리는 연결 풀을 사용합니다.

JAVA
// 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
# 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 명령어로 디버깅

연결 문제가 발생했을 때 유용한 명령어입니다.

BASH
# 연결된 모든 클라이언트 조회
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 LISTCLIENT INFO로 연결 상태를 모니터링할 수 있습니다
댓글 로딩 중...