본문으로 건너뛰기
Previous
Next
C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩

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

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

이 글의 핵심

C++ 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는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

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++ 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 호출·연결 풀·타임아웃·프로덕션 패턴

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.