C++ 초경량 HTTP 웹 프레임워크 바닥부터 만들기
들어가며: Express 같은 것을 C++로 최소한만 구현해 보기
C++로 HTTP 서버를 만들 때 프로덕션이라면 Boost.Beast, Crow, Drogon 같은 검증된 라이브러리를 쓰는 것이 맞습니다. 그래도 직접 만들어 볼 가치가 있는 경우가 있습니다. 바이너리 크기와 의존성을 엄격하게 제한하는 임베디드 장치에서 몇 개의 엔드포인트만 제공하면 될 때, HTTP와 자체 TCP 프로토콜을 한 서버에서 함께 다뤄야 해서 요청 처리 흐름을 직접 설계해야 할 때, 그리고 라우팅이나 미들웨어 체인이 내부에서 어떻게 돌아가는지 이해하고 싶을 때입니다.
이 글에서는 Asio 위에 다음 조각들을 차례로 올려 Express와 비슷한 사용감의 최소 프레임워크를 만듭니다.
- 요청 라인과 헤더,
Content-Length기반 바디를 읽는 파서 /users/:id같은 경로 파라미터를 지원하는 라우터- 로깅·CORS·인증을 끼워 넣는 미들웨어 체인
- keep-alive 연결에서 여러 요청을 순서대로 처리하는 세션
선수 지식으로는 Asio 입문과 Redis 클론에서 다룬 세션과 비동기 읽기 패턴을 알고 있으면 좋습니다. 예제는 Boost.Asio와 C++17 기준이며, vcpkg install boost-asio나 시스템 패키지로 설치할 수 있습니다.
전체 구조
연결 하나는 세션 객체 하나가 맡습니다. 세션은 async_read_until로 헤더 끝(\r\n\r\n)까지 읽고, Content-Length가 있으면 그만큼 바디를 더 읽습니다. 요청이 완성되면 라우터가 경로에 맞는 핸들러를 찾고, 미들웨어 체인이 그 핸들러를 감싼 채로 실행합니다. 핸들러가 만든 응답은 HTTP 텍스트로 직렬화되어 async_write로 나가고, keep-alive라면 같은 세션이 다음 요청을 다시 읽습니다.
sequenceDiagram
participant C as Client
participant S as Session
participant R as Router
participant M as Middleware
participant H as Handler
C->>S: HTTP 요청
S->>S: 헤더와 바디 파싱
S->>R: 경로 매칭, 파라미터 추출
R-->>S: 핸들러
S->>M: 요청 + 핸들러
M->>H: 전처리 후 next
H-->>M: Response
M-->>S: 후처리한 Response
S->>C: HTTP 응답
라우팅을 미들웨어보다 먼저 하는 이유는, 경로에 맞는 핸들러가 없는 404 요청도 로깅 미들웨어를 거치게 하고, 미들웨어가 필요하면 추출된 경로 파라미터를 볼 수 있게 하기 위해서입니다.
HTTP 요청 파싱
HTTP/1.1 요청은 요청 라인(GET /api/hello?name=world HTTP/1.1), 이름: 값 형태의 헤더 줄들, 빈 줄, 그리고 선택적인 바디로 이루어집니다. 줄 구분자는 CRLF(\r\n)입니다. std::getline은 \n만 떼어 내므로 줄 끝에 남는 \r을 직접 지워야 합니다. 이걸 빠뜨리면 헤더 값이 "keep-alive\r"처럼 되어 문자열 비교가 모두 실패합니다.
헤더 이름은 대소문자를 구분하지 않습니다(RFC 9110). 클라이언트가 Content-Length를 보낼지 content-length를 보낼지 알 수 없으므로, 파싱할 때 이름을 소문자로 정규화해 두면 이후 조회가 단순해집니다.
#include <cctype>
#include <map>
#include <sstream>
#include <string>
struct Request {
std::string method;
std::string path;
std::string query_string;
std::string version;
std::map<std::string, std::string> headers; // 이름은 소문자로 저장
std::map<std::string, std::string> params; // 경로 파라미터
std::string body;
};
inline std::string to_lower(std::string s) {
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
inline void trim(std::string& s) {
auto is_space = [](char c) { return std::isspace(static_cast<unsigned char>(c)) != 0; };
while (!s.empty() && is_space(s.back())) s.pop_back();
std::size_t i = 0;
while (i < s.size() && is_space(s[i])) ++i;
s.erase(0, i);
}
inline void strip_cr(std::string& line) {
if (!line.empty() && line.back() == '\r') line.pop_back();
}
// "GET /api/hello?name=world HTTP/1.1"
inline bool parse_request_line(const std::string& line, Request& req) {
std::istringstream iss(line);
if (!(iss >> req.method >> req.path >> req.version)) return false;
auto qpos = req.path.find('?');
if (qpos != std::string::npos) {
req.query_string = req.path.substr(qpos + 1);
req.path.resize(qpos);
}
return true;
}
// "Content-Type: application/json"
inline bool parse_header(const std::string& line, Request& req) {
auto colon = line.find(':');
if (colon == std::string::npos || colon == 0) return false;
std::string key = to_lower(line.substr(0, colon));
std::string value = line.substr(colon + 1);
trim(value);
req.headers[key] = value;
return true;
}
std::isspace와 std::tolower에 char를 그대로 넘기면, char가 부호 있는 타입인 플랫폼에서 UTF-8 바이트 같은 음수 값이 들어갈 때 미정의 동작이 됩니다. 그래서 unsigned char로 한 번 변환해서 넘깁니다. 같은 이름의 헤더가 여러 번 오면 위 구현은 마지막 값만 남기는데, 최소 구현에서는 허용할 만한 단순화입니다.
경로 파라미터를 지원하는 라우터
라우트 패턴과 요청 경로를 / 기준으로 나눠 세그먼트 단위로 비교합니다. 패턴 세그먼트가 :로 시작하면 어떤 값이든 받아들이고 그 값을 파라미터로 기록합니다.
#include <functional>
#include <vector>
struct Response {
int status = 200;
std::string content_type = "text/plain; charset=utf-8";
std::string body;
std::vector<std::pair<std::string, std::string>> headers; // 추가 헤더
};
class Router {
public:
using Handler = std::function<Response(const Request&)>;
void get(const std::string& pattern, Handler h) { add("GET", pattern, std::move(h)); }
void post(const std::string& pattern, Handler h) { add("POST", pattern, std::move(h)); }
// 일치하는 핸들러를 찾아 경로 파라미터를 req.params에 채운다. 없으면 nullptr
const Handler* match(Request& req) const {
auto segs = split(req.path);
for (const auto& r : routes_) {
if (r.method != req.method) continue;
std::map<std::string, std::string> params;
if (match_segments(r.segments, segs, params)) {
req.params = std::move(params);
return &r.handler;
}
}
return nullptr;
}
private:
struct Route {
std::string method;
std::vector<std::string> segments;
Handler handler;
};
void add(std::string method, const std::string& pattern, Handler h) {
routes_.push_back({std::move(method), split(pattern), std::move(h)});
}
static std::vector<std::string> split(const std::string& path) {
std::vector<std::string> out;
std::string cur;
for (char c : path) {
if (c == '/') {
if (!cur.empty()) out.push_back(cur);
cur.clear();
} else {
cur += c;
}
}
if (!cur.empty()) out.push_back(cur);
return out;
}
static bool match_segments(const std::vector<std::string>& pattern,
const std::vector<std::string>& segs,
std::map<std::string, std::string>& params) {
if (pattern.size() != segs.size()) return false;
for (std::size_t i = 0; i < pattern.size(); ++i) {
if (!pattern[i].empty() && pattern[i][0] == ':') {
params[pattern[i].substr(1)] = segs[i];
} else if (pattern[i] != segs[i]) {
return false;
}
}
return true;
}
std::vector<Route> routes_;
};
라우트를 등록 순서대로 선형 탐색하므로, /users/me처럼 구체적인 경로는 /users/:id보다 먼저 등록해야 합니다. Express도 같은 규칙을 따릅니다. 라우트가 수십 개 수준이면 선형 탐색으로 충분하고, 수백 개 이상으로 늘어나면 세그먼트 단위 트리(radix tree)로 바꾸는 것이 일반적인 다음 단계입니다.
처음에 라우팅 테이블을 std::unordered_map<std::pair<std::string, std::string>, Handler>로 잡는 경우가 많은데, 표준 라이브러리에는 std::pair에 대한 std::hash 특수화가 없어서 해시 함수를 직접 넘기지 않으면 컴파일되지 않습니다. 고정 경로만 다룬다면 std::map<std::pair<...>, Handler>로 충분하고, 경로 파라미터가 필요하면 위처럼 패턴 목록을 두는 편이 자연스럽습니다.
미들웨어 체인
미들웨어는 요청과 “다음 단계”를 받아, 전처리를 하고 next(req)를 호출한 뒤 돌아온 응답을 후처리해서 반환하는 함수입니다. next를 호출하지 않고 바로 응답을 반환하면 체인이 거기서 끊깁니다. 인증 실패 시 401을 돌려주는 경우가 그렇습니다.
#include <iostream>
using Next = std::function<Response(const Request&)>;
using Middleware = std::function<Response(const Request&, const Next&)>;
Response logging_mw(const Request& req, const Next& next) {
std::cout << "--> " << req.method << " " << req.path << "\n";
Response res = next(req);
std::cout << "<-- " << res.status << " " << req.path << "\n";
return res;
}
Response cors_mw(const Request& req, const Next& next) {
Response res = next(req);
res.headers.emplace_back("Access-Control-Allow-Origin", "*");
return res;
}
Response auth_mw(const Request& req, const Next& next) {
if (req.path.rfind("/api/admin", 0) == 0) { // /api/admin 으로 시작하는 경로만 보호
auto it = req.headers.find("authorization");
if (it == req.headers.end() || it->second != "Bearer secret-token") {
return Response{401, "text/plain; charset=utf-8", "Unauthorized"};
}
}
return next(req);
}
체인을 조립하는 부분은 미들웨어 목록의 i번째를 실행하면서, 그 미들웨어에 “i+1번째부터 실행하는 함수”를 next로 넘기는 재귀로 표현할 수 있습니다. 마지막까지 가면 라우터가 찾은 핸들러를 호출합니다.
class App {
public:
Router router;
void use(Middleware mw) { middlewares_.push_back(std::move(mw)); }
Response handle(Request& req) const {
const Router::Handler* h = router.match(req);
Next final_handler = h ? *h : Next([](const Request&) {
return Response{404, "text/plain; charset=utf-8", "Not Found"};
});
return run(0, req, final_handler);
}
private:
Response run(std::size_t i, const Request& req, const Next& final_handler) const {
if (i == middlewares_.size()) return final_handler(req);
return middlewares_[i](req, [this, i, &final_handler](const Request& r) {
return run(i + 1, r, final_handler);
});
}
std::vector<Middleware> middlewares_;
};
logging_mw, cors_mw, auth_mw 순서로 등록했다면 요청은 logging → cors → auth → 핸들러 순서로 들어가고, 응답은 auth → cors → logging 순서로 되돌아 나옵니다. 따라서 로깅 미들웨어가 찍는 상태 코드에는 401과 404도 포함됩니다. 반대로 인증 실패 응답에도 CORS 헤더를 붙이고 싶다면 cors가 auth보다 바깥에 있어야 합니다. 브라우저는 CORS 헤더가 없는 응답의 본문을 스크립트에 노출하지 않으므로, 이 순서를 틀리면 프런트엔드에서 401 대신 알 수 없는 CORS 에러만 보게 됩니다.
응답 직렬화
inline const char* status_text(int status) {
switch (status) {
case 200: return "OK";
case 201: return "Created";
case 204: return "No Content";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 404: return "Not Found";
case 413: return "Content Too Large";
case 500: return "Internal Server Error";
case 501: return "Not Implemented";
default: return "";
}
}
inline std::string to_http_response(const Response& res, bool keep_alive) {
std::string out = "HTTP/1.1 " + std::to_string(res.status) + " " + status_text(res.status) + "\r\n";
out += "Content-Type: " + res.content_type + "\r\n";
out += "Content-Length: " + std::to_string(res.body.size()) + "\r\n";
out += keep_alive ? "Connection: keep-alive\r\n" : "Connection: close\r\n";
for (const auto& [k, v] : res.headers) out += k + ": " + v + "\r\n";
out += "\r\n";
out += res.body;
return out;
}
상태 라인의 사유 문구(reason phrase)는 클라이언트가 해석하지 않으므로 모르는 코드는 빈 문자열로 보내도 됩니다. 413의 문구는 RFC 9110에서 “Payload Too Large”에서 “Content Too Large”로 바뀌었습니다. Connection 헤더는 서버가 실제로 할 동작과 일치해야 합니다. 응답에 keep-alive라고 써 놓고 연결을 닫으면 클라이언트는 연결 풀에 죽은 연결을 넣어 두었다가 다음 요청에서 에러를 만납니다.
세션: 헤더, 바디, keep-alive
세션 구현에서 가장 틀리기 쉬운 부분은 버퍼 관리입니다. TCP는 메시지 경계가 없는 바이트 스트림이라서, async_read_until이 \r\n\r\n을 찾았다고 알려 줄 때 버퍼에는 그 뒤의 바디 일부나 다음 요청(파이프라이닝)까지 이미 들어 있을 수 있습니다. 그래서 async_read_until이 돌려주는 길이(구분자까지의 바이트 수)만큼만 헤더로 소비하고, 바디는 Content-Length만큼만 소비하며, 나머지는 다음 요청을 위해 버퍼에 남겨 둬야 합니다. 요청을 처리한 뒤 buffer_.consume(buffer_.size())로 버퍼를 통째로 비우면 다음 요청의 앞부분이 사라집니다.
또 하나는 쓰기 버퍼의 수명입니다. asio::buffer(str)은 문자열을 복사하지 않고 포인터와 길이만 기억하므로, 지역 변수로 만든 응답 문자열을 넘기고 함수가 반환되면 쓰기가 끝나기 전에 메모리가 해제됩니다. 응답 문자열은 세션 멤버에 보관해야 합니다.
#include <boost/asio.hpp>
#include <memory>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
class HttpSession : public std::enable_shared_from_this<HttpSession> {
public:
HttpSession(tcp::socket socket, const App& app)
: socket_(std::move(socket)), buffer_(kMaxHeader + kMaxBody), app_(app) {}
void start() { read_header(); }
private:
static constexpr std::size_t kMaxHeader = 8 * 1024;
static constexpr std::size_t kMaxBody = 1024 * 1024;
void read_header() {
auto self = shared_from_this();
asio::async_read_until(socket_, buffer_, "\r\n\r\n",
[this, self](boost::system::error_code ec, std::size_t header_len) {
if (ec) return; // eof, connection_reset, 버퍼 한도 초과(not_found) → 연결 종료
req_ = Request{};
if (header_len > kMaxHeader || !parse_head(header_len)) {
return send(Response{400, "text/plain; charset=utf-8", "Bad Request"}, false);
}
if (req_.headers.count("transfer-encoding")) {
return send(Response{501, "text/plain; charset=utf-8", "chunked not supported"}, false);
}
std::size_t len = 0;
auto it = req_.headers.find("content-length");
if (it != req_.headers.end()) {
try { len = std::stoull(it->second); }
catch (const std::exception&) {
return send(Response{400, "text/plain; charset=utf-8", "Bad Content-Length"}, false);
}
}
if (len > kMaxBody) {
return send(Response{413, "text/plain; charset=utf-8", "Content Too Large"}, false);
}
read_body(len);
});
}
bool parse_head(std::size_t header_len) {
auto begin = asio::buffers_begin(buffer_.data());
std::string head(begin, begin + header_len);
buffer_.consume(header_len); // 헤더 부분만 소비
std::istringstream is(head);
std::string line;
if (!std::getline(is, line)) return false;
strip_cr(line);
if (!parse_request_line(line, req_)) return false;
while (std::getline(is, line)) {
strip_cr(line);
if (line.empty()) break;
if (!parse_header(line, req_)) return false;
}
return true;
}
void read_body(std::size_t len) {
if (buffer_.size() >= len) {
return handle_request(len);
}
auto self = shared_from_this();
asio::async_read(socket_, buffer_, asio::transfer_exactly(len - buffer_.size()),
[this, self, len](boost::system::error_code ec, std::size_t) {
if (ec) return;
handle_request(len);
});
}
void handle_request(std::size_t body_len) {
auto begin = asio::buffers_begin(buffer_.data());
req_.body.assign(begin, begin + body_len);
buffer_.consume(body_len); // 이 요청의 바디만 소비, 뒤따르는 요청은 남긴다
bool keep_alive = wants_keep_alive(req_);
Response res;
try {
res = app_.handle(req_);
} catch (const std::exception&) {
res = Response{500, "text/plain; charset=utf-8", "Internal Server Error"};
}
send(std::move(res), keep_alive);
}
static bool wants_keep_alive(const Request& req) {
auto it = req.headers.find("connection");
std::string conn = it == req.headers.end() ? "" : to_lower(it->second);
if (req.version == "HTTP/1.0") return conn == "keep-alive";
return conn != "close"; // HTTP/1.1은 기본이 keep-alive
}
void send(Response res, bool keep_alive) {
out_ = to_http_response(res, keep_alive); // 쓰기가 끝날 때까지 살아 있어야 한다
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(out_),
[this, self, keep_alive](boost::system::error_code ec, std::size_t) {
if (ec) return;
if (keep_alive) {
read_header();
} else {
boost::system::error_code ignored;
socket_.shutdown(tcp::socket::shutdown_both, ignored);
}
});
}
tcp::socket socket_;
asio::streambuf buffer_;
const App& app_;
Request req_;
std::string out_;
};
streambuf의 생성자 인자는 최대 크기입니다. 이 한도를 넘도록 구분자가 나오지 않으면 async_read_until이 asio::error::not_found로 끝나므로, 헤더를 끝없이 보내 메모리를 소진시키는 요청을 막을 수 있습니다. 바디도 같은 버퍼로 읽기 때문에 한도를 헤더와 바디 최대 크기의 합으로 잡았습니다.
모든 비동기 콜백은 shared_from_this()로 얻은 self를 캡처합니다. this만 캡처하면, 세션을 가리키는 마지막 shared_ptr가 사라진 뒤에 콜백이 실행될 때 이미 해제된 객체에 접근하게 됩니다. 반대로 self를 캡처하면 대기 중인 비동기 작업이 있는 동안 세션이 살아 있고, 에러로 콜백이 더 이상 작업을 걸지 않고 반환하면 참조가 모두 사라져 세션이 자연스럽게 소멸합니다.
Transfer-Encoding: chunked 요청은 바디 길이를 미리 알 수 없어 청크 단위 파서가 필요합니다. 최소 구현에서는 지원하지 않는다고 명시적으로 거절하는 편이, 헤더를 무시하고 바디가 없는 것처럼 처리하는 것보다 안전합니다. 무시하면 청크 데이터가 다음 요청으로 해석되어 요청 스머글링(request smuggling) 같은 문제로 이어질 수 있기 때문입니다.
서버 조립과 실행
#include <nlohmann/json.hpp>
void do_accept(tcp::acceptor& acceptor, const App& app) {
acceptor.async_accept([&acceptor, &app](boost::system::error_code ec, tcp::socket socket) {
if (!ec) std::make_shared<HttpSession>(std::move(socket), app)->start();
do_accept(acceptor, app);
});
}
int main() {
using json = nlohmann::json;
App app;
app.use(logging_mw);
app.use(cors_mw);
app.use(auth_mw);
app.router.get("/api/hello", [](const Request&) {
return Response{200, "application/json", R"({"msg":"hello"})"};
});
app.router.get("/api/users/:id", [](const Request& req) {
return Response{200, "application/json",
json{{"id", req.params.at("id")}}.dump()};
});
app.router.post("/api/users", [](const Request& req) {
auto j = json::parse(req.body, nullptr, false); // 예외 대신 discarded 값
if (j.is_discarded() || !j.contains("name") || !j["name"].is_string()) {
return Response{400, "application/json", R"({"error":"name required"})"};
}
return Response{201, "application/json",
json{{"id", 1}, {"name", j["name"]}}.dump()};
});
app.router.get("/health", [](const Request&) {
return Response{200, "application/json", R"({"status":"ok"})"};
});
asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
do_accept(acceptor, app);
std::cout << "listening on http://localhost:8080\n";
io.run();
}
경로 파라미터를 JSON 응답에 넣을 때 문자열을 직접 이어 붙이면, id에 따옴표가 들어 있는 경우 JSON이 깨집니다. 그래서 nlohmann/json이 이스케이프를 처리하도록 했습니다. json::parse의 세 번째 인자를 false로 주면 파싱 실패 시 예외 대신 discarded 값을 돌려주므로 핸들러 안에서 흐름이 단순해집니다.
mkdir build && cd build
cmake .. -DCMAKE_TOOLCHAIN_FILE=<vcpkg 경로>/scripts/buildsystems/vcpkg.cmake
cmake --build .
./http_server
curl -v http://localhost:8080/api/hello
curl http://localhost:8080/api/users/42
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" -d '{"name":"Bob"}'
curl -i http://localhost:8080/api/admin/stats # 401
curl -v로 같은 연결에 두 번 요청하면(curl -v URL1 URL2) 두 번째 요청이 “Re-using existing connection”으로 표시되는데, keep-alive 처리가 제대로 됐는지 확인하는 간단한 방법입니다.
운영 환경으로 가려면 더 필요한 것
위 코드는 요청 처리 흐름을 보여 주는 최소 구성이고, 실제로 노출하려면 몇 가지가 더 필요합니다.
읽기 타임아웃이 없으면 연결만 열어 두고 아무것도 보내지 않는 클라이언트가 세션을 무한정 붙잡습니다. 세션에 asio::steady_timer를 두고 읽기를 시작할 때 만료 시간을 설정한 뒤, 만료되면 소켓을 닫고, 읽기가 끝나면 타이머를 취소하는 방식이 일반적입니다.
void start_timeout() {
timer_.expires_after(std::chrono::seconds(30));
auto self = shared_from_this();
timer_.async_wait([this, self](boost::system::error_code ec) {
if (ec != asio::error::operation_aborted) { // 취소가 아닌 실제 만료
boost::system::error_code ignored;
socket_.close(ignored); // 대기 중인 읽기가 에러로 끝난다
}
});
}
io.run()을 여러 스레드에서 호출해 확장할 때는 공유 상태를 따져 봐야 합니다. 라우터와 미들웨어 목록은 서버 시작 전에만 수정하고 이후에는 읽기만 하므로 여러 스레드에서 동시에 읽어도 안전합니다. 세션 안에서는 읽기 콜백과 타이머 콜백이 서로 다른 스레드에서 동시에 실행될 수 있으므로, 연결마다 strand를 두어 한 세션의 콜백이 순서대로 실행되게 합니다. acceptor.async_accept(asio::make_strand(io), handler)처럼 받으면 새 소켓이 strand 실행자에 묶입니다. 예전 코드에서 보이는 io_context::strand 클래스는 최신 Asio에서 deprecated이므로 make_strand를 쓰는 편이 낫습니다. 이 주제는 고성능 네트워크 가이드 #3에서 자세히 다룹니다.
종료 처리도 생각해야 합니다. asio::signal_set으로 SIGINT/SIGTERM을 받아 acceptor.close()로 새 연결을 막는 것까지는 쉽지만, keep-alive 세션들이 다음 요청을 기다리며 읽기를 걸어 두고 있으므로 io.run()은 그대로 반환되지 않습니다. 살아 있는 세션을 목록으로 관리해 소켓을 닫거나, 일정 시간 기다린 뒤 io.stop()을 호출해야 합니다. 곧바로 io.stop()을 부르면 처리 중이던 응답이 중간에 끊깁니다.
그 밖에 동시 연결 수 제한, 요청 로그와 지연 시간 메트릭, TLS 종료(보통 앞단의 리버스 프록시에 맡깁니다)가 필요합니다. 이 목록이 길어지는 지점에서 라이브러리를 쓰는 편이 낫다는 판단이 서게 됩니다.
| 라이브러리 | 특징 | 적합한 경우 |
|---|---|---|
| Boost.Beast | Asio 기반의 저수준 HTTP/1.1·WebSocket 구현. 라우팅은 직접 작성 | 프로토콜 제어가 필요한 서버, 다른 Asio 코드와의 통합 |
| Crow | Express 스타일 라우팅, 단일 헤더 배포 가능 | 빠른 REST API 프로토타입 |
| Drogon | 비동기 프레임워크, ORM·템플릿 포함 | C++로 웹 애플리케이션 전체를 만들 때 |
| 직접 구현 | 의존성 최소, 동작을 완전히 통제 | 임베디드, 학습, 특수 프로토콜 혼합 |
Beast는 HTTP/2를 지원하지 않습니다. C++에서 HTTP/2가 필요하다면 nghttp2 기반 라이브러리를 쓰거나 앞단 프록시에서 HTTP/2를 종료하는 구성이 일반적입니다.
같이 보면 좋은 글
- Boost.Beast로 REST API 서버 만들기
- C++에서 HTTP 제대로 파싱하기
- C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]
- C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
자주 묻는 질문 (FAQ)
Q. Keep-Alive 연결에서 두 번째 요청부터 파싱이 깨지는 이유는 무엇인가요?
A. TCP는 메시지 경계가 없는 바이트 스트림이라서, 한 번의 읽기에 이전 요청의 바디 끝과 다음 요청의 앞부분이 함께 들어올 수 있습니다. 요청 하나를 처리한 뒤 버퍼를 통째로 비우면 다음 요청의 일부가 사라지고, 반대로 처리한 부분을 지우지 않으면 이전 데이터를 다시 파싱하게 됩니다. Content-Length로 요청의 끝을 정확히 계산해 소비한 바이트만 버퍼에서 제거하고, 남은 바이트는 다음 요청 파싱의 시작점으로 넘겨야 합니다.
Q. Beast와 직접 구현의 차이는 무엇인가요?
A. Beast는 HTTP/1.1 메시지 파싱·직렬화(chunked 인코딩 포함)와 WebSocket을 표준에 맞게 구현한 저수준 라이브러리이고, 라우팅이나 미들웨어 같은 프레임워크 기능은 제공하지 않습니다. 이 글의 구현은 파서까지 직접 만든 최소 버전이므로, 실제 서비스라면 파서는 Beast에 맡기고 그 위에 라우터와 미들웨어만 올리는 구성이 현실적인 절충입니다.