본문 바로가기
잡(job)기술

RTSP 응답을 읽는다는 것 - 왜 recv() 한 번으로 끝내면 안 되는가

by 무니이구나 2026. 2. 25.

 

들어가며: 생각보다 만만하지 않다

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로 결정된다

따라서 올바른 읽기 순서는 이렇다.

  1. \r\n\r\n이 나올 때까지 읽는다.
  2. 헤더를 파싱한다.
  3. Content-Length를 확인한다.
  4. 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에 있다.