들어가며

이전 포스팅에서 TCP 스트림을 파싱해 PING/ECHO 명령어를 처리하는 서버를 만들었다. 하지만 PING\r\n 같은 단순한 텍스트 형식에는 몇 가지 한계가 있다.

  • 데이터 안에 \r\n이 포함되면 구분자로 오인한다
  • 문자열인지, 숫자인지, 에러인지 타입을 알 수 없다
  • 악의적인 클라이언트가 무한히 긴 데이터를 보낼 수 있다

Redis는 이런 문제를 해결하기 위해 Redis Serialization Protocol 이라는 자체 프로토콜을 사용한다. 이번 포스팅에서는 RESP 프로토콜의 구조를 이해하고 직접 파서를 구현해본다. 단순히 어떻게 파싱하는지를 넘어서 프로토콜이 왜 이런 형태를 갖게 되었는지, 직렬화 포맷 사이의 트레이드오프는 무엇이고 바이너리 세이프라는 개념이 실제로 어떤 문제를 해결하는지까지 깊이 들여다본다.




프로토콜이란

약속된 언어

프로토콜은 두 시스템이 데이터를 주고받기 위한 약속이다. 사람들이 한국어나 영어라는 언어로 소통하듯 컴퓨터도 정해진 형식으로 데이터를 주고받아야 서로를 이해할 수 있다.

HTTP를 예로 들면 GET /index.html HTTP/1.1처럼 메서드, 경로, 버전을 정해진 형식으로 보낸다. 이 형식을 벗어나면 서버는 요청을 이해하지 못한다.


프로토콜 설계의 원칙

Self-Describing Protocol

좋은 프로토콜은 자기 서술적(Self-Describing) 이어야 한다. 수신 측이 외부 정보 없이 메시지 자체만 보고 어떻게 파싱해야 하는지 알 수 있어야 한다는 뜻이다.

네트워크 통신에서 송신자와 수신자는 서로 독립적이다. 수신자는 “지금 들어오는 데이터가 문자열인지, 숫자인지, 배열인지” 미리 알 수 없다. 만약 프로토콜이 자기 서술적이지 않다면 송신자와 수신자가 사전에 “첫 번째 메시지는 문자열이고 두 번째는 정수야"라는 스키마를 공유해야 한다. 이러면 프로토콜 변경이 생길 때마다 양쪽을 동시에 업데이트해야 하고 버전 호환성 관리가 복잡해진다.

RESP는 모든 메시지의 첫 바이트에 타입 정보를 담는다. +면 Simple String, $면 Bulk String, *면 Array, -면 Error 를 의미하는 등 말이다. 수신자는 첫 바이트 하나만 읽으면 나머지 데이터를 어떻게 해석해야 하는지 결정할 수 있다.


Type-Length-Value 패턴

네트워크 프로토콜 설계에서 자주 등장하는 패턴이 TLV(Type-Length-Value) 인코딩이다.

  • Type - 이 데이터가 무엇인지 (타입 식별자)
  • Length - 데이터의 길이
  • Value - 실제 데이터

TLV는 DNS 레코드, TLS 핸드셰이크, ASN.1 등 수많은 프로토콜에서 사용되는 검증된 패턴이다. RESP도 이 패턴의 변형이라고 볼 수 있다.

RESP Bulk String: $5\r\nhello\r\n

  $     -> Type   (Bulk String)
  5     -> Length (5바이트)
  hello -> Value  (실제 데이터)

다만 RESP는 순수한 TLV와 약간 다르다. Simple String이나 Error 같은 타입은 length 없이 구분자(\r\n)로 끝을 표시한다. 길이가 필요한 Bulk String만 TLV 구조를 따른다.


첫 바이트 타입 결정

RESP에서 타입을 판별하는 비용은 O(1)이다. 첫 바이트 하나만 읽으면 된다.

이 방식의 장점은 파서 구현이 깔끔하다는 것이다. 첫 바이트를 읽고 switch 문으로 분기하면 끝이다. JSON처럼 전체 토큰을 읽어봐야 타입을 알 수 있는 포맷과 비교하면 파싱 로직이 훨씬 단순해진다. 그리고 잘못된 데이터가 들어왔을 때 첫 바이트만 확인하고 즉시 에러를 반환할 수 있어 불필요한 파싱 작업을 피할 수 있다.


프로토콜 설계의 트레이드오프

프로토콜을 설계할 때는 항상 트레이드오프가 존재한다.

  • 텍스트 기반(Human-Readable) - HTTP 헤더, SMTP 등이 있다. 사람이 직접 읽고 디버깅할 수 있다. 하지만 파싱 비용이 크고 데이터 크기도 커진다.

  • 바이너리 기반 - Protocol Buffers, MessagePack 등이 있다. 파싱이 빠르고 데이터가 작다. 하지만 사람이 직접 읽을 수 없어 디버깅이 어렵다.

  • 하이브리드 - RESP가 여기에 해당한다. 타입 접두사와 길이는 텍스트로 표현하면서, 실제 데이터는 바이너리도 담을 수 있다. telnet이나 netcat으로 직접 명령어를 보내볼 수 있을 만큼 사람이 읽기 쉬우면서도 Bulk String을 통해 바이너리 안전성을 확보했다.

RESP가 단순함을 선택한 이유는 명확하다. 레디스는 인메모리 데이터베이스이므로 병목은 네트워크 직렬화가 아니라 메모리 접근이다. 프로토콜을 극도로 최적화해서 나노초를 절약하는 것보다, 구현과 디버깅이 쉬운 프로토콜을 택하는 것이 전체 시스템 관점에서 더 합리적인 선택이다.




직렬화와 역직렬화

프로토콜을 다룰 때 자주 등장하는 개념이 직렬화(Serialization)와 역직렬화(Deserialization)다.

  • 직렬화 - 메모리의 데이터 구조를 바이트 스트림으로 변환
  • 역직렬화 - 바이트 스트림을 다시 데이터 구조로 복원
Go 구조체 -> 바이트 스트림 (직렬화)

{Str: "hello"} -> "$5\r\nhello\r\n"

  
바이트 스트림 -> Go 구조체 (역직렬화)

"$5\r\nhello\r\n" -> {Str: "hello"}

본 포스팅에서 구현할 Reader는 역직렬화를, Writer는 직렬화를 담당한다.




직렬화 포맷의 종류

직렬화는 CS에서 근본적인 문제 중 하나다. 메모리에 있는 데이터 구조를 바이트의 나열로 변환하는 작업은 네트워크 통신, 파일 저장, 프로세스 간 통신 등 시스템의 거의 모든 곳에서 필요하다. 이 문제를 해결하는 포맷은 다양한데 각각의 설계 철학과 트레이드오프가 다르다.

포맷타입가독성크기파싱 속도스키마
JSON텍스트높음느림불필요
Protocol Buffers바이너리없음작음빠름필수 (.proto 파일)
MessagePack바이너리없음작음빠름불필요
RESP하이브리드높음보통빠름불필요

JSON 사람이 읽을 수 있고 별도의 스키마 정의 없이 사용할 수 있다. 하지만 텍스트 기반이라 숫자 12345를 표현하는 데 5바이트가 필요하고(바이너리라면 2바이트), 파싱할 때 문자열을 숫자로 변환하는 비용이 든다.

Protocol Buffers는 구글에서 만든 바이너리 직렬화 포맷이다. .proto 파일로 스키마를 먼저 정의하고 코드를 생성하는 방식이다. 크기와 속도 면에서 가장 효율적이지만, 스키마 관리가 필요하고 직렬화된 바이트를 사람이 읽을 수 없다.

MessagePack은 “JSON의 바이너리 버전"을 지향한다. JSON과 같은 데이터 모델을 사용하면서 바이너리로 인코딩해 크기와 속도를 개선했다. 스키마가 필요 없어 유연하지만 사람이 직접 읽을 수는 없다.

RESP는 이들과 비교하면 범용 직렬화 포맷이 아니다. Redis 클라이언트-서버 통신이라는 특수한 목적에 맞게 설계되었다. 지원하는 타입이 5개뿐이고 중첩 구조도 Array 한 단계가 전부다. 이런 단순함 덕분에 파서를 처음부터 작성해도 100줄 안에 끝나고, telnet으로 직접 레디스 서버에 명령어를 보내볼 수 있다.

직렬화 포맷을 선택할 때 “가장 빠른 것"이나 “가장 작은 것"이 항상 정답은 아니라고 생각한다. 레디스가 RESP를 선택했듯 시스템의 특성, 개발 생산성, 디버깅 편의성을 함께 고려해야 한다.




RESP 프로토콜

타입 접두사

RESP의 핵심은 첫 바이트가 데이터 타입을 결정한다는 것이다.

접두사타입용도예시
+Simple String단순 성공 응답 (OK, PONG)+OK\r\n
-Error에러 메시지 전달-ERR unknown command\r\n
:Integer숫자 응답 (카운트 등):1000\r\n
$Bulk String바이너리 안전 문자열$5\r\nhello\r\n
*Array명령어 전송 (명령 + 인자)*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n

파서 입장에서는 첫 바이트만 읽으면 어떤 방식으로 나머지를 파싱할지 알 수 있다. 이런 방식을 상태 기반 파싱이라고 한다.


Simple String과 Error

가장 단순한 형식이다. 접두사 뒤에 문자열이 오고 \r\n으로 끝난다.

+OK\r\n -> Simple String "OK"

-ERR unknown\r\n -> Error "ERR unknown"

Simple String은 성공 응답에 사용되고 Error는 에러 응답에 사용한다. 둘의 차이는 +- 라는 접두사뿐이다.

한 가지 중요한 제약이 있다. Simple String은 데이터 안에 \r\n을 포함할 수 없다. \r\n이 메시지의 끝을 의미하기 때문이다. 이 제약이 왜 문제가 되는지는 바이너리 안전성 섹션에서 다룬다.


Bulk String

Bulk String은 길이 접두사 방식을 사용한다.

$5\r\nhello\r\n

이 방식의 장점은 바이너리 세이프 하다는 것이다. 데이터 안에 \r\n이 있어도 길이를 미리 알기 때문에 구분자로 오인하지 않는다.

  • $5\r\n - 데이터 길이가 5바이트임을 의미한다.
  • hello\r\n - 5바이트의 데이터이다.

특수한 경우로 $-1\r\n은 Null을 의미한다.


Array

클라이언트가 명령어를 보낼 때 사용하는 형식이다. 배열 안에 여러 Bulk String이 들어간다.

*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n
  • 2\r\n - 배열 시작 (2개 요소)

  • $4\r\n -> 첫 번째 요소: 4바이트 Bulk String

  • ECHO\r\n -> “ECHO”

  • $5\r\n -> 두 번째 요소: 5바이트 Bulk String

  • hello\r\n -> “hello”

ECHO hello 명령어가 이런 형식으로 변환되어 전송된다. 사용자가 redis-cli에서 타이핑하면 클라이언트가 자동으로 RESP 형식으로 변환해준다.




바이너리 안전성(Binary Safety)이란

바이너리 안전성은 프로토콜이나 자료구조가 임의의 바이트 시퀀스를 손상 없이 저장하고 전송할 수 있는가를 의미한다. 임의의 바이트에는 널 바이트(\0), 캐리지 리턴(\r), 줄바꿈(\n), 그리고 이들의 조합인 \r\n까지 모두 포함된다.


구분자 기반 프로토콜의 한계

+hello\r\nworld\r\n

Simple String 형식을 떠올려보자. +OK\r\n에서 \r\n은 메시지의 끝을 의미한다. 만약 데이터 자체에 \r\n이 포함되어 있다면 파서는 첫 번째 \r\n을 만나는 순간 “메시지가 끝났다"고 판단한다. 결과적으로 hello만 읽히고 world는 다음 메시지의 시작으로 잘못 해석된다. 구분자 기반 프로토콜은 구분자와 동일한 바이트가 데이터 안에 등장하면 메시지 경계를 혼동하게 되며 이 때문에 바이너리 세이프하지 않다고 볼 수 있다.

c언어의 문자열도 비슷한 문제를 가진다. C 문자열은 \0으로 끝을 표시한다. 그래서 문자열 중간에 \0이 있으면 거기서 문자열이 끝났다고 판단한다. strlen() 같은 함수가 바이너리 세이프하지 않은 이유다.


길이 접두사가 해결하는 것

Bulk String은 데이터 앞에 길이를 명시한다. 파서는 구분자를 찾는 대신 정확히 지정된 바이트 수만큼 읽는다.

$12\r\nhello\r\nworld\r\n

이 메시지에서 파서는 $12를 읽고 “앞으로 12바이트를 읽으면 된다"는 것을 알고있다. 그 12바이트 안에 \r\n이 포함되어 있더라도 데이터의 일부일 뿐 메시지의 끝이 아니다. 마지막 \r\n은 RESP 규격상 Bulk String 끝에 붙는 종결자로 데이터 길이에 포함되지 않는다.

  1. $12\r\n  - “다음 payload는 12바이트다” 를 의미
  2. hello\r\nworld - 정확히 12바이트 읽기 (중간의 \r\n도 데이터로 포함)
  3. 마지막 \r\n - payload가 끝났다는 종결자라서 12바이트에 포함 안 됨

실제 사례 - JPEG 이미지 데이터

레디스는 문자열뿐 아니라 바이너리 데이터도 값으로 저장할 수 있다. 예를 들어 JPEG 이미지 파일의 바이트를 그대로 레디스에 저장한다고 해보자.

JPEG 파일의 내부 바이트에는 \r\n과 동일한 바이트 시퀀스(0x0D 0x0A)가 얼마든지 포함될 수 있다. Simple String으로 이 데이터를 전송하면 이미지가 중간에 잘려버릴 것이다. 하지만 Bulk String은 $153824\r\n[153824바이트의 이미지 데이터]\r\n처럼 전체 길이를 먼저 알려주기 때문에, 데이터 안에 어떤 바이트가 들어있든 안전하게 전송할 수 있다.

이것이 레디스가 실제 데이터 전송에 Simple String이 아닌 Bulk String을 사용하는 이유다. Simple String은 +OK\r\n, +PONG\r\n처럼 제어 가능한 짧은 응답에만 쓰이고 사용자 데이터는 항상 Bulk String으로 감싸서 전송한다.




상태 기반 파싱과 상태 머신


유한 상태 머신(Finite State Machine)

상태 기반 파싱의 본질은 유한 상태 머신(FSM) 이다. FSM은 유한한 개수의 상태를 가지며, 입력에 따라 하나의 상태에서 다른 상태로 전이하는 모델이다.

RESP 파서를 FSM으로 바라보면, 각 타입 접두사가 상태 전이를 일으키는 입력이 된다.

stateDiagram-v2 [*] --> ReadType : 첫 바이트 읽기 ReadType --> SimpleString : "+" ReadType --> Error : "-" ReadType --> Integer : "콜론" ReadType --> BulkString : "$" ReadType --> Array : "*" SimpleString --> [*] : \r\n까지 읽기 Error --> [*] : \r\n까지 읽기 Integer --> [*] : \r\n까지 읽기 BulkString --> ReadLength : 길이 읽기 ReadLength --> ReadData : N바이트 읽기 ReadData --> [*] : \r\n 소비 Array --> ReadType : 요소 개수만큼 반복

파서가 $를 만나면 BulkString 상태로 전이하고, 그 안에서 길이를 읽는 상태, 데이터를 읽는 상태를 순차적으로 거친다. *를 만나면 Array 상태로 전이한 뒤, 요소 개수만큼 다시 ReadType 상태로 돌아가 재귀적으로 파싱을 수행한다.


왜 상태 머신이 정규식이나 임의 파싱보다 나은가

RESP를 파싱하는 데 정규식을 사용할 수도 있을 것이다. 하지만 정규식은 재귀적 구조를 다루지 못한다. Array 안에 또 다른 Array가 들어올 수 있는 RESP의 구조를 정규식 하나로 표현하는 것은 불가능하다.

if-else를 나열하는 임의 파싱도 가능하지만, 상태가 추가될 때마다 코드가 얽히기 쉽다. FSM 기반 설계는 각 상태의 책임이 명확하고 새로운 타입이 추가되어도 switch에 case를 하나 넣으면 된다. 실제로 RESP3(RESP의 새 버전)에서 타입이 추가되었을 때도 기존 파서 구조를 크게 건드리지 않고 확장할 수 있었다.




구현


Value 타입 정의

RESP의 여러 타입을 담을 수 있는 구조체를 먼저 정의한다.

type Value struct {
	Type  byte   // '+', '-', ':', '$', '*'
    Str   string
    Num   int
    Array []Value
}

Type 필드로 어떤 종류의 값인지 구분하고 타입에 따라 Str, Num, Array 중 적절한 필드를 사용한다.


Reader 구현

Reader는 io.Reader를 래핑하여 바이트 스트림을 Value 구조체로 변환한다.

type Reader struct {
	reader *bufio.Reader
}

func NewReader(rd *bufio.Reader) *Reader {
	return &Reader{rd}
}

func (r *Reader) Read() (Value, error) {
	typeByte, err := r.reader.ReadByte()
	if err != nil {
		return Value{}, err
	}

	str, _ := r.reader.ReadString('\n')

	switch typeByte {
	case '+':
		return Value{
			Type: '+',
			Str:  strings.TrimSpace(str),
		}, nil

	case '-':
		return Value{
			Type: '-',
			Str:  strings.TrimSpace(str),
		}, nil

	case ':':
		parse, _ := strconv.Atoi(strings.TrimSpace(str))
		return Value{
			Type: ':',
			Num:  parse,
		}, nil

	case '$':
		length, _ := strconv.Atoi(strings.TrimSpace(str)) // 데이터 길이 가져오기

		if length == -1 {
			return Value{Type: '$', Str: "Null"}, nil
		}
		buf := make([]byte, length) // 해당 길이만큼 바이트 생성
		io.ReadFull(r.reader, buf)  // 정확히 length만큼의 바이트 읽기
		r.reader.ReadString('\n') // 마지막 \r\n 소비만 하고 버림
		return Value{
			Type: '$',
			Str:  string(buf),
		}, nil

	case '*':
		elementCount, _ := strconv.Atoi(strings.TrimSpace(str))
		arr := make([]Value, elementCount)

		for i := 0; i < len(arr); i++ {
			arr[i], _ = r.Read() // 재귀 호출
		}

		return Value{
			Type:  '*',
			Array: arr,
		}, nil

	default:
		return Value{}, errors.New("알 수 없는 타입입니다.")
	}
}

Read() 분석


타입 바이트 읽기

typeByte, err := r.reader.ReadByte()

첫 바이트를 읽어 데이터 타입을 파악한다. 유한 상태 머신의 상태 전이가 이 한 줄에서 일어난다. 읽힌 바이트가 +, -, :, $, * 중 하나인지에 따라 파서의 다음 행동이 결정되는 O(1) 타입 디스패치다.

str, _ := r.reader.ReadString('\n')

타입 바이트 뒤에 오는 데이터를 \r\n까지 읽는다. Simple String이라면 이것이 데이터이고, Bulk String이나 Array라면 길이나 요소 개수 정보다.


Simple String / Error 파싱

	case '+':
		return Value{
			Type: '+',
			Str:  strings.TrimSpace(str),
		}, nil

ReadString('\n')으로 줄 끝까지 읽고 TrimSpace\r\n을 제거한다. Error도 접두사만 다르고 로직은 동일하다. 위에서 설명했듯 이 두 타입은 구분자 기반이므로 바이너리 세이프 하지않다.


Int 파싱

	case ':':
		parse, _ := strconv.Atoi(strings.TrimSpace(str))
		return Value{
			Type: ':',
			Num:  parse,
		}, nil

int는 문자열로 읽은 값을 정수로 변환해주는 과정이 추가되었다.


Bulk String 파싱

	case '$':
		length, _ := strconv.Atoi(strings.TrimSpace(str)) // 데이터 길이 가져오기

		if length == -1 {
			return Value{Type: '$', Str: "Null"}, nil
		}
		buf := make([]byte, length) // 해당 길이만큼 바이트 생성
		io.ReadFull(r.reader, buf)  // 정확히 length만큼의 바이트 읽기
		r.reader.ReadString('\n') // 마지막 \r\n 소비만 하고 버림
		return Value{
			Type: '$',
			Str:  string(buf),
		}, nil
  1. 길이를 문자열에서 정수로 변환한다

  2. -1이면 Null을 의미하므로 바로 반환한다

  3. 길이만큼 버퍼를 생성하고 io.ReadFull로 정확히 그만큼 읽는다

  4. 마지막 \r\n을 소비한다

이 과정은 TLV 패턴이 코드로 구현된 모습이다. 타입($)을 읽고, 길이를 읽고, 해당 길이만큼 값을 읽는다. 구분자가 아닌 길이를 기준으로 데이터를 읽기 때문에 바이너리 세이프하다.

그리고 Read()가 아닌 ReadFull() 을 사용하는 이유는 다음과 같다.
2편에서 말했듯이 TCP는 데이터의 경계가 없는 스트림 기반 프로토콜이다. “5바이트를 읽고 싶다"고 요청해도 패킷이 네트워크 지연이나 분할로 인해 3바이트만 도착해 있을 수 있다.

  • conn.Read() - 현재 버퍼에 있는 만큼만 읽고 즉시 리턴한다. (e.g. 3바이트만 읽힘 → Short Read 발생)

  • io.ReadFull() - 버퍼가 가득 찰 때까지(5바이트) 기다렸다가 읽는다.

데이터 무결성을 보장하기 위해서는 반드시 ReadFull을 사용하여 약속된 길이만큼 데이터가 다 도착할 때까지 기다려야 한다.


Array 파싱

Array 타입은 내부에 또 다른 RESP 타입들을 포함한다. 따라서 재귀적으로 Read()를 호출해야 한다.

	case '*':
		elementCount, _ := strconv.Atoi(strings.TrimSpace(str))
		arr := make([]Value, elementCount)

		for i := 0; i < len(arr); i++ {
			arr[i], _ = r.Read() // 재귀 호출
		}

		return Value{
			Type:  '*',
			Array: arr,
		}, nil

요소 개수만큼 루프를 돌며 r.Read()재귀 호출한다. 각 요소를 읽을 때 다시 첫 바이트를 확인하고 해당 타입에 맞게 파싱한다.

다음과 같은 트리 구조를 순차적으로 탐색하며 파싱하는 것과 같다.

*2 (배열 시작)
 ├── $4 (첫 번째 요소: Bulk String 파싱)
 │    └── ECHO
 └── $5 (두 번째 요소: Bulk String 파싱)
      └── hello

상태 머신의 관점에서 보면, *를 만나 Array 상태에 진입한 뒤 요소 개수만큼 ReadType 상태로 되돌아가는 재귀적 전이가 일어나는 것이다.


Writer 구현

Writer는 반대로 Value 구조체를 바이트로 변환하여 전송한다.

type Writer struct {
	writer io.Writer
}

func NewWriter(w io.Writer) *Writer {
	return &Writer{w}
}

func (w *Writer) WriteSimpleString(s string) error {
	w.writer.Write([]byte("+" + s + "\r\n"))
	return nil
}

func (w *Writer) WriteError(s string) error {
	w.writer.Write([]byte("-ERR " + s + "\r\n"))
	return nil
}

func (w *Writer) WriteBulkString(s string) error {
	length := strconv.Itoa(len(s))
	w.writer.Write([]byte("$" + length + "\r\n" + s + "\r\n"))
	return nil
}

func (w *Writer) WriteNull() error {
	w.writer.Write([]byte("$-1\r\n"))
	return nil
}

Reader의 역과정이다. 각 타입에 맞는 접두사를 붙이고 \r\n으로 끝낸다.

WriteBulkString에서 주목할 점은 len(s)를 사용한다는 것이다. go의 len() 함수는 문자 수가 아닌 문자열의 바이트 수를 반환한다. 이것이 RESP의 길이 접두사가 요구하는 것과 정확히 일치한다. 하지만 이 바이트 수 계산이 잘못되면 파서가 엉뚱한 위치에서 데이터를 읽게 되는데 이 함정에 대해서는 다음 섹션에서 자세히 다룬다.




Bulk String 길이와 바이트 계산의 함정

이 문제는 구현 중 실제로 겪은 문제다. RESP의 Bulk String 길이는 문자 수가 아니라 바이트 수인데, 이 차이를 놓치면 파서가 블로킹되거나 데이터가 잘리는 현상이 발생한다.


길이가 잘못되면 일어나는 일

길이가 실제보다 작을 때

$4\r\n승기\r\n

“승기"는 6바이트이므로, “승"의 3바이트 + “기"의 첫 1바이트만 읽힌다. “기"라는 글자의 UTF-8 인코딩이 중간에서 잘리기 때문에 깨진 문자열이 된다. 그리고 남은 2바이트(“기"의 나머지)와 \r\n이 버퍼에 남아서, 다음 ReadString('\n')이 이 잔여 데이터를 소비하면서 후속 파싱이 전부 어긋난다.


길이가 실제보다 1바이트 더 클 때

$7\r\n승기\r\n

파서는 “승기"의 6바이트를 읽은 뒤, 1바이트가 부족하므로 뒤따르는 \r을 데이터의 일부로 읽어들인다. 그 다음 ReadString('\n')은 남은 \n을 소비한다. 파싱 자체는 진행되지만 데이터에 \r이 붙어서 값이 오염된다.


길이가 실제보다 2바이트 더 클 때

$8\r\n승기\r\n

“승기”(6바이트) + \r\n(2바이트) = 8바이트까지 읽힌다. 이제 ReadString('\n')이 마지막 \r\n을 소비해야 하는데, 이미 \r\n은 데이터로 소비되었다. 뒤에 아무 데이터도 없으면 ReadString('\n')\n이 올 때까지 영원히 기다린다. 파서가 블로킹된다.


실제 코드에서의 해결

go의 len() 함수는 문자열의 바이트 수를 반환하므로 Writer에서 길이를 계산할 때는 자연스럽게 올바른 값이 나온다.

func (w *Writer) WriteBulkString(s string) error {
	length := strconv.Itoa(len(s)) // len("승기") == 6
	w.writer.Write([]byte("$" + length + "\r\n" + s + "\r\n"))
	return nil
}

물론 실제 환경에서는 작성된 len() 코드로 인해 정확한 바이트 수 만큼 읽겠지만, 아래 코드는 테스트코드의 일부분인데 테스트코드를 작성하며 길이 접두사와 데이터의 바이트 길이가 서로 다르게 입력되면 어떨까 라는 개인적인 생각에서 한 번 테스트 해봤던 것이다.

// 틀린 예시 - 주어진 길이 접두사는 8바이트 -> "승기" 에서 6바이트 소비 -> 이후 \r과 \n 에서 각각 1바이트씩, 합쳐서 2바이트 소비 -> 총 8바이트 소비 -> 종결자임을 나타내는 \r\n 구분자를 소비하는 코드에서 블로킹 걸림
input := "*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$8\r\n승기\r\n"

// 맞는 예시 - "승기"를 6바이트로 계산
input := "*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$6\r\n승기\r\n"



서버 리팩토링

이제 서버가 RESP 형식을 사용하도록 수정해야한다.

func (s *Server) handleConnection(conn net.Conn) {
	// 연결 종료 예약
	defer conn.Close()

	bufReader := bufio.NewReader(conn)
	reader := protocol.NewReader(bufReader)
	writer := protocol.NewWriter(conn)

	for {
		value, err := reader.Read()
		// EOF면 클라이언트가 연결을 끊은 것이니 루프 탈출 필요
		if err != nil {
			return
		}
		
		command := strings.ToUpper(value.Array[0].Str)

		switch command {

		case "PING":
			writer.WriteSimpleString("PONG")

		case "ECHO":
			if len(value.Array) > 1 {
				writer.WriteBulkString(value.Array[1].Str)
			} else {
				writer.WriteError("missing argument")
			}

		default:
			writer.WriteError("unknown command")
		}
	}
}

변경 포인트

Before

reader := bufio.NewReader(conn)
read, _ := reader.ReadString('\n')

// 직접 문자열 파싱
conn.Write([]byte("+PONG\r\n"))

After

reader := protocol.NewReader(bufReader)
writer := protocol.NewWriter(conn)
value, _ := reader.Read()

// 구조화된 Value로 접근
writer.WriteSimpleString("PONG")

직접 바이트를 다루던 코드가 추상화 된 Reader/Writer 기반으로 수정되었다. 명령어 처리 로직은 value.Array[0].Str처럼 구조화된 데이터로 접근한다.




테스트

Reader 테스트

func TestReadSimpleString(t *testing.T) {
	// given
	input := "+OK\r\n"
	// 메모리에 있는 문자열을 파일이나 네트워크처럼 "읽을 수 있는" 형태로 만듦
	StrReader := strings.NewReader(input)
	bufioReader := bufio.NewReader(StrReader)

	// when: RESP Reader 생성 후 Read() 함수 실행
	reader := NewReader(bufioReader)
	value, err := reader.Read()

	if err != nil {
		t.Fatal("읽어오기 실패")
	}

	// then: 파싱 결과 검증
	if value.Type != '+' || value.Str != "OK" {
		t.Fatalf("타입: %q 문자열: %s", value.Type, value.Str)
	}
}

strings.NewReader로 문자열에서 io.Reader를 만들어 테스트한다.


Writer 테스트

func TestWriteBulkString(t *testing.T) {
	// given
	var buf bytes.Buffer
	writer := NewWriter(&buf)

	// when
	writer.WriteBulkString("hello")

	// then
	if buf.String() != "$5\r\nhello\r\n" {
		t.Fatalf("출력: %s", buf.String())
	}
}

bytes.Buffer로 출력을 캡처해서 검증한다. Reader 테스트와 반대 방향이다.


통합 테스트

func TestRespPingCommand(t *testing.T) {
	// given
	server := New(":6379")
	go server.Start()
	time.Sleep(time.Second)

	conn, _ := net.Dial("tcp", "localhost:6379")
	defer conn.Close()

	// when: RESP 형식으로 PING 명령어 전송
	conn.Write([]byte("*1\r\n$4\r\nPING\r\n"))

	// then
	reader := bufio.NewReader(conn)
	response, _ := reader.ReadString('\n')

	if response != "+PONG\r\n" {
		t.Fatalf("응답: %s", response)
	}
}

실제 서버에 RESP 형식으로 명령어를 보내고 응답을 검증한다. 단위 테스트에서 strings.NewReader로 파서만 테스트한 것과 달리, 여기서는 실제 TCP 연결을 통해 서버 전체 흐름을 검증한다. *1\r\n$4\r\nPING\r\n이라는 RESP Array가 네트워크를 타고 서버에 도착하면 Reader가 이를 파싱하고 명령어 처리 로직이 PONG을 결정하고, Writer가 +PONG\r\n으로 직렬화해서 응답한다.




마치며

이번 포스팅에서는 단순 텍스트 기반 통신을 RESP 프로토콜 기반으로 전환했다. 그 과정에서 프로토콜 설계의 원칙들을 함께 살펴보았다. 자기 서술적 프로토콜이 왜 필요한지, TLV 패턴이 어떻게 RESP에 녹아들었는지, 그리고 첫 바이트로 타입을 결정하는 방식이 FSM과 어떻게 연결되는지를 이해하고 나니 RESP의 각 설계 선택이 자의적인 것이 아니라 CS의 오래된 원칙들을 따른 결과라는 것을 알 수 있었다.

바이너리 안정성 이라는 개념은 Bulk String의 존재 이유를 명확하게 설명해준다. 구분자 기반 프로토콜이 왜 임의의 바이너리 데이터를 다룰 수 없는지, 길이 접두사가 이 문제를 어떻게 해결하는지 이해하면 Simple String과 Bulk String이 왜 따로 존재하는지가 자연스럽게 납득이 될 것이다.

RESP 프로토콜을 구현했지만 아직 실제 데이터를 저장하는 기능은 없다. SET key value와 같은 명령어를 보낼지언정 데이터는 어디에도 저장되지 않는다. 다음 글에서는 SET/GET 명령어를 구현하고, 실제 Key-Value 저장소를 만들어보겠다.


참고