C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]

C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]

이 글의 핵심

C++ HTTP 기초 완벽 가이드에 대한 실전 가이드입니다. 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1] 등을 예제와 함께 상세히 설명합니다.

들어가며: “HTTP 요청 파싱이 버그 투성이예요”

문제 상황 1: 수동 파싱의 함정

// ❌ 문제: 직접 파싱하면 엣지 케이스에서 크래시
std::string parse_path(const std::string& raw) {
    auto pos = raw.find(" ");
    auto pos2 = raw.find(" ", pos + 1);
    return raw.substr(pos + 1, pos2 - pos - 1);  // 💥 공백 2개면? 빈 문자열?
}
// 문제점:
// - HTTP/1.0 vs HTTP/1.1 차이
// - 연속 공백, 탭, CRLF vs LF 혼용
// - 멀티바이트 문자 (Content-Length vs 실제 바이트)
// - 청크 인코딩: Transfer-Encoding: chunked 처리 누락

왜 이런 일이 발생할까요?

HTTP 프로토콜은 단순해 보이지만 엣지 케이스가 많습니다. \r\n vs \n, 연속 공백, 퍼센트 인코딩, 청크 인코딩, Keep-Alive 등 직접 파싱하면 버그가 쌓입니다.

추가 문제 시나리오

시나리오 2: Content-Length와 본문 불일치
클라이언트가 Content-Length: 100을 보냈는데 실제 본문이 50바이트만 오면, async_read가 영원히 대기합니다. 타임아웃 없으면 서버 스레드가 블로킹됩니다.

시나리오 3: 청크 인코딩 파싱 실패
스트리밍 응답에서 Transfer-Encoding: chunked를 처리하지 않으면, 본문을 끝까지 읽을 수 없어 응답이 잘립니다. 대용량 파일 다운로드·실시간 스트리밍에서 필수입니다.

시나리오 4: 헤더 대소문자·중복
Content-Type vs content-type, Set-Cookie가 여러 개 오는 경우를 처리하지 않으면 파싱 오류나 보안 취약점이 발생합니다.

시나리오 5: 요청이 여러 TCP 패킷에 분할
한 요청이 여러 async_read_some 호출에 걸쳐 도착합니다. “요청 완료” 시점을 정확히 판단하지 못하면 잘못된 데이터를 다음 요청으로 넘깁니다.

해결책:

  1. Boost.Beast: RFC 준수 파서, 에러 처리 내장
  2. flat_buffer: 파싱 중 데이터 보존
  3. http::read: 요청/응답 완료 시점 자동 판단
  4. chunked 인코딩: Beast가 자동 처리

목표:

  • HTTP 요청/응답 구조 완전 이해
  • 헤더 파싱 (대소문자, 중복, 인코딩)
  • 청크 인코딩 (Transfer-Encoding: chunked)
  • Beast 파서 사용법
  • 일반적인 에러와 해결법
  • 베스트 프랙티스프로덕션 패턴

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

이 글을 읽으면:

  • HTTP 프로토콜의 정확한 구조를 이해할 수 있습니다.
  • Beast로 안전한 요청/응답 파싱을 구현할 수 있습니다.
  • 프로덕션 수준의 HTTP 서버/클라이언트 기초를 다질 수 있습니다.

개념을 잡는 비유

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


목차

  1. HTTP 프로토콜 구조
  2. 요청 파싱 (Request)
  3. 응답 파싱 (Response)
  4. 헤더 처리
  5. 청크 인코딩 (Chunked Transfer)
  6. Beast 기반 완전한 파서
  7. 일반적인 에러와 해결법
  8. 베스트 프랙티스
  9. 프로덕션 패턴

1. HTTP 프로토콜 구조

요청/응답 흐름

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버

    C->>S: Request Line + Headers + CRLF + Body
    Note over S: 파싱 → 라우팅 → 처리
    S->>C: Status Line + Headers + CRLF + Body

HTTP 요청 구조

GET /api/users?id=1 HTTP/1.1\r\n
Host: example.com\r\n
Content-Type: application/json\r\n
Content-Length: 0\r\n
\r\n

구성 요소:

  • Request Line: METHOD SP Request-URI SP HTTP-Version CRLF
  • Headers: Field-Name: Field-Value CRLF (반복)
  • 빈 줄: CRLF (헤더와 본문 구분)
  • Body: Content-Length 또는 Transfer-Encoding: chunked로 길이 결정

HTTP 응답 구조

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

구성 요소:

  • Status Line: HTTP-Version SP Status-Code SP Reason-Phrase CRLF
  • Headers: 요청과 동일 형식
  • 빈 줄: 헤더와 본문 구분
  • Body: 응답 본문

HTTP 메시지 파싱 시각화

flowchart TB
    subgraph Request["HTTP 요청"]
        RL[Request Line\nGET /path HTTP/1.1]
        H1[Headers\nHost: example.com\nContent-Type: ...]
        BL[빈 줄 CRLF]
        BD[Body\n본문 데이터]
    end

    RL --> H1 --> BL --> BD

    style RL fill:#4caf50
    style BL fill:#ff9800

2. 요청 파싱 (Request)

Request Line 파싱

#include <string>
#include <sstream>
#include <stdexcept>

struct ParsedRequestLine {
    std::string method;   // GET, POST, ...
    std::string path;     // /api/users
    std::string query;    // id=1 (쿼리 스트링)
    std::string version;  // HTTP/1.1
};

ParsedRequestLine parse_request_line(const std::string& line) {
    std::istringstream iss(line);
    ParsedRequestLine result;

    // METHOD SP Request-URI SP HTTP-Version
    if (!(iss >> result.method >> result.path >> result.version)) {
        throw std::runtime_error("Invalid request line");
    }

    // 쿼리 스트링 분리: /api/users?id=1 → path=/api/users, query=id=1
    auto qpos = result.path.find('?');
    if (qpos != std::string::npos) {
        result.query = result.path.substr(qpos + 1);
        result.path = result.path.substr(0, qpos);
    }

    return result;
}

// 사용 예
int main() {
    auto parsed = parse_request_line("GET /api/users?id=1 HTTP/1.1");
    // parsed.method == "GET"
    // parsed.path == "/api/users"
    // parsed.query == "id=1"
    // parsed.version == "HTTP/1.1"
}

주의점: 실제 프로덕션에서는 퍼센트 인코딩(%20 → 공백) 디코딩, 경로 순회 공격(/../../../etc/passwd) 방지가 필요합니다. Beast는 이를 내장합니다.

헤더와 본문 구분

// CRLF 두 번 연속 = 헤더 끝
std::pair<std::string, std::string> split_headers_and_body(
    const std::string& raw)
{
    // \r\n\r\n 또는 \n\n 찾기 (일부 클라이언트는 LF만 사용)
    const std::string crlfcrlf = "\r\n\r\n";
    const std::string lflf = "\n\n";

    auto pos = raw.find(crlfcrlf);
    if (pos == std::string::npos) {
        pos = raw.find(lflf);
    }
    if (pos == std::string::npos) {
        return {"", ""};  // 아직 헤더 수신 중
    }

    size_t header_end = (raw.find(crlfcrlf) != std::string::npos)
        ? pos + crlfcrlf.size()
        : pos + lflf.size();

    return {
        raw.substr(0, pos),
        raw.substr(header_end)
    };
}

Content-Length 기반 본문 읽기

#include <cstdlib>
#include <optional>

std::optional<size_t> get_content_length(const std::string& headers) {
    // Content-Length: 123 형태에서 123 추출
    const std::string key = "Content-Length:";
    auto pos = headers.find(key);
    if (pos == std::string::npos) {
        return std::nullopt;  // 본문 없음 또는 chunked
    }

    pos += key.size();
    while (pos < headers.size() && headers[pos] == ' ') ++pos;

    char* end;
    long value = std::strtol(headers.c_str() + pos, &end, 10);
    if (value < 0 || end == headers.c_str() + pos) {
        return std::nullopt;  // 잘못된 형식
    }
    return static_cast<size_t>(value);
}

3. 응답 파싱 (Response)

Status Line 파싱

struct ParsedStatusLine {
    std::string version;   // HTTP/1.1
    int status_code;       // 200, 404, ...
    std::string reason;    // OK, Not Found, ...
};

ParsedStatusLine parse_status_line(const std::string& line) {
    std::istringstream iss(line);
    ParsedStatusLine result;

    if (!(iss >> result.version >> result.status_code)) {
        throw std::runtime_error("Invalid status line");
    }
    std::getline(iss, result.reason);  // 나머지: " OK\r" 또는 " OK"
    // 앞뒤 공백 제거
    result.reason.erase(0, result.reason.find_first_not_of(" \t\r\n"));
    result.reason.erase(result.reason.find_last_not_of(" \t\r\n") + 1);

    return result;
}

// 사용 예
// parse_status_line("HTTP/1.1 200 OK")  → 200, "OK"
// parse_status_line("HTTP/1.1 404 Not Found")  → 404, "Not Found"

응답 본문 읽기 전략

// 본문 읽기 전략 결정
enum class BodyReadStrategy {
    NoBody,           // HEAD, 204, 304 등
    ContentLength,    // Content-Length 있음
    Chunked,          // Transfer-Encoding: chunked
    UntilClose        // HTTP/1.0, 본문 끝까지 (연결 종료 시)
};

BodyReadStrategy determine_strategy(
    int status_code,
    const std::string& method,
    const std::map<std::string, std::string>& headers)
{
    if (method == "HEAD" || status_code == 204 || status_code == 304) {
        return BodyReadStrategy::NoBody;
    }

    auto it = headers.find("transfer-encoding");
    if (it != headers.end() &&
        it->second.find("chunked") != std::string::npos) {
        return BodyReadStrategy::Chunked;
    }

    if (headers.count("content-length")) {
        return BodyReadStrategy::ContentLength;
    }

    return BodyReadStrategy::UntilClose;  // HTTP/1.0 폴백
}

4. 헤더 처리

헤더 파싱 (대소문자 무시)

#include <map>
#include <algorithm>
#include <cctype>

std::map<std::string, std::string> parse_headers(const std::string& header_block) {
    std::map<std::string, std::string> headers;

    std::istringstream iss(header_block);
    std::string line;

    while (std::getline(iss, line) && !line.empty() &&
           (line.back() == '\r' ? (line.pop_back(), true) : true)) {

        auto colon = line.find(':');
        if (colon == std::string::npos) continue;

        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\r\n") + 1);

        // 헤더 이름 소문자로 정규화 (HTTP 헤더는 대소문자 무시)
        std::transform(name.begin(), name.end(), name.begin(),
             { return std::tolower(c); });

        // 동일 헤더 여러 개: Set-Cookie 등은 별도 처리 필요
        if (headers.count(name)) {
            headers[name] += ", " + value;  // 간단한 병합
        } else {
            headers[name] = value;
        }
    }

    return headers;
}

주요 헤더 설명

헤더용도예시
Content-Type본문 MIME 타입application/json, text/html
Content-Length본문 바이트 수1024
Transfer-Encoding전송 인코딩chunked
Host요청 대상 호스트example.com:8080
Connection연결 유지keep-alive, close
Accept-Encoding압축 지원gzip, deflate, br

Content-Type 파싱 (MIME + charset)

struct ParsedContentType {
    std::string media_type;   // application/json
    std::string charset;      // utf-8 (있으면)
};

ParsedContentType parse_content_type(const std::string& value) {
    ParsedContentType result;

    auto semicolon = value.find(';');
    result.media_type = value.substr(0, semicolon);
    result.media_type.erase(0, result.media_type.find_first_not_of(" \t"));
    result.media_type.erase(result.media_type.find_last_not_of(" \t") + 1);

    if (semicolon != std::string::npos) {
        std::string rest = value.substr(semicolon + 1);
        auto eq = rest.find('=');
        if (eq != std::string::npos) {
            std::string key = rest.substr(0, eq);
            std::string val = rest.substr(eq + 1);
            // 공백 제거, 따옴표 제거
            val.erase(0, val.find_first_not_of(" \t\""));
            val.erase(val.find_last_not_of(" \t\"") + 1);
            if (key.find("charset") != std::string::npos) {
                result.charset = val;
            }
        }
    }

    return result;
}

5. 청크 인코딩 (Chunked Transfer)

청크 형식

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

형식: [16진수 크기]\r\n[데이터]\r\n 반복, 마지막에 0\r\n\r\n

청크 디코딩 구현

#include <vector>
#include <cctype>

std::pair<std::vector<char>, size_t> decode_chunk(
    const char* data, size_t size, size_t& consumed)
{
    std::vector<char> body;
    consumed = 0;

    const char* p = data;
    const char* end = data + size;

    while (p < end) {
        // 청크 크기 읽기 (16진수)
        if (p + 2 > end) break;  // 최소 "0\r\n" 필요

        char* hex_end;
        unsigned long chunk_size = std::strtoul(p, &hex_end, 16);
        p = hex_end;

        // \r\n 건너뛰기
        if (p + 2 > end) break;
        if (p[0] != '\r' || p[1] != '\n') {
            throw std::runtime_error("Invalid chunk: expected CRLF");
        }
        p += 2;
        consumed = p - data;

        if (chunk_size == 0) {
            // 마지막 청크, 뒤에 \r\n 있을 수 있음
            if (p + 2 <= end && p[0] == '\r' && p[1] == '\n') {
                consumed += 2;
            }
            break;
        }

        // 청크 데이터
        if (p + chunk_size + 2 > end) {
            break;  // 아직 데이터 부족
        }

        body.insert(body.end(), p, p + chunk_size);
        p += chunk_size;
        consumed = p - data;

        if (p[0] != '\r' || p[1] != '\n') {
            throw std::runtime_error("Invalid chunk: expected CRLF after data");
        }
        p += 2;
        consumed = p - data;
    }

    return {body, consumed};
}

청크 인코딩 시각화

flowchart LR
    subgraph Chunked["청크 인코딩"]
        C1["5\r\nHello\r\n"]
        C2["6\r\n World\r\n"]
        C3["0\r\n\r\n"]
    end

    C1 --> C2 --> C3

    subgraph Decoded["디코딩 결과"]
        D["Hello World"]
    end

    Chunked -->|decode_chunk| Decoded

6. Beast 기반 완전한 파서

Beast로 요청 읽기

#include <boost/beast.hpp>
#include <boost/asio.hpp>

namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
using tcp = net::ip::tcp;

void read_http_request(tcp::socket& socket) {
    beast::flat_buffer buffer;
    http::request<http::string_body> req;

    beast::error_code ec;
    http::read(socket, buffer, req, ec);

    if (ec) {
        if (ec == http::error::end_of_stream) {
            // 연결 종료 (정상)
            return;
        }
        std::cerr << "Read error: " << ec.message() << "\n";
        return;
    }

    // 파싱 완료된 요청 사용
    std::cout << "Method: " << req.method_string() << "\n";
    std::cout << "Path: " << req.target() << "\n";
    std::cout << "Version: " << req.version() << "\n";

    for (const auto& field : req) {
        std::cout << field.name() << ": " << field.value() << "\n";
    }

    std::cout << "Body: " << req.body() << "\n";
}

Beast로 응답 읽기 (청크 자동 처리)

void read_http_response(beast::tcp_stream& stream) {
    beast::flat_buffer buffer;
    http::response_parser<http::string_body> parser;
    parser.body_limit(std::numeric_limits<std::uint64_t>::max());  // 본문 제한

    beast::error_code ec;
    http::read(stream, buffer, parser, ec);

    if (ec) {
        std::cerr << "Read error: " << ec.message() << "\n";
        return;
    }

    auto res = parser.get();
    std::cout << "Status: " << res.result_int() << "\n";
    std::cout << "Body: " << res.body() << "\n";
    // Beast가 Transfer-Encoding: chunked를 자동 디코딩함
}

비동기 요청 읽기

void do_read_async(beast::tcp_stream& stream,
    std::function<void(http::request<http::string_body>)> on_request)
{
    auto buffer = std::make_shared<beast::flat_buffer>();
    auto req = std::make_shared<http::request<http::string_body>>();

    http::async_read(stream, *buffer, *req,
        [&stream, buffer, req, on_request](beast::error_code ec, std::size_t) {
            if (ec) {
                if (ec != http::error::end_of_stream) {
                    std::cerr << "Read error: " << ec.message() << "\n";
                }
                return;
            }
            on_request(std::move(*req));
        });
}

HTTP 응답 생성 및 전송

void send_json_response(beast::tcp_stream& stream,
    unsigned status, const std::string& json_body)
{
    http::response<http::string_body> res{http::status::ok, 11};
    res.set(http::field::server, "MyServer/1.0");
    res.set(http::field::content_type, "application/json");
    res.body() = json_body;
    res.prepare_payload();  // Content-Length 자동 설정

    if (status != 200) {
        res.result(static_cast<http::status>(status));
    }

    beast::error_code ec;
    http::write(stream, res, ec);
    if (ec) {
        std::cerr << "Write error: " << ec.message() << "\n";
    }
}

완전한 HTTP 서버 예시 (Beast)

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

7. 일반적인 에러와 해결법

문제 1: “end_of_stream” 또는 “connection reset”

원인: 클라이언트가 요청 도중 연결을 끊음 (브라우저 새로고침, 타임아웃 등).

해결법:

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: “partial message” 또는 읽기 대기 무한 루프

원인: Content-Length와 실제 본문 크기 불일치, 또는 청크 인코딩 파싱 오류.

해결법:

  • Beast 사용 시 자동 처리됨. 수동 파싱 시 Content-Length 검증 필수.
  • 타임아웃 설정으로 무한 대기 방지:
stream_.expires_after(std::chrono::seconds(30));
http::async_read(stream_, buffer_, req_, handler);

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

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

해결법:

void do_read() {
    req_ = {};  // 요청 초기화
    buffer_.consume(buffer_.size());  // 버퍼 비우기
    http::async_read(stream_, buffer_, req_, ...);
}

문제 5: 헤더 인젝션 (CRLF Injection)

원인: 사용자 입력을 헤더에 그대로 넣으면 \r\n으로 새 헤더 주입 가능.

해결법:

// ❌ 위험
res.set("X-Custom", user_input);

// ✅ 안전: CRLF 제거
std::string safe_value = user_input;
safe_value.erase(
    std::remove(safe_value.begin(), safe_value.end(), '\r'),
    safe_value.end());
safe_value.erase(
    std::remove(safe_value.begin(), safe_value.end(), '\n'),
    safe_value.end());
res.set("X-Custom", safe_value);

문제 6: 대용량 본문 메모리 폭발

원인: string_body로 1GB 파일 업로드 시 메모리 1GB 사용.

해결법: dynamic_body 또는 file_body 사용:

http::request<http::dynamic_body> req;
// 또는
http::request_parser<http::file_body> parser;
parser.body_limit(100 * 1024 * 1024);  // 100MB
boost::beast::file_mode mode = boost::beast::file_mode::write;
parser.get().body().open("/tmp/upload.dat", mode);

8. 베스트 프랙티스

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. 타임아웃 설정

stream_.expires_after(std::chrono::seconds(30));

4. prepare_payload() 호출

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

5. Keep-Alive 처리

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

6. 에러 응답 일관성

std::string escape_json(const std::string& s) {
    std::string out;
    for (char c : s) {
        if (c == '"') out += "\\\"";
        else if (c == '\\') out += "\\\\";
        else if (c == '\n') out += "\\n";
        else if (c == '\r') out += "\\r";
        else out += c;
    }
    return out;
}

void send_error(beast::tcp_stream& stream, unsigned status,
    const std::string& message)
{
    http::response<http::string_body> res{
        static_cast<http::status>(status), 11};
    res.set(http::field::content_type, "application/json");
    res.body() = "{\"error\":\"" + escape_json(message) + "\"}";
    res.prepare_payload();
    http::write(stream, res);
}

9. 프로덕션 패턴

패턴 1: 요청 로깅 미들웨어

void log_request(const http::request<http::string_body>& req) {
    auto now = std::chrono::system_clock::now();
    auto time = std::chrono::system_clock::to_time_t(now);
    std::cerr << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S")
              << " " << req.method_string() << " " << req.target()
              << " " << req.version() << "\n";
}

패턴 2: 요청 크기 제한 (Rate Limiting)

constexpr size_t MAX_HEADER_SIZE = 8 * 1024;   // 8KB
constexpr size_t MAX_BODY_SIZE = 10 * 1024 * 1024;  // 10MB

http::request_parser<http::string_body> parser;
parser.header_limit(MAX_HEADER_SIZE);
parser.body_limit(MAX_BODY_SIZE);

패턴 3: Graceful Shutdown

std::atomic<bool> shutdown_requested{false};

void do_accept() {
    if (shutdown_requested) return;
    acceptor_.async_accept(
        [this](beast::error_code ec, tcp::socket socket) {
            if (shutdown_requested) return;
            if (!ec) {
                std::make_shared<HttpSession>(std::move(socket))->start();
            }
            do_accept();
        });
}

// SIGINT 핸들러
void on_signal() {
    shutdown_requested = true;
    acceptor_.close();
}

패턴 4: 연결 풀 (클라이언트)

class HttpClientPool {
    net::io_context& ioc_;
    std::queue<std::unique_ptr<beast::tcp_stream>> pool_;
    std::mutex mtx_;
    tcp::resolver::results_type endpoints_;

public:
    void get_connection(std::function<void(beast::tcp_stream&)> callback) {
        std::unique_lock lock(mtx_);
        if (!pool_.empty()) {
            auto stream = std::move(pool_.front());
            pool_.pop();
            lock.unlock();
            callback(*stream);
            return;
        }
        lock.unlock();

        auto stream = std::make_unique<beast::tcp_stream>(ioc_);
        stream->async_connect(endpoints_,
            [this, cb = std::move(callback), s = stream.get()]
            (beast::error_code ec) {
                if (!ec) cb(*s);
            });
    }

    void release_connection(std::unique_ptr<beast::tcp_stream> stream) {
        std::lock_guard lock(mtx_);
        pool_.push(std::move(stream));
    }
};

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

if (req.target() == "/health") {
    res.result(http::status::ok);
    res.set(http::field::content_type, "application/json");
    res.body() = "{\"status\":\"ok\"}";
    res.prepare_payload();
    // DB/캐시 체크 생략, 빠른 응답
    return;
}

패턴 6: CORS 헤더

res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");

if (req.method() == http::verb::options) {
    res.result(http::status::ok);
    res.body() = "";
    res.prepare_payload();
    return;  // Preflight 응답
}

구현 체크리스트

  • Beast http::read/http::write 사용 (수동 파싱 지양)
  • body_limit 설정 (DoS 방지)
  • expires_after 타임아웃 설정
  • prepare_payload() 호출
  • Keep-Alive 처리 (buffer_.consume, req_ = {})
  • CRLF 인젝션 방지 (헤더 값 검증)
  • 에러 응답 일관성 (JSON 형식)
  • 로깅 미들웨어
  • Graceful shutdown

참고 자료


이전 글: C++ 실전 가이드 #29-3: 멀티스레드 네트워크 서버


자주 묻는 질문 (FAQ)

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

A. HTTP 프로토콜 파싱이 헷갈리는 문제를 해결합니다. 요청/응답 구조, 헤더 파싱, 청크 인코딩, Beast 기반 파서, 일반적인 에러, 베스트 프랙티스, 프로덕션 패턴까지 실전 코드로 완벽 정리. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

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

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

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

다음 글: C++ 실전 가이드 #30-2: SSL/TLS 보안 통신


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

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

  • C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지
  • C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]
  • C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]

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

C++, HTTP, Beast, Asio, 요청파싱, 응답파싱, 청크인코딩, 헤더, REST 등으로 검색하시면 이 글이 도움이 됩니다.


관련 글

  • C++ 시리즈 전체 보기
  • C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
  • C++ ADL |
  • C++ Aggregate Initialization |