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

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

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도 아직 못 읽음

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

  • 불완전한 응답: 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 프로토콜 구조

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 /", [](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();
}

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·타임아웃·재시도

관련 글