C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩

C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩

이 글의 핵심

HTTP 요청/응답 파싱이 어려운 문제를 해결합니다. 완전한 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도 아직 못 읽음

실제 프로덕션에서 겪는 문제들:

  • 불완전한 응답: recv가 한 번에 전체를 반환하지 않아 파싱 실패
  • 타임아웃: 느린 서버에서 무한 대기
  • 연결 낭비: 요청마다 새 TCP 연결 → 3-way handshake 반복
  • 청크 인코딩: Content-Length 없이 Transfer-Encoding: chunked로 오는 응답 처리 못 함
  • 헤더 대소문자: Content-Length vs 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 경계 혼동: 한 연결에 여러 요청이 오면 버퍼에 잔여 데이터가 섞임. req_ = {}, buffer_.consume()으로 초기화 필요.
  • Content-Length 불일치: 서버가 1000바이트라고 했는데 500바이트만 보내고 끊으면 무한 대기. 타임아웃 필수.
  • 청크 파싱 오류: 1a\r\n(26바이트)을 10진수로 파싱하면 잘못됨. 16진수(stoull(..., 16)) 사용.
  • 동시 연결 폭주: 요청마다 새 스레드 생성 시 스레드 수 폭증. 스레드 풀 + 비동기 I/O 권장.

해결책:

  1. HTTP 파서: 버퍼에 데이터 누적, \r\n\r\n 찾기, Content-Length 기반 본문 읽기
  2. 연결 풀: Keep-Alive로 연결 재사용
  3. 청크 파서: 크기\r\n데이터\r\n 반복 파싱
  4. 타임아웃: SO_RCVTIMEO 또는 select
  5. Boost.Beast: RFC 준수 파서, 에러 처리 내장

목표:

  • HTTP 파서 구현 (요청/응답)
  • Beast 기반 완전한 클라이언트/서버
  • Keep-Alive 연결 풀
  • 청크 인코딩 파싱
  • 타임아웃에러 처리
  • 베스트 프랙티스프로덕션 패턴

요구 환경: POSIX 소켓, Boost.Asio, Boost.Beast 1.70+

이 글을 읽으면:

  • HTTP 프로토콜의 동작 원리를 이해할 수 있습니다.
  • 완전한 HTTP 클라이언트/서버를 구현할 수 있습니다.
  • Beast로 프로덕션 수준의 HTTP 통신을 구현할 수 있습니다.

개념을 잡는 비유

소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.


목차

  1. HTTP 프로토콜 구조
  2. 문제 시나리오 상세
  3. HTTP 파서 구현
  4. Beast HTTP 클라이언트
  5. Beast HTTP 서버
  6. HTTP 클라이언트 (Keep-Alive, 소켓)
  7. HTTP 서버 (라우팅)
  8. 청크 인코딩 처리
  9. 타임아웃 처리
  10. 자주 발생하는 에러
  11. 베스트 프랙티스
  12. 프로덕션 패턴
  13. 성능 비교

1. 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;
};

2. 문제 시나리오 상세

시나리오 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

비용: 새 연결 시 RTT(왕복 지연) + TLS 핸드셰이크(HTTPS) 추가. Keep-Alive로 5~10배 성능 향상 가능합니다.

차이: Content-Length는 본문 크기를 미리 알 때 사용. 스트리밍 응답(실시간, 대용량)은 청크 인코딩을 씁니다.


3. HTTP 파서 구현

완전한 HTTP 파서 클래스

#include <string>
#include <sstream>
#include <algorithm>

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 기반 본문 읽기
    static void readBody(int fd, HttpResponse& res, std::string& buffer) {
        auto it = res.headers.find("content-length");
        if (it == res.headers.end()) return;

        size_t content_length = std::stoull(it->second);

        // 이미 버퍼에 있는 본문
        size_t already_read = res.body.size();

        // 더 읽어야 할 바이트
        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);
        }
    }

    // 청크 인코딩 본문 읽기
    static void readChunkedBody(int fd, std::string& body) {
        std::string buffer;

        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) {
                // 마지막 청크
                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
        }
    }
};

4. 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::readTransfer-Encoding: chunked를 자동 디코딩합니다.


5. Beast HTTP 서버

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

#include <boost/beast.hpp>
#include <boost/asio.hpp>
#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_;

public:
    explicit HttpSession(tcp::socket socket)
        : stream_(std::move(socket)) {}

    void start() { do_read(); }

private:
    void do_read() {
        req_ = {};
        buffer_.consume(buffer_.size());

        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() {
        http::response<http::string_body> res{http::status::ok, req_.version()};
        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) {
                    if (req_.keep_alive()) {
                        do_read();  // Keep-Alive: 다음 요청
                    }
                }
            });
    }
};

int main() {
    net::io_context ioc;
    tcp::acceptor acceptor(ioc, {tcp::v4(), 8080});

    auto 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();
}

6. HTTP 클라이언트 (Keep-Alive, 소켓)

연결 풀 구현

#include <unordered_map>
#include <queue>
#include <mutex>

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;
            HttpParser::readUntilHeaders(fd, buffer);
            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, res.body);
            else
                HttpParser::readBody(fd, res, buffer);

            returnConnection(fd, host, port);
            return res;
        } catch (...) {
            close(fd);
            throw;
        }
    }

private:
    int connectNew(const std::string& host, uint16_t port) {
        int fd = socket(AF_INET, SOCK_STREAM, 0);
        if (fd < 0) throw std::runtime_error("socket() failed");
        struct sockaddr_in addr{};
        addr.sin_family = AF_INET;
        addr.sin_port = htons(port);
        inet_pton(AF_INET, "93.184.216.34", &addr.sin_addr);
        if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
            close(fd);
            throw std::runtime_error("connect() failed");
        }
        return fd;
    }
};

7. 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);
                    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 /",  {
        res.status_code = 200;
        res.status_message = "OK";
        res.body = "Hello, World!";
    });

    server.route("GET /api/users",  {
        res.status_code = 200;
        res.status_message = "OK";
        res.body = R"([{"id":1,"name":"Alice"}])";
    });

    server.run();
}

8. 청크 인코딩 처리

청크 인코딩 형식

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가 자동으로 청크를 디코딩합니다.


9. 타임아웃 처리

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);

10. 자주 발생하는 에러

에러 요약 표

에러원인해결
불완전 응답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을 초과함 (DoS 방지용).

해결법:

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

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

원인: 한 연결에 여러 요청이 올 때, 이전 요청의 버퍼를 비우지 않음.

해결법:

void do_read() {
    req_ = {};
    buffer_.consume(buffer_.size());
    http::async_read(stream_, buffer_, req_, ...);
}

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

원인: 사용자 입력을 헤더에 그대로 넣으면 \r\n으로 새 헤더 주입 가능. 해결: \r, \n 문자 제거 후 헤더에 삽입.

문제 5: std::getline과 CRLF

원인: HTTP는 \r\n(CRLF)를 줄 구분자로 사용. std::getline\n만 제거.

해결법:

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

11. 베스트 프랙티스

1. 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());

2. body_limit 설정

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

3. prepare_payload() 호출

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

4. Keep-Alive 처리

if (req.keep_alive()) {
    res.keep_alive(true);
    do_read();  // 다음 요청 대기
} else {
    res.keep_alive(false);
    stream_.socket().shutdown(tcp::socket::shutdown_send);
}

5. 에러 응답 일관성

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


12. 프로덕션 패턴

패턴 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 등과 연동합니다.

프로덕션 체크리스트

  • body_limit, header_limit 설정
  • 타임아웃 설정 (stream_.expires_after)
  • Graceful shutdown (SIGINT/SIGTERM)
  • 헬스 체크 엔드포인트 (/health)
  • CRLF 인젝션 방지
  • Keep-Alive 처리
  • 에러 로깅

13. 성능 비교

Keep-Alive vs 새 연결

테스트: 100회 GET 요청

방식시간연결 수오버헤드
새 연결2.8초100개3-way handshake × 100
Keep-Alive0.5초1개재사용

결론: Keep-Alive는 약 5.6배 빠름

Beast vs 수동 파싱

항목수동 파싱Beast
RFC 준수부분완전
청크 인코딩직접 구현자동
유지보수높음낮음

참고 자료


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]
  • C++ 네트워크 에러 완벽 가이드 | errno·타임아웃·재시도·서킷브레이커 [#28-3]
  • C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]

이 글에서 다루는 키워드 (관련 검색어)

C++ HTTP 클라이언트, HTTP 파싱, Boost.Beast, Keep-Alive, 청크 인코딩 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목내용
파서\r\n\r\n 찾기, 헤더 파싱, Content-Length 기반 본문 읽기
BeastRFC 준수, 청크 자동 처리, 권장
Keep-Alive연결 재사용으로 5.6배 빠름
청크 인코딩크기(hex)\r\n데이터\r\n 반복 파싱
타임아웃SO_RCVTIMEO 또는 stream_.expires_after

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. REST API 클라이언트, 마이크로서비스 통신, 파일 다운로드, 웹 크롤러 등에 활용합니다. Beast나 libcurl을 쓰더라도 프로토콜 이해가 디버깅에 필수입니다.

Q. Beast나 libcurl을 쓰는 게 낫지 않나요?

A. 네, 프로덕션에서는 검증된 라이브러리를 사용하는 것이 좋습니다. Beast는 HTTP/1.1, HTTP/2, WebSocket을 표준에 맞게 구현합니다. 프로토콜을 이해하면 디버깅과 최적화에 큰 도움이 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. RFC 7230-7235, Boost.Beast 문서, libcurl 문서를 참고하세요.

한 줄 요약: HTTP 파서·Beast·Keep-Alive·청크 인코딩으로 완전한 HTTP 클라이언트/서버를 구현할 수 있습니다.

이전 글: C++ 실전 가이드 #28-1: TCP 소켓 기초

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


관련 글

  • C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴