C++로 HTTP 클라이언트·서버 구현하기: 파서, Beast, Keep-Alive, 청크 인코딩, 타임아웃

들어가며: “HTTP 요청/응답 파싱이 어려워요”

// ❌ 문제: recv로 받은 데이터가 한 번에 오지 않음
char buffer[4096];
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
// buffer에 "GET /path HTTP/1.1\r\nHost: ex" 까지만 들어올 수 있음!
// \r\n\r\n이 없어서 헤더 끝을 알 수 없음
// Content-Length도 아직 못 읽음

직접 HTTP를 구현하면 비슷한 문제를 차례로 만납니다. recv가 메시지 전체를 한 번에 돌려주지 않아 파싱이 실패하고, 타임아웃이 없으면 느린 상대 앞에서 무한히 기다립니다. 요청마다 새 TCP 연결을 맺으면 핸드셰이크 비용이 반복되고, Content-Length 없이 Transfer-Encoding: chunked로 오는 응답은 별도 파싱이 필요합니다. 헤더 이름은 대소문자를 구분하지 않으므로 Content-Length와 content-length를 같게 다뤄야 합니다.

문제 시나리오 시각화

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버
    C->>S: recv() 호출
    Note over S: 패킷 1: "GET / HTTP/1.1\r\nHo"
    S-->>C: 20바이트 반환
    Note over C: \r\n\r\n 없음 → 헤더 끝 모름
    C->>S: recv() 재호출
    Note over S: 패킷 2: "st: example.com\r\n\r\n"
    S-->>C: 28바이트 반환
    Note over C: 이제 파싱 가능

그 밖에 자주 생기는 문제

한 연결로 여러 요청이 오는 Keep-Alive에서는 버퍼에 다음 요청의 일부가 이미 들어와 있을 수 있어, 버퍼를 통째로 비우면 그 데이터가 사라집니다. 서버가 Content-Length: 1000이라고 해 놓고 500바이트만 보낸 채 멈추면 타임아웃 없이는 영원히 기다리게 됩니다. 청크 크기 1a는 16진수(26바이트)이므로 10진수로 파싱하면 틀립니다. 연결마다 스레드를 만드는 서버는 동시 연결이 늘면 스레드 수가 폭증하므로 비동기 I/O나 스레드 풀이 필요합니다.

이 글에서는 버퍼 누적·\r\n\r\n 탐색·Content-Length 기반 본문 읽기를 하는 파서를 직접 만들고, Keep-Alive 연결 풀, 청크 파서, 타임아웃을 붙인 뒤, 같은 일을 Boost.Beast로 하는 방법을 봅니다. 예제는 POSIX 소켓과 Boost 1.70 이상의 Asio·Beast를 기준으로 합니다.


HTTP 프로토콜 구조

HTTP 요청 형식

GET /api/users HTTP/1.1\r\n
Host: example.com\r\n
User-Agent: MyClient/1.0\r\n
Connection: keep-alive\r\n
\r\n

구조:

  1. 요청 라인: 메서드 경로 버전\r\n
  2. 헤더: 이름: 값\r\n (여러 줄)
  3. 빈 줄: \r\n
  4. 본문: (POST 등에서 사용)

HTTP 응답 형식

HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 27\r\n
Connection: keep-alive\r\n
\r\n
{"message":"Hello, World!"}

구조:

  1. 상태 라인: 버전 코드 메시지\r\n
  2. 헤더: 이름: 값\r\n
  3. 빈 줄: \r\n
  4. 본문: Content-Length 바이트만큼

요청/응답 구조체

#include <string>
#include <map>
struct HttpRequest {
    std::string method;
    std::string path;
    std::string version;
    std::map<std::string, std::string> headers;
    std::string body;
};
struct HttpResponse {
    std::string version;
    int status_code;
    std::string status_message;
    std::map<std::string, std::string> headers;
    std::string body;
};

불완전한 수신과 Keep-Alive: 직접 구현할 때 부딪히는 문제

시나리오 1: 불완전한 수신

flowchart LR
    subgraph 문제[recv 한 번 호출]
        A[버퍼] --> B["GET /path HTTP/1.1\r\nHo"]
        B --> C["\r\n\r\n 없음"]
    end
    subgraph 해결[버퍼 누적]
        D[버퍼] --> E["데이터 추가 수신"]
        E --> F["\r\n\r\n 발견"]
        F --> G[파싱 가능]
    end

TCP는 스트림 프로토콜이라 메시지 경계가 없고, 한 번의 recv로 전체 HTTP 메시지가 온다는 보장이 없습니다. \r\n\r\n이 나올 때까지 버퍼에 데이터를 누적해야 합니다.

시나리오 2: Keep-Alive 연결 풀 효과

flowchart TB
    subgraph 새연결[요청마다 새 연결]
        N1[요청1] --> H1[3-way handshake]
        H1 --> R1[응답1]
        N2[요청2] --> H2[3-way handshake]
        H2 --> R2[응답2]
    end
    subgraph 풀[Keep-Alive 풀]
        P1[요청1] --> C[연결 재사용]
        C --> P2[요청2]
        C --> P3[요청3]
    end

새 연결은 TCP 핸드셰이크에 RTT 하나, HTTPS라면 TLS 핸드셰이크에 RTT가 더 듭니다. 얼마나 차이가 나는지는 RTT에 따라 달라지며, 뒤의 비교 절에서 다시 정리합니다. 본문 길이를 미리 알면 Content-Length를 쓰고, 생성하면서 보내는 스트리밍 응답은 청크 인코딩을 씁니다.


HTTP 파서 구현

완전한 HTTP 파서 클래스

#include <string>
#include <sstream>
#include <algorithm>
#include <stdexcept>
#include <sys/socket.h>
class HttpParser {
public:
    // 버퍼에서 \r\n\r\n까지 읽기
    static bool readUntilHeaders(int fd, std::string& buffer) {
        char temp[1024];
        while (buffer.find("\r\n\r\n") == std::string::npos) {
            ssize_t n = recv(fd, temp, sizeof(temp), 0);
            if (n <= 0) return false;
            buffer.append(temp, n);
            // 헤더가 너무 크면 에러
            if (buffer.size() > 8192) {
                throw std::runtime_error("Headers too large");
            }
        }
        return true;
    }
    // 요청 파싱
    static HttpRequest parseRequest(const std::string& data) {
        HttpRequest req;
        size_t header_end = data.find("\r\n\r\n");
        if (header_end == std::string::npos) {
            throw std::runtime_error("Invalid HTTP request");
        }
        std::string header_part = data.substr(0, header_end);
        req.body = data.substr(header_end + 4);
        std::istringstream stream(header_part);
        std::string line;
        // 첫 줄: 요청 라인
        std::getline(stream, line);
        if (!line.empty() && line.back() == '\r') {
            line.pop_back();
        }
        std::istringstream request_line(line);
        request_line >> req.method >> req.path >> req.version;
        // 헤더 파싱
        while (std::getline(stream, line)) {
            if (!line.empty() && line.back() == '\r') {
                line.pop_back();
            }
            if (line.empty()) break;
            size_t colon = line.find(':');
            if (colon != std::string::npos) {
                std::string name = line.substr(0, colon);
                std::string value = line.substr(colon + 1);
                // 앞뒤 공백 제거
                value.erase(0, value.find_first_not_of(" \t"));
                value.erase(value.find_last_not_of(" \t") + 1);
                // 헤더 이름을 소문자로 통일
                std::transform(name.begin(), name.end(), name.begin(), ::tolower);
                req.headers[name] = value;
            }
        }
        return req;
    }
    // 응답 파싱
    static HttpResponse parseResponse(const std::string& data) {
        HttpResponse res;
        size_t header_end = data.find("\r\n\r\n");
        if (header_end == std::string::npos) {
            throw std::runtime_error("Invalid HTTP response");
        }
        std::string header_part = data.substr(0, header_end);
        res.body = data.substr(header_end + 4);
        std::istringstream stream(header_part);
        std::string line;
        // 첫 줄: 상태 라인
        std::getline(stream, line);
        if (!line.empty() && line.back() == '\r') {
            line.pop_back();
        }
        std::istringstream status_line(line);
        status_line >> res.version >> res.status_code;
        std::getline(status_line, res.status_message);
        // 앞 공백 제거
        if (!res.status_message.empty() && res.status_message[0] == ' ') {
            res.status_message = res.status_message.substr(1);
        }
        // 헤더 파싱
        while (std::getline(stream, line)) {
            if (!line.empty() && line.back() == '\r') {
                line.pop_back();
            }
            if (line.empty()) break;
            size_t colon = line.find(':');
            if (colon != std::string::npos) {
                std::string name = line.substr(0, colon);
                std::string value = line.substr(colon + 1);
                value.erase(0, value.find_first_not_of(" \t"));
                value.erase(value.find_last_not_of(" \t") + 1);
                std::transform(name.begin(), name.end(), name.begin(), ::tolower);
                res.headers[name] = value;
            }
        }
        return res;
    }
    // Content-Length 기반 본문 읽기
    // res.body에는 헤더와 함께 이미 받은 본문 앞부분이 들어 있음
    static void readBody(int fd, HttpResponse& res) {
        auto it = res.headers.find("content-length");
        if (it == res.headers.end()) return;
        size_t content_length = std::stoull(it->second);
        // 다음 응답의 일부까지 함께 받았다면 잘라 냄 (실제로는 다음 파싱용으로 보관해야 함)
        if (res.body.size() > content_length) res.body.resize(content_length);
        // 더 읽어야 할 바이트
        while (res.body.size() < content_length) {
            char temp[4096];
            size_t to_read = std::min(sizeof(temp), content_length - res.body.size());
            ssize_t n = recv(fd, temp, to_read, 0);
            if (n <= 0) {
                throw std::runtime_error("Connection closed while reading body");
            }
            res.body.append(temp, n);
        }
    }
    // 청크 인코딩 본문 읽기
    // buffer: 헤더 뒤에 이미 받아 둔 바이트 (버리면 첫 청크가 사라짐)
    static void readChunkedBody(int fd, std::string buffer, std::string& body) {
        body.clear();
        while (true) {
            // 청크 크기 읽기 (hex\r\n)
            while (buffer.find("\r\n") == std::string::npos) {
                char temp[256];
                ssize_t n = recv(fd, temp, sizeof(temp), 0);
                if (n <= 0) throw std::runtime_error("Connection closed");
                buffer.append(temp, n);
            }
            size_t line_end = buffer.find("\r\n");
            std::string size_line = buffer.substr(0, line_end);
            buffer = buffer.substr(line_end + 2);
            // 16진수 크기 파싱
            size_t chunk_size = std::stoull(size_line, nullptr, 16);
            if (chunk_size == 0) {
                // 마지막 청크. 뒤따르는 트레일러와 빈 줄(\r\n)은 이 예제에서 생략
                break;
            }
            // 청크 데이터 읽기
            while (buffer.size() < chunk_size + 2) {  // +2 for \r\n
                char temp[4096];
                ssize_t n = recv(fd, temp, sizeof(temp), 0);
                if (n <= 0) throw std::runtime_error("Connection closed");
                buffer.append(temp, n);
            }
            body.append(buffer.substr(0, chunk_size));
            buffer = buffer.substr(chunk_size + 2);  // Skip \r\n
        }
    }
};

Beast HTTP 클라이언트

완전한 Beast HTTP 클라이언트 (동기)

#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <iostream>
#include <string>
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
using tcp = net::ip::tcp;
class BeastHttpClient {
public:
    BeastHttpClient(net::io_context& ioc) : resolver_(ioc), stream_(ioc) {}
    HttpResponse get(const std::string& host, const std::string& port,
                     const std::string& path) {
        // DNS 조회
        auto const results = resolver_.resolve(host, port);
        // 연결
        beast::get_lowest_layer(stream_).connect(results);
        // HTTP GET 요청
        http::request<http::string_body> req{http::verb::get, path, 11};
        req.set(http::field::host, host);
        req.set(http::field::user_agent, "BeastHttpClient/1.0");
        req.keep_alive(false);
        // 전송
        http::write(stream_, req);
        // 응답 수신
        beast::flat_buffer buffer;
        http::response<http::string_body> res;
        http::read(stream_, buffer, res);
        // 연결 종료
        beast::error_code ec;
        stream_.socket().shutdown(tcp::socket::shutdown_both, ec);
        // HttpResponse로 변환
        HttpResponse result;
        result.version = "HTTP/1.1";
        result.status_code = res.result_int();
        result.status_message = std::string(res.reason());
        result.body = res.body();
        for (const auto& field : res) {
            result.headers[std::string(field.name_string())] =
                std::string(field.value());
        }
        return result;
    }
    HttpResponse post(const std::string& host, const std::string& port,
                     const std::string& path, const std::string& body,
                     const std::string& content_type = "application/json") {
        auto const results = resolver_.resolve(host, port);
        beast::get_lowest_layer(stream_).connect(results);
        http::request<http::string_body> req{http::verb::post, path, 11};
        req.set(http::field::host, host);
        req.set(http::field::content_type, content_type);
        req.body() = body;
        req.prepare_payload();
        http::write(stream_, req);
        beast::flat_buffer buffer;
        http::response<http::string_body> res;
        http::read(stream_, buffer, res);
        beast::error_code ec;
        stream_.socket().shutdown(tcp::socket::shutdown_both, ec);
        HttpResponse result;
        result.status_code = res.result_int();
        result.body = res.body();
        return result;
    }
private:
    tcp::resolver resolver_;
    beast::tcp_stream stream_;
};
// 사용 예
int main() {
    net::io_context ioc;
    BeastHttpClient client(ioc);
    auto res = client.get("example.com", "80", "/");
    std::cout << "Status: " << res.status_code << "\n";
    std::cout << "Body: " << res.body.substr(0, 200) << "...\n";
}

참고: Beast의 http::read는 Transfer-Encoding: chunked를 자동 디코딩합니다.


Beast HTTP 서버

완전한 Beast HTTP 서버 (비동기)

#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <chrono>
#include <functional>
#include <iostream>
#include <memory>
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
using tcp = net::ip::tcp;
class HttpSession : public std::enable_shared_from_this<HttpSession> {
    beast::tcp_stream stream_;
    beast::flat_buffer buffer_;  // 연결 내내 재사용 (파이프라인된 다음 요청이 남아 있을 수 있음)
    http::request<http::string_body> req_;
    http::response<http::string_body> res_;  // async_write가 끝날 때까지 살아 있어야 하므로 멤버로 둠
public:
    explicit HttpSession(tcp::socket socket)
        : stream_(std::move(socket)) {}
    void start() { do_read(); }
private:
    void do_read() {
        req_ = {};  // 요청 객체만 초기화, buffer_는 비우지 않음
        stream_.expires_after(std::chrono::seconds(30));
        auto self = shared_from_this();
        http::async_read(stream_, buffer_, req_,
            [self, this](beast::error_code ec, std::size_t) {
                if (ec) {
                    if (ec != http::error::end_of_stream)
                        std::cerr << "read: " << ec.message() << "\n";
                    return;
                }
                handle_request();
            });
    }
    void handle_request() {
        res_ = {};
        auto& res = res_;
        res.version(req_.version());
        res.result(http::status::ok);
        res.keep_alive(req_.keep_alive());
        res.set(http::field::server, "Beast-HTTP-Server");
        res.set(http::field::content_type, "text/plain");
        if (req_.method() == http::verb::get && req_.target() == "/") {
            res.body() = "Hello, World!";
        } else if (req_.method() == http::verb::get &&
                   req_.target().starts_with("/api/")) {
            res.set(http::field::content_type, "application/json");
            res.body() = "{\"message\":\"API response\"}";
        } else if (req_.method() == http::verb::get &&
                   req_.target() == "/health") {
            res.set(http::field::content_type, "application/json");
            res.body() = "{\"status\":\"ok\"}";
        } else {
            res.result(http::status::not_found);
            res.body() = "Not Found";
        }
        res.prepare_payload();
        auto self = shared_from_this();
        http::async_write(stream_, res_,
            [self, this](beast::error_code ec, std::size_t) {
                if (ec) return;
                if (res_.need_eof()) {  // Connection: close
                    beast::error_code ignored;
                    stream_.socket().shutdown(tcp::socket::shutdown_send, ignored);
                    return;
                }
                do_read();  // Keep-Alive: 다음 요청
            });
    }
};
int main() {
    net::io_context ioc;
    tcp::acceptor acceptor(ioc, {tcp::v4(), 8080});
    // 람다가 자기 자신을 다시 호출하므로 auto 대신 std::function으로 선언
    std::function<void()> do_accept;
    do_accept = [&]() {
        acceptor.async_accept(ioc,
            [&](beast::error_code ec, tcp::socket socket) {
                if (!ec) {
                    std::make_shared<HttpSession>(std::move(socket))->start();
                }
                do_accept();
            });
    };
    do_accept();
    std::cout << "HTTP server on :8080\n";
    ioc.run();
}

소켓으로 직접 만드는 Keep-Alive 클라이언트

연결 풀 구현

#include <chrono>
#include <cstring>
#include <mutex>
#include <netdb.h>
#include <queue>
#include <unistd.h>
#include <unordered_map>
class HttpClient {
    struct Connection {
        int fd;
        std::string host;
        uint16_t port;
        std::chrono::steady_clock::time_point last_used;
    };
    std::unordered_map<std::string, std::queue<Connection>> pool_;
    std::mutex mutex_;
    const size_t max_connections_per_host_ = 4;
    const std::chrono::seconds idle_timeout_{30};
public:
    int getConnection(const std::string& host, uint16_t port) {
        std::lock_guard<std::mutex> lock(mutex_);
        std::string key = host + ":" + std::to_string(port);
        auto& queue = pool_[key];
        while (!queue.empty()) {
            auto conn = queue.front();
            queue.pop();
            if (std::chrono::steady_clock::now() - conn.last_used < idle_timeout_)
                return conn.fd;
            close(conn.fd);
        }
        return connectNew(host, port);
    }
    void returnConnection(int fd, const std::string& host, uint16_t port) {
        std::lock_guard<std::mutex> lock(mutex_);
        std::string key = host + ":" + std::to_string(port);
        if (pool_[key].size() < max_connections_per_host_)
            pool_[key].push({fd, host, port, std::chrono::steady_clock::now()});
        else
            close(fd);
    }
    HttpResponse get(const std::string& host, uint16_t port, const std::string& path) {
        int fd = getConnection(host, port);
        try {
            std::string request = "GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nConnection: keep-alive\r\n\r\n";
            send(fd, request.c_str(), request.size(), 0);  // 실제로는 부분 전송을 반복 처리해야 함
            std::string buffer;
            if (!HttpParser::readUntilHeaders(fd, buffer))
                throw std::runtime_error("connection closed");  // 풀에서 꺼낸 연결이 이미 끊긴 경우
            HttpResponse res = HttpParser::parseResponse(buffer);
            auto it = res.headers.find("transfer-encoding");
            if (it != res.headers.end() && it->second.find("chunked") != std::string::npos)
                HttpParser::readChunkedBody(fd, std::move(res.body), res.body);
            else
                HttpParser::readBody(fd, res);
            auto conn = res.headers.find("connection");
            if (conn != res.headers.end() && conn->second == "close")
                close(fd);  // 서버가 닫겠다고 했으면 풀에 넣지 않음
            else
                returnConnection(fd, host, port);
            return res;
        } catch (...) {
            close(fd);
            throw;
        }
    }
private:
    int connectNew(const std::string& host, uint16_t port) {
        // host 이름을 DNS로 조회해 IPv4/IPv6 주소를 차례로 시도
        addrinfo hints{};
        hints.ai_family = AF_UNSPEC;
        hints.ai_socktype = SOCK_STREAM;
        addrinfo* res = nullptr;
        std::string port_str = std::to_string(port);
        if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0)
            throw std::runtime_error("getaddrinfo() failed");
        int fd = -1;
        for (addrinfo* p = res; p; p = p->ai_next) {
            fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
            if (fd < 0) continue;
            if (connect(fd, p->ai_addr, p->ai_addrlen) == 0) break;
            close(fd);
            fd = -1;
        }
        freeaddrinfo(res);
        if (fd < 0) throw std::runtime_error("connect() failed");
        return fd;
    }
};

라우팅 테이블을 가진 HTTP 서버

간단한 HTTP 서버

#include <functional>
#include <unordered_map>
class HttpServer {
public:
    using Handler = std::function<void(const HttpRequest&, HttpResponse&)>;
private:
    int listen_fd_;
    std::unordered_map<std::string, Handler> routes_;
public:
    HttpServer(uint16_t port) {
        listen_fd_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listen_fd_ < 0) {
            throw std::runtime_error("socket() failed");
        }
        int opt = 1;
        setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
        struct sockaddr_in addr{};
        addr.sin_family = AF_INET;
        addr.sin_addr.s_addr = INADDR_ANY;
        addr.sin_port = htons(port);
        if (bind(listen_fd_, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
            throw std::runtime_error("bind() failed");
        }
        if (listen(listen_fd_, 128) < 0) {
            throw std::runtime_error("listen() failed");
        }
    }
    ~HttpServer() {
        close(listen_fd_);
    }
    void route(const std::string& method_path, Handler handler) {
        routes_[method_path] = handler;
    }
    void run() {
        std::cout << "Server listening on port 8080\n";
        while (true) {
            int client_fd = accept(listen_fd_, nullptr, nullptr);
            if (client_fd < 0) continue;
            std::thread([this, client_fd]() {
                handleClient(client_fd);
            }).detach();
        }
    }
private:
    void handleClient(int fd) {
        try {
            while (true) {
                // 단순화를 위해 요청마다 새 버퍼를 씀: 클라이언트가 응답을 기다리지 않고
                // 다음 요청을 이어 보내면(파이프라이닝) 그 데이터가 사라짐
                std::string buffer;
                if (!HttpParser::readUntilHeaders(fd, buffer)) break;
                HttpRequest req = HttpParser::parseRequest(buffer);
                auto it = req.headers.find("content-length");
                if (it != req.headers.end()) {
                    size_t content_length = std::stoull(it->second);
                    if (content_length > 10 * 1024 * 1024) break;  // 본문 크기 제한 (DoS 방지)
                    while (req.body.size() < content_length) {
                        char temp[4096];
                        size_t to_read = std::min(sizeof(temp), content_length - req.body.size());
                        ssize_t n = recv(fd, temp, to_read, 0);
                        if (n <= 0) break;
                        req.body.append(temp, n);
                    }
                }
                HttpResponse res;
                res.version = "HTTP/1.1";
                std::string key = req.method + " " + req.path;
                auto route_it = routes_.find(key);
                if (route_it != routes_.end())
                    route_it->second(req, res);
                else {
                    res.status_code = 404;
                    res.status_message = "Not Found";
                    res.body = "404 Not Found";
                }
                sendResponse(fd, res);
                auto conn_it = req.headers.find("connection");
                if (conn_it != req.headers.end() && conn_it->second == "close") break;
            }
        } catch (const std::exception& e) {
            std::cerr << "Error: " << e.what() << "\n";
        }
        close(fd);
    }
    void sendResponse(int fd, const HttpResponse& res) {
        std::ostringstream oss;
        oss << res.version << " " << res.status_code << " " << res.status_message << "\r\n";
        oss << "Content-Length: " << res.body.size() << "\r\n";
        oss << "Connection: keep-alive\r\n";
        oss << "\r\n";
        oss << res.body;
        std::string response = oss.str();
        send(fd, response.c_str(), response.size(), 0);
    }
};
// 사용 예시
int main() {
    HttpServer server(8080);
    server.route("GET /", [](const HttpRequest& req, HttpResponse& res) {
        res.status_code = 200;
        res.status_message = "OK";
        res.body = "Hello, World!";
    });
    server.route("GET /api/users", [](const HttpRequest& req, HttpResponse& res) {
        res.status_code = 200;
        res.status_message = "OK";
        res.body = R"([{"id":1,"name":"Alice"}])";
    });
    server.run();
}

청크 인코딩 처리

청크 인코딩 형식

5\r\n
Hello\r\n
6\r\n
 World\r\n
0\r\n
\r\n

구조:

  1. 청크 크기 (16진수) + \r\n
  2. 청크 데이터 + \r\n
  3. 반복
  4. 마지막 청크: 0\r\n\r\n

청크 파싱 예시

앞서 HttpParser::readChunkedBody 참조. Beast 사용 시 http::read가 자동으로 청크를 디코딩합니다.


타임아웃 처리

SO_RCVTIMEO 사용

void setTimeout(int fd, int seconds) {
    struct timeval tv;
    tv.tv_sec = seconds;
    tv.tv_usec = 0;
    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}

Beast 타임아웃

stream_.expires_after(std::chrono::seconds(30));
http::async_read(stream_, buffer_, req_, handler);

Connection reset, body limit, CRLF 인젝션: HTTP 구현 에러

에러 요약 표

에러원인해결
불완전 응답recv가 한 번에 전체를 반환 안 함버퍼에 누적, \r\n\r\n 확인
타임아웃서버 응답 느림SO_RCVTIMEO 설정
Content-Length 불일치본문이 더 짧거나 김정확히 N바이트만 읽기
청크 파싱 실패크기 라인 잘못 파싱16진수 변환 확인
Connection reset서버가 연결 끊음재시도 로직
body limit exceeded요청 본문 초과Beast: parser.body_limit()
end_of_stream클라이언트 연결 끊음정상 종료로 처리
CRLF 인젝션사용자 입력을 헤더에 직접 삽입\r\n 제거

문제 1: “Connection reset by peer”

클라이언트가 요청 도중 연결을 끊거나 keep-alive 유휴 시간이 지나 상대가 연결을 닫으면 생깁니다. 서버 입장에서는 정상적인 종료로 취급하고 로그만 낮은 레벨로 남깁니다.

http::async_read(stream_, buffer_, req_,
    [self, this](beast::error_code ec, std::size_t) {
        if (ec) {
            if (ec == http::error::end_of_stream ||
                ec == net::error::connection_reset) {
                return;  // 정상적인 끊김
            }
            std::cerr << "read error: " << ec.message() << "\n";
            return;
        }
        handle_request();
    });

문제 2: “body limit exceeded”

요청 본문이 body_limit을 초과하면 발생합니다. Beast의 요청 파서는 기본 본문 제한이 1MB이고 헤더 제한은 8KB입니다. 더 큰 업로드를 받아야 한다면 파서를 직접 만들어 제한을 올립니다.

http::request_parser<http::string_body> parser;
parser.body_limit(10 * 1024 * 1024);  // 10MB
http::async_read(stream_, buffer_, parser, ...);

문제 3: Keep-Alive에서 다음 요청 파싱 실패

흔히 “이전 요청의 버퍼를 비우지 않아서”라고 설명하며 buffer_.consume(buffer_.size())를 권하지만, 이는 오히려 버그를 만듭니다. http::async_read는 메시지를 읽고 나면 사용한 바이트만 버퍼에서 소비하므로, 버퍼에 남은 것은 다음 요청의 앞부분입니다. 이를 지우면 클라이언트가 이어 보낸 요청이 깨집니다. 매 요청마다 초기화할 것은 요청 객체뿐입니다.

void do_read() {
    req_ = {};  // 요청 객체만 초기화, buffer_는 그대로 재사용
    http::async_read(stream_, buffer_, req_, ...);
}

문제 4: CRLF 인젝션 (헤더 주입)

사용자 입력을 헤더 값에 그대로 넣으면 \r\n을 포함한 입력으로 새 헤더나 응답 본문을 끼워 넣을 수 있습니다. 헤더에 넣기 전에 \r, \n이 포함된 값은 거부하거나 제거합니다. Beast는 set()에 CR·LF가 든 값을 넘기면 예외를 던집니다.

문제 5: std::getline과 CRLF

HTTP는 \r\n(CRLF)을 줄 구분자로 쓰는데 std::getline은 \n만 제거하므로, 각 줄 끝에 남은 \r을 직접 지워야 합니다.

std::getline(stream, line);
if (!line.empty() && line.back() == '\r') {
    line.pop_back();
}

body_limit·prepare_payload·Keep-Alive 처리 원칙

Beast 사용 권장

// ❌ 수동 파싱: 엣지 케이스 버그
std::string path = extract_path(raw_request);
// ✅ Beast: RFC 준수, 검증됨
http::request<http::string_body> req;
http::read(socket, buffer, req);
std::string path = std::string(req.target());

body_limit 설정

http::request_parser<http::string_body> parser;
parser.body_limit(1024 * 1024);  // 1MB 제한

prepare_payload() 호출

res.body() = "Hello";
res.prepare_payload();  // Content-Length 자동 설정

Keep-Alive 처리

응답의 keep-alive 여부는 요청에 맞추고, 다음 읽기나 연결 종료는 응답 쓰기가 끝난 뒤(완료 핸들러 안)에서 결정합니다.

res.keep_alive(req.keep_alive());
// ... async_write 완료 핸들러 안에서
if (res.need_eof()) {
    stream_.socket().shutdown(tcp::socket::shutdown_send, ec);
} else {
    do_read();  // 다음 요청 대기
}

에러 응답 일관성

JSON 형식 {"error":"메시지"}로 에러를 반환하며, escape_json으로 따옴표·개행 이스케이프합니다.


graceful shutdown, 헬스 체크, 요청 크기 제한

패턴 1: Graceful Shutdown

std::atomic<bool> shutdown_requested로 SIGINT/SIGTERM 시 acceptor_.close() 호출. do_accept에서 shutdown_requested 확인 후 새 연결 수락 중단.

패턴 2: 헬스 체크 엔드포인트

/health 경로에서 {"status":"ok"} JSON을 반환합니다. Beast 서버 예제에 이미 포함되어 있습니다.

패턴 3: 요청 크기 제한

parser.header_limit(8*1024), parser.body_limit(10*1024*1024)로 헤더 8KB, 본문 10MB 제한을 둡니다.

패턴 4: REST API 클라이언트

BeastHttpClient를 래핑해 getUsers(), createUser() 등 도메인 API를 노출합니다. JSON 파싱은 nlohmann/json 등과 연동합니다.

Keep-Alive vs 새 연결, Beast vs 수동 파싱

Keep-Alive vs 새 연결

같은 서버에 GET을 100번 보낸다고 하면, 요청마다 새 연결을 맺는 쪽은 매번 TCP 3-way handshake에 왕복 시간(RTT) 1번이 추가로 들고, HTTPS라면 TLS 핸드셰이크로 1~2 RTT가 더 붙습니다. Keep-Alive로 연결 하나를 재사용하면 이 비용을 처음 한 번만 냅니다.

방식연결 수요청마다 추가되는 지연
새 연결100개TCP 1 RTT (+ TLS 1~2 RTT)
Keep-Alive1개없음 (첫 요청만 연결 비용)

그래서 차이의 크기는 RTT에 비례합니다. 같은 머신(localhost)에서는 RTT가 매우 짧아 차이가 작게 보이고, 다른 리전의 서버처럼 RTT가 수십 ms인 환경에서는 요청 수만큼 그 시간이 쌓여 차이가 크게 벌어집니다. 새 연결을 계속 만들면 클라이언트 쪽에 TIME_WAIT 소켓이 쌓여 임시 포트가 고갈되는 문제도 생기므로, 같은 호스트로 반복 요청하는 클라이언트는 연결을 재사용하는 것이 기본입니다.

Beast vs 수동 파싱

항목수동 파싱Beast
RFC 준수직접 다룬 경우만파서가 RFC 7230 규칙을 검사
청크 인코딩직접 구현자동
유지보수 부담큼작음
HTTP/2직접 구현미지원(HTTP/1.1과 WebSocket만)

참고 자료


같이 보면 좋은 글


이전 글: C++ 실전 가이드 #28-1: TCP 소켓 기초 다음 글: [C++ 실전 가이드 #28-3] 네트워크 에러 처리: errno·타임아웃·재시도