바이너리 프로토콜 설계가 실제로 가르쳐준 것
Source: Dev.to
대부분의 개발자는 네트워크 프로토콜을 처음부터 설계할 일은 없습니다. 이미 존재하고 수천 명에 의해 수년간 디버깅된 HTTP, gRPC, WebSocket 등을 사용하죠. 대부분의 상황에서는 이것이 올바른 선택입니다.
저는 키‑값 데이터베이스 엔진인 Vaylix를 만들 때 그 길을 가지 않았습니다. 직접 VTP2라는 커스텀 바이너리 프로토콜을 설계했으며, 그 과정에서 다른 방법으로는 얻을 수 없었던 네트워킹 지식을 얻게 되었습니다.
이 글이 “당신도 커스텀 프로토콜을 만들어야 한다”는 주장은 아닙니다. 대부분의 경우 그렇게 할 필요는 없습니다. 여기서는 제가 겪은 일들을 솔직히 풀어보려 합니다.
왜 HTTP가 안 될까
누구든 합리적으로 처음 묻는 질문은 “왜 HTTP를 그냥 쓰지 않나요?” 일 것입니다.
HTTP는 어디에나 존재합니다. 툴링도 훌륭하고, 모든 언어에 클라이언트가 있습니다. curl 로 디버깅하는 것도 간단합니다. 만약 HTTP를 사용했다면 서버 코드를 한 줄도 쓰기 전에 수십 개 언어에 대한 클라이언트 라이브러리를 이미 갖출 수 있었겠죠.
문제는 HTTP가 설계상 무상태라는 점입니다. 각 요청은 독립적이며, 요청마다 헤더가 실리고, 응답마다 헤더가 실립니다. 이 모델은 매 라운드 트립이 이전 대화를 전혀 기억하지 못하는 새로운 대화라고 가정합니다.
데이터베이스 세션은 그와 정반대입니다. 클라이언트가 연결하고 인증한 뒤, 같은 연결 위에서 여러 명령을 연속으로 보냅니다. 인증은 한 번만 하면 되고, 세션은 상태를 유지해야 합니다. 응답을 기다리지 않고 파이프라인 방식으로 요청을 보내는 것이 자연스러워야 하는데, 이를 위해 프로토콜을 억지로 비틀어야 합니다.
HTTP/2가 이 격차를 어느 정도 메워 주긴 합니다. 하지만 상태ful 세션 모델을 위해 HTTP/2를 올바르게 쓰려면 HTTP가 원래 설계된 방향과 반대로 작업해야 합니다. 결국 HTTP를 덜 ‘HTTP처럼’ 만들기 위한 인프라에 많은 시간을 쏟게 될 겁니다.
또 다른 문제는 오버헤드입니다. HTTP 헤더는 꽤 장황합니다. 작은 키‑값 연산이라도 헤더가 페이로드보다 커질 수 있죠. 이는 데이터 스토어가 가볍게 동작해야 한다는 목표와 맞지 않았습니다.
그래서 저는 바로 TCP 위에 커스텀 프레이밍 레이어를 얹는 방식을 선택했습니다.
TCP가 처음 가르쳐 주는 것
TCP는 스트림입니다. 메시지의 연속이 아니라 연속적인 바이트 흐름이죠.
클라이언트가 두 개의 요청을 연속으로 보냈다고 해도, 서버는 그것이 두 개의 별도 바이트 청크로 도착한다고 가정할 수 없습니다. 한 번에 함께 도착할 수도 있고, 세 조각으로 나뉘어 도착할 수도 있습니다. 하나는 먼저 도착하고, 다른 하나는 두 번에 걸쳐 읽히기도 합니다.
커스텀 프로토콜이 해결해야 할 첫 번째 실제 문제는 “한 메시지는 어디서 끝나고 다음 메시지는 어디서 시작하는가?” 입니다.
표준적인 답은 길이‑프리픽스 프레임입니다. 모든 메시지는 고정 크기의 헤더로 시작하고, 그 헤더 안에 뒤따르는 페이로드의 길이가 들어 있습니다. 수신자는 헤더를 읽고, 본문의 길이를 알게 되면 정확히 그 바이트 수만큼 읽어 하나의 완전한 메시지를 얻습니다.
+--------+-------+---------+------ ... ------+
| magic | ver | flags | payload |
| 4 bytes| 1 byte| 2 bytes | length bytes |
+--------+-------+---------+------ ... ------+
| |
header (fixed) body (variable)
이론적으로는 간단합니다. 하지만 부분 읽기가 문제를 일으킵니다. 16바이트를 요청했는데 10바이트만 도착하면 남은 바이트를 기다려야 하고, 10,000바이트를 요청했는데 연결이 9,000바이트 뒤에 끊기면 프레임이 잘려버립니다. 이런 상황은 TCP에서 정상적인 동작이며, 파서가 패닉에 빠지거나 영원히 블록되지 않도록 처리해야 합니다.
Rust와 Tokio를 사용하면 명시적인 처리를 통해 관리할 수 있지만, read()만 호출하고 전체 프레임이 온다고 가정해서는 안 됩니다.
버전 관리는 모든 미래 클라이언트에 대한 약속
프레이밍을 잡았다면 다음으로 필요한 것은 버전 관리입니다. 무언가를 깨뜨릴 계획이어서가 아니라, 실제로 깨뜨릴 가능성이 있기 때문에, 그때를 부드럽게 처리할 방법이 필요합니다.
VTP2는 모든 프레임 헤더에 프로토콜 버전을 포함합니다. 이는 겉보기엔 간단해 보이지만, 버전 간 호환성이 실제로 무엇을 의미하는지 생각해 보면 복잡합니다.
프로토콜 버전 2를 기준으로 만든 클라이언트가 버전 3 서버에 연결하면 어떻게 될까요? 합리적인 답은 두 가지입니다.
- 서버가 연결을 받아들인 뒤, 공통된 버전으로 협상한다.
- 서버가 지원 가능한 버전을 설명하는 구조화된 오류와 함께 연결을 거부한다.
VTP2는 시작 협상 단계를 사용합니다. 명령 프레임이 교환되기 전에, 클라이언트는 자신의 프로토콜 버전, 클라이언트 이름·버전, 사용하고자 하는 기능들을 담은 hello 프레임을 보냅니다. 서버는 받아들일 수 있는 내용을 응답합니다.
이 방식 덕분에 미래 버전에서 새로운 기능을 추가해도, 오래된 클라이언트는 그 기능을 요청하지 않으므로 기존 연결은 그대로 동작합니다.
반면, 기존 opcode의 의미를 바꾸거나 기존 응답 포맷을 재구성하면 와이어‑브레이킹 변화가 됩니다. 버전 번호는 근본적인 변화가 있었음을 알리는 신호가 됩니다.
저도 이 점을 힘들게 배웠습니다. VTP2 초창기에 EXEC 명령의 응답 포맷을 문자열 리스트에서 구조화된 타입 결과로 바꿨는데, 이는 정확도는 높아졌지만 기존 클라이언트에게는 조용히 깨지는 변화였습니다. 결과적으로 0.2.x 클라이언트는 0.3.0 서버와 트랜잭션‑와이어 호환이 되지 않으며, 변경 로그에 이를 명시했습니다.
파이프라인을 사용할 때 요청 ID는 선택 사항이 아니다
초기 설계에서는 로컬 카운터를 이용해 요청 ID를 만들었습니다. 간단했지만 잘못된 선택이었습니다.
단일 연결 위에서 파이프라인 방식으로 요청을 보낼 경우, 동시에 수십 개의 요청이 비행 중일 수 있고, 각 요청에 대한 응답은 연산 시간에 따라 임의의 순서로 돌아옵니다. 두 개의 연결이 각각 로컬 카운터에서 ID를 생성한다면 충돌이 발생할 수 있고, 하나의 연결이 카운터를 리셋하면 자체 충돌도 일어납니다.
VTP2는 UUID를 요청 ID로 사용합니다. 모든 요청에 UUID가 실리고, 응답도 동일한 UUID를 되돌려 줍니다. 클라이언트는 위치가 아니라 UUID를 기준으로 응답을 요청과 매핑합니다.
이렇게 하면 순서에 대한 가정이 완전히 사라집니다. 응답이 어떤 순서로 오든 클라이언트는 올바르게 매칭합니다.
UUID는 요청당 16바이트, 응답당 16바이트라는 비용이 듭니다. Vaylix가 목표로 하는 워크로드에서는 무시해도 될 정도이지만, 초당 수백만 건의 초소형 연산을 처리하는 고처리량 시스템이라면 재검토가 필요할 수도 있습니다. 상태 조정 용도라면 충분히 타당한 선택입니다.
체크섬은 조용한 손상과 잡힌 오류 사이의 차이
프레임은 클라이언트 → OS → 네트워크 스택 →(가능하면 미들웨어) → 서버 순으로 이동합니다. 이 과정에서 바이트가 뒤바뀔 수 있습니다. 흔하지도, 재현 가능하게 일어나지도 않지만, 일어날 수 있습니다.
체크섬이 없으면 손상된 프레임이 정상적인 것으로 처리됩니다. 서버는 잘못된 인자를 가지고 명령을 실행하거나, 스토어에 쓰레기 데이터를 넣거나, 전혀 요청되지 않은 결과를 반환할 수 있습니다. 오류는 조용히 발생하고, 결과는 예측 불가능합니다.
VTP2는 프레임 헤더에 체크섬을 포함해 페이로드 전체를 검증합니다. 체크섬이 일치하지 않으면 프레임은 어떤 처리도 이루어지기 전에 폐기됩니다. 클라이언트는 기대값과 실제값을 포함한 구조화된 오류를 받으며, 서버는 로그에 기록합니다. 실행은 전혀 일어나지 않습니다.
한 가지 미묘한 점: Vaylix는 일정 크기 이상인 outbound 프레임에 대해 zstd 압축을 적용합니다. 체크섬은 압축된 페이로드를 검증하고, 압축 해제된 페이로드는 검증하지 않습니다. 즉, 압축 단계에서 바이트가 달라지는 버그는 체크섬에 잡히지만, 압축 해제 단계에서