
들어가며: 생각보다 만만하지 않다
RTSP 클라이언트를 직접 구현하려고 마음먹으면 처음에는 이렇게 생각하기 쉽다. 서버가 RTSP/1.0 200 OK\r\n... 같은 문자열을 보내는데, recv() 한 번 호출해서 문자열로 파싱하면 끝나는 것 아닌가?
겉으로 보면 그렇다. 응답은 텍스트이고, HTTP와 구조도 비슷하다. 그래서 자연스럽게 “한 번에 온다”는 전제를 깔고 코드를 짜게 된다. 그런데 실제로 테스트를 해보면 이상한 일이 생긴다.
- 헤더가 중간에서 잘린다.
- SDP body가 반만 들어온다.
- 어떤 경우에는 응답 두 개가 붙어서 한 번에 들어온다.
그제야 깨닫게 된다. 문제는 RTSP가 아니라, TCP를 잘못 이해한 데 있었다는 것을.
TCP는 메시지가 아니라 바이트 흐름이다
TCP는 “메시지 단위” 프로토콜이 아니다. 그냥 바이트 스트림이다. 이 차이를 정확히 이해해야 한다.
비유를 해보면 이렇다. TCP는 편지 봉투가 아니라 수도관에 가깝다. 서버가 물을 한 컵 부었다고 해서, 내가 정확히 한 컵씩 나눠서 받는다는 보장은 없다. 중간에서 나뉠 수도 있고, 여러 컵이 한 번에 섞여 올 수도 있다. 실제로 이런 상황은 충분히 발생한다.
recv 1회 → "RTSP/1.0 200"
recv 2회 → " OK\r\nCSeq: 3\r\n..."
또는 반대로,
recv 1회 → [RTSP 응답1][RTSP 응답2]
서버는 한 번에 send()했을 수 있다. 하지만 네트워크, 커널 버퍼, 타이밍 등의 이유로 수신은 전혀 다르게 보일 수 있다. 그래서 recv() 한 번에 “응답 하나가 온다”라고 기대하는 순간, 구현은 언젠가 반드시 깨진다.
헤더는 어디까지인가 — \r\n\r\n의 의미
RTSP는 HTTP와 거의 같은 구조를 가진다.
RTSP/1.0 200 OK
CSeq: 3
Content-Length: 10
abcdefghij
여기서 핵심 구분점은 \r\n\r\n이다.
- \r\n\r\n 전까지는 헤더
- 그 뒤는 body (있을 수도, 없을 수도 있다)
- body 길이는 보통 Content-Length로 결정된다
따라서 올바른 읽기 순서는 이렇다.
- \r\n\r\n이 나올 때까지 읽는다.
- 헤더를 파싱한다.
- Content-Length를 확인한다.
- body가 있다면, 그 길이만큼 정확히 더 읽는다.
이 과정을 건너뛰면, body가 잘리거나 파싱 오류가 발생한다. 특히 DESCRIBE 응답의 SDP는 길이가 있기 때문에, 이 부분이 매우 중요하다.
설계의 핵심은 “누적 버퍼 + 소비”
이제 구조를 정리하겠다. 핵심은 단 하나다.
읽은 데이터를 계속 쌓아두고, 완성된 메시지만 소비하라.
이를 위해 누적 버퍼를 사용한다.
std::string inbuf; // 소켓에서 읽은 데이터를 누적
동작 흐름은 다음과 같다.
- recv()로 읽은 데이터를 inbuf에 append한다.
- inbuf 안에서 \r\n\r\n을 찾는다.
- 헤더를 파싱한다.
- Content-Length만큼 body가 모일 때까지 기다린다.
- 응답 하나를 완성한다.
- 사용한 만큼만 inbuf.erase(0, consumed)로 제거한다.
여기서 “사용한 만큼만 제거한다”는 부분이 중요하다. 왜냐하면 이런 상황이 생길 수 있기 때문이다.
[RTSP 응답1][RTSP 응답2 일부]
응답1을 처리한 뒤, 응답2의 일부를 버리면 안 된다. 그 데이터는 다음 파싱에 필요하다. 이 구조는 이후 interleaved $ RTP 프레임을 처리할 때도 그대로 확장된다. 처음부터 이렇게 설계하는 것이 안전하다.
실제 코드 예제: incremental read 방식
아래는 RTSP 응답 하나를 완성해서 반환하는 예제 코드다. 핵심만 유지하면서도 실무에서 바로 사용할 수 있는 형태다.
응답 구조체
#include <string>
#include <unordered_map>
struct RtspResponse {
int status = 0;
std::unordered_map<std::string, std::string> headers;
std::string body;
};
소켓에서 데이터 누적
#include <sys/socket.h>
#include <stdexcept>
#include <cerrno>
#include <cstring>
static void recv_append(int fd, std::string& inbuf) {
char tmp[4096];
ssize_t n = ::recv(fd, tmp, sizeof(tmp), 0);
if (n == 0) {
throw std::runtime_error("Peer closed");
}
if (n < 0) {
if (errno == EINTR) return;
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
}
inbuf.append(tmp, static_cast<size_t>(n));
}
헤더 파싱
#include <algorithm>
#include <cctype>
static std::string to_lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c){ return std::tolower(c); });
return s;
}
static int parse_content_length(
const std::unordered_map<std::string, std::string>& headers)
{
auto it = headers.find("content-length");
if (it == headers.end()) return 0;
return std::stoi(it->second);
}
핵심 함수: read_rtsp_response
RtspResponse read_rtsp_response(int fd, std::string& inbuf) {
const std::string kHeaderEnd = "\r\n\r\n";
size_t header_end_pos = std::string::npos;
// 1. 헤더 끝까지 읽기
while (true) {
header_end_pos = inbuf.find(kHeaderEnd);
if (header_end_pos != std::string::npos)
break;
recv_append(fd, inbuf);
}
// 2. 헤더 블록 추출
std::string header_block = inbuf.substr(0, header_end_pos);
RtspResponse resp;
// 상태 라인 파싱
{
size_t sp1 = header_block.find(' ');
size_t sp2 = header_block.find(' ', sp1 + 1);
std::string status_str =
header_block.substr(sp1 + 1, sp2 - (sp1 + 1));
resp.status = std::stoi(status_str);
}
// 헤더 파싱 (간단 버전)
size_t pos = header_block.find("\r\n") + 2;
while (pos < header_block.size()) {
size_t eol = header_block.find("\r\n", pos);
std::string line = header_block.substr(pos, eol - pos);
pos = eol + 2;
size_t colon = line.find(':');
if (colon == std::string::npos) continue;
std::string key = to_lower(line.substr(0, colon));
std::string val = line.substr(colon + 1);
resp.headers[key] = val;
}
// 3. body 길이 확보
int content_len = parse_content_length(resp.headers);
size_t total_needed =
header_end_pos + kHeaderEnd.size() + content_len;
while (inbuf.size() < total_needed) {
recv_append(fd, inbuf);
}
// 4. body 추출
if (content_len > 0) {
size_t body_start = header_end_pos + kHeaderEnd.size();
resp.body = inbuf.substr(body_start, content_len);
}
// 5. 소비한 만큼 제거
inbuf.erase(0, total_needed);
return resp;
}
이 구현은 다음을 만족한다.
- 여러 번에 쪼개져 와도 정상 동작한다.
- 여러 응답이 붙어서 와도 처리 가능하다.
- 다음 메시지의 일부는 버퍼에 남겨둔다.
실무에서 이 구조가 중요한 이유
스트리밍 환경에서는 네트워크 상태가 항상 일정하지 않다. 테스트 환경에서는 문제없던 코드가 실제 배포 환경에서만 깨지는 경우가 있다. 그 원인을 추적해 보면, 대부분 이런 가정 때문이다.
“응답은 한 번에 온다.”
RTSP over TCP, interleaved RTP를 사용하는 경우라면 이 구조는 선택이 아니라 필수에 가깝다. 라이브러리를 쓰면 내부에서 다 처리해준다. 하지만 직접 구현하는 순간, 이 문제를 정면으로 마주하게 된다.
마무리
RTSP를 직접 구현하다 보면, 단순히 문자열을 파싱하는 문제가 아니라는 걸 알게 된다. 네트워크 계층과 프로토콜 계층이 어떻게 맞물리는지 이해해야 한다.
recv() 한 번으로 끝내지 않겠다고 결심하는 순간, 비로소 “프로토콜을 사용하는 사람”에서 “프로토콜을 구현하는 사람”으로 넘어가기 시작한다. 이 차이는 생각보다 크다.
이 글에서 사용된 예제는 https://github.com/moony211/cpp-rtsp-study에 있다.
'잡(job)기술' 카테고리의 다른 글
| C++ 프로젝트가 나뉘어 있다면, CMake로 한 번에 묶어 빌드하자 - 그리고 Sanitizer는 꼭 붙이자 (1) | 2026.02.26 |
|---|---|
| RTSP 클라이언트 첫걸음: TCP 연결하고 OPTIONS 한 번 던져보기 (0) | 2026.02.21 |
| RTSP 클라이언트를 만들기 전에 꼭 해야 할 준비― mediamtx + FFmpeg으로 테스트 환경 먼저 만들기 (3) | 2026.02.21 |
| SVN Merge 실수 줄이는 법: 체리픽부터 mergeinfo까지 실전 전략 5가지 (0) | 2026.02.11 |
| NestJS 모듈의 비밀: 왜 클래스 안은 텅 비어 있을까? (0) | 2026.02.06 |