C++로 REST API 서버 만들기: 라우터·미들웨어 체인·JWT 인증·요청 검증
들어가며: “Express.js처럼 쉬운 C++ REST API 서버를 만들고 싶다”
REST API 서버의 핵심
Node.js Express나 Python Flask처럼 간결한 라우팅, 미들웨어 체인, 자동 문서화를 갖춘 서버를 C++로 직접 만들어 봅니다. Boost.Beast 위에 GET/POST/PUT/DELETE 라우터를 올리고, 로깅·CORS·JWT 인증 미들웨어 체인, JSON 요청/응답 처리, 요청 검증과 에러 핸들러, Swagger/OpenAPI 문서 생성을 차례로 구현합니다.
요구 환경: C++17 이상, Boost.Beast, nlohmann/json
C++로 REST API 서버를 만드는 상황
고성능 백엔드 API
Node.js나 Python은 편하지만, 초당 수만 요청을 처리해야 하는 게임 서버, 금융 거래 API, 실시간 데이터 피드에서는 지연 시간이 병목이 됩니다. C++로 REST API를 직접 구현하면 런타임 오버헤드를 줄이고 지연 시간을 세밀하게 제어할 수 있습니다. 다만 대부분의 API 서버에서 지연의 대부분은 DB와 외부 호출에서 생기므로, 언어를 바꾸기 전에 실제 병목이 어디인지부터 측정하는 편이 좋습니다.
기존 C++ 시스템에 HTTP API 추가
이미 C++로 작성된 게임 엔진, 트레이딩 시스템, IoT 게이트웨이가 있는데 웹/모바일 클라이언트가 연동해야 하는 경우입니다. 별도 Node.js 서버를 두면 프로세스 간 통신 오버헤드와 배포 단위가 하나 더 생깁니다. C++ 프로세스 내에서 직접 REST API를 제공하면 통합이 단순해집니다. 대신 HTTP 처리 스레드의 문제(느린 클라이언트, 과도한 요청)가 핵심 로직과 같은 프로세스에 영향을 줄 수 있으므로, I/O 스레드와 핵심 로직 스레드를 분리해 두는 설계가 필요합니다.
리소스 제약 환경
임베디드·엣지 서버에서 메모리 제한이 엄격합니다. Node.js 런타임은 수십 MB 이상을 차지합니다. C++로 직접 구현한 경량 REST 서버는 메가바이트 단위로 동작하며, 의존성을 최소화할 수 있습니다.
라우팅·미들웨어 패턴 학습
Express의 app.get(), app.use(), next() 같은 패턴이 어떻게 동작하는지 이해하려면 직접 구현해 보는 것이 가장 효과적입니다. C++로 구현하면 메모리와 런타임 동작을 완전히 제어할 수 있습니다.
Swagger 문서 자동 생성
API가 많아질수록 수동 문서가 뒤쳐집니다. 코드와 동기화된 OpenAPI 문서를 자동 생성하면, 프론트엔드·모바일 팀과 협업이 수월해집니다.
“브라우저에서 CORS 에러가 발생해요”
프론트엔드(React, Vue)에서 http://localhost:3000으로 개발 중인데, API 서버가 http://localhost:8080이면 브라우저가 cross-origin 요청을 차단합니다. Access to fetch has been blocked by CORS policy 에러가 발생합니다. 해결: CORS 미들웨어에서 Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers를 설정하며, OPTIONS preflight 요청을 204로 즉시 응답합니다.
“POST 요청 body가 비어 있어요”
req.json_body()를 호출했는데 빈 객체 {}가 반환됩니다. 원인: Content-Type: application/json 헤더 없이 전송했거나, body 파싱 전에 이미 응답을 보냈을 수 있습니다. 해결: Content-Type 검사 후 파싱하며, 파싱 실패 시 400 Bad Request를 반환합니다.
“인증 없이 보호된 API에 접근해도 200이 나와요”
/users 엔드포인트에 인증 미들웨어를 등록했는데, Authorization 헤더 없이 요청해도 200 OK가 반환됩니다. 원인: 미들웨어 등록 순서 오류 또는 라우트별 미들웨어가 전역 미들웨어보다 나중에 적용되지 않음. 해결: 인증 미들웨어를 해당 라우트에 명시적으로 바인딩하며, 인증 실패 시 next()를 호출하지 않고 즉시 return합니다.
Express 스타일 라우터 설계
아키텍처 다이어그램
flowchart TB
subgraph Client[클라이언트]
C1[HTTP 요청]
end
subgraph Server[서버]
subgraph Middleware[미들웨어 체인]
M1[로깅]
M2[CORS]
M3[인증]
M4[에러 핸들러]
end
subgraph Router[라우터]
R1["GET /users"]
R2["POST /users"]
R3["GET /users/:id"]
end
M1 --> M2 --> M3 --> M4 --> Router
end
C1 --> Client
Client --> Server
Express 스타일 API
// 사용 예시
RestServer server;
server.get("/users", [](const Request& req, Response& res) {
res.json({{"users", get_all_users()}});
});
server.post("/users", [](const Request& req, Response& res) {
auto user = req.body<User>();
auto id = create_user(user);
res.status(201).json({{"id", id}});
});
server.get("/users/:id", [](const Request& req, Response& res) {
auto id = req.params("id");
auto user = get_user(id);
if (!user) {
res.status(404).json({{"error", "User not found"}});
return;
}
res.json(*user);
});
server.listen(8080);
Router 구현
라우터의 핵심은 /users/:id 같은 패턴을 정규식 ^/users/([^/]+)$로 바꿔 두었다가 요청 경로와 맞춰 보는 것입니다. 정규식은 등록 시점에 한 번만 컴파일하고, 요청마다 routes_를 순서대로 훑어 처음 매칭된 라우트를 씁니다. 이 “첫 매칭 우선” 규칙 때문에 등록 순서가 의미를 가집니다. /users/:id를 /users/me보다 먼저 등록하면 /users/me 요청이 id = "me"로 앞의 라우트에 잡히므로, 고정 경로를 파라미터 경로보다 먼저 등록해야 합니다.
class Router {
public:
using Handler = std::function<void(const Request&, Response&)>;
using Middleware = std::function<void(const Request&, Response&, std::function<void()>)>;
private:
struct Route {
std::string method;
std::regex path_regex;
std::vector<std::string> param_names;
Handler handler;
std::vector<Middleware> middlewares;
};
std::vector<Route> routes_;
std::vector<Middleware> global_middlewares_;
public:
void get(const std::string& path, Handler handler) {
add_route("GET", path, handler);
}
void post(const std::string& path, Handler handler) {
add_route("POST", path, handler);
}
void put(const std::string& path, Handler handler) {
add_route("PUT", path, handler);
}
void del(const std::string& path, Handler handler) {
add_route("DELETE", path, handler);
}
void use(Middleware middleware) {
global_middlewares_.push_back(middleware);
}
private:
void add_route(const std::string& method, const std::string& path, Handler handler) {
Route route;
route.method = method;
route.handler = handler;
// 경로 파라미터 파싱: /users/:id -> /users/([^/]+)
std::string regex_path = path;
std::regex param_regex(R"(:([a-zA-Z_][a-zA-Z0-9_]*))");
std::smatch match;
while (std::regex_search(regex_path, match, param_regex)) {
route.param_names.push_back(match[1]);
regex_path = match.prefix().str() + "([^/]+)" + match.suffix().str();
}
route.path_regex = std::regex("^" + regex_path + "$");
routes_.push_back(route);
}
public:
std::optional<Route> match(const std::string& method, const std::string& path) {
for (auto& route : routes_) {
if (route.method != method) continue;
std::smatch match;
if (std::regex_match(path, match, route.path_regex)) {
// 파라미터 추출
for (size_t i = 0; i < route.param_names.size(); ++i) {
// match[i+1]을 route에 저장
}
return route;
}
}
return std::nullopt;
}
};
match()의 파라미터 추출 부분은 골격만 남겨 두었습니다. 실제로는 match[i + 1]의 값을 route.param_names[i]를 키로 Request의 파라미터 맵에 넣어야 핸들러에서 req.param("id")로 꺼낼 수 있습니다. 또 이 코드는 std::optional<Route>로 라우트를 복사해서 돌려주므로, 요청마다 std::regex 객체와 핸들러 std::function, 미들웨어 벡터가 통째로 복사됩니다. 운영 코드에서는 const Route*를 돌려주는 편이 맞습니다. std::regex 자체도 표준 구현들이 느리기로 유명해서, 라우트가 수십 개를 넘으면 매 요청 선형 탐색 비용이 눈에 띕니다. 경로를 / 기준으로 나눠 트리(radix tree)로 찾는 방식이 대부분의 웹 프레임워크가 쓰는 해법이며, 메서드가 다르면 405(Method Not Allowed)를, 경로 자체가 없으면 404를 구분해 돌려주는 것도 트리 구조에서 자연스럽게 됩니다.
로깅·CORS 미들웨어 체인
미들웨어 패턴
sequenceDiagram
participant C as 클라이언트
participant M1 as 로깅
participant M2 as CORS
participant M3 as 인증
participant H as 핸들러
C->>M1: 요청
M1->>M2: next()
M2->>M3: next()
M3->>H: next()
H-->>M3: 응답
M3-->>M2: 반환
M2-->>M1: 반환
M1-->>C: 응답
class MiddlewareChain {
std::vector<Router::Middleware> middlewares_;
Router::Handler final_handler_;
public:
void add(Router::Middleware middleware) {
middlewares_.push_back(middleware);
}
void execute(const Request& req, Response& res) {
execute_at(0, req, res);
}
private:
void execute_at(size_t index, const Request& req, Response& res) {
if (index >= middlewares_.size()) {
final_handler_(req, res);
return;
}
middlewares_[index](req, res, [this, index, &req, &res]() {
execute_at(index + 1, req, res);
});
}
};
이 체인은 next 람다가 다음 단계를 바로 호출하는 재귀 구조입니다. 그래서 next()가 반환되는 시점에는 뒤의 미들웨어와 핸들러가 이미 모두 끝나 응답이 만들어져 있고, 각 미들웨어는 next() 앞에서 요청 전처리를, 뒤에서 응답 후처리를 할 수 있습니다(Express보다는 Koa의 “양파” 모델에 가깝습니다). 이 구조의 전제는 모든 처리가 동기라는 것입니다. 핸들러가 DB를 비동기로 호출하고 콜백에서 응답을 보내도록 바꾸면, next()는 응답이 만들어지기 전에 돌아오고 로깅 미들웨어는 아직 정해지지 않은 상태 코드를 찍게 됩니다. 또 캡처한 &req, &res가 비동기 콜백 시점까지 살아 있다는 보장도 없어집니다. 비동기로 확장할 계획이라면 요청·응답 객체를 shared_ptr로 관리하고, 체인 끝에서 “응답 완료” 콜백을 받는 구조로 바꿔야 합니다.
로깅 미들웨어
auto logging_middleware = [](const Request& req, Response& res, std::function<void()> next) {
auto start = std::chrono::steady_clock::now();
std::cout << req.method() << " " << req.path() << std::endl;
next(); // 다음 미들웨어 실행
auto end = std::chrono::steady_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << " -> " << res.status_code()
<< " (" << duration.count() << "ms)" << std::endl;
};
server.use(logging_middleware);
CORS 미들웨어
auto cors_middleware = [](const Request& req, Response& res, std::function<void()> next) {
res.set_header("Access-Control-Allow-Origin", "*");
res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method() == "OPTIONS") {
res.status(204).send();
return;
}
next();
};
server.use(cors_middleware);
Access-Control-Allow-Origin: *는 개발용으로 편하지만 두 가지 제약이 있습니다. 첫째, 브라우저가 쿠키를 함께 보내는 요청(fetch의 credentials: "include")에는 *가 허용되지 않아 The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include' 에러가 납니다. 이 경우 요청의 Origin 헤더 값을 허용 목록과 비교해 그대로 돌려주고 Access-Control-Allow-Credentials: true를 함께 보내야 하며, 응답이 Origin마다 달라지므로 캐시가 섞이지 않도록 Vary: Origin도 붙여야 합니다. 둘째, Access-Control-Allow-Headers: *로 헤더를 모두 허용하려 해도 Authorization 헤더는 와일드카드에 포함되지 않으므로 위 코드처럼 이름을 명시해야 합니다. preflight 응답은 매 요청마다 오가면 지연이 늘어나므로 Access-Control-Max-Age로 브라우저가 결과를 캐시하게 하는 것도 좋습니다.
Request·Response 클래스
Request 클래스
class Request {
beast::http::request<beast::http::string_body> req_;
std::unordered_map<std::string, std::string> params_;
std::unordered_map<std::string, std::string> query_;
json body_json_;
public:
Request(beast::http::request<beast::http::string_body> req)
: req_(std::move(req)) {
parse_query();
parse_body();
}
std::string method() const {
return std::string(req_.method_string());
}
std::string path() const {
auto target = req_.target();
auto pos = target.find('?');
return std::string(target.substr(0, pos));
}
std::string param(const std::string& name) const {
auto it = params_.find(name);
return it != params_.end() ? it->second : "";
}
std::string query(const std::string& name) const {
auto it = query_.find(name);
return it != query_.end() ? it->second : "";
}
template<typename T>
T body() const {
return body_json_.get<T>();
}
const json& json_body() const {
return body_json_;
}
std::string header(const std::string& name) const {
return std::string(req_[name]);
}
private:
void parse_query() {
auto target = req_.target();
auto pos = target.find('?');
if (pos == std::string_view::npos) return;
auto query_string = target.substr(pos + 1);
// query_string 파싱: key1=value1&key2=value2
// ... 구현 생략
}
void parse_body() {
if (req_.body().empty()) return;
try {
body_json_ = json::parse(req_.body());
} catch (const json::exception&) {
// JSON 파싱 실패
}
}
};
Response 클래스
class Response {
beast::http::response<beast::http::string_body> res_;
bool sent_ = false;
public:
Response& status(unsigned code) {
res_.result(code);
return *this;
}
Response& set_header(const std::string& name, const std::string& value) {
res_.set(name, value);
return *this;
}
void json(const ::json& data) {
res_.set(beast::http::field::content_type, "application/json");
res_.body() = data.dump();
res_.prepare_payload();
sent_ = true;
}
void send(const std::string& body = "") {
res_.body() = body;
res_.prepare_payload();
sent_ = true;
}
void send_file(const std::string& path) {
std::ifstream file(path, std::ios::binary);
if (!file) {
status(404).send("File not found");
return;
}
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
// MIME 타입 설정
auto ext = std::filesystem::path(path).extension().string();
set_header("Content-Type", get_mime_type(ext));
send(content);
}
unsigned status_code() const {
return res_.result_int();
}
const auto& native() const { return res_; }
};
JWT 인증 미들웨어
Bearer 토큰 검증
class JWTAuth {
std::string secret_;
public:
JWTAuth(const std::string& secret) : secret_(secret) {}
std::string generate(const std::string& user_id) {
return jwt::create()
.set_issuer("my-api")
.set_type("JWT")
.set_payload_claim("user_id", jwt::claim(user_id))
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24))
.sign(jwt::algorithm::hs256{secret_});
}
std::optional<std::string> verify(const std::string& token) {
try {
auto decoded = jwt::decode(token);
auto verifier = jwt::verify()
.allow_algorithm(jwt::algorithm::hs256{secret_})
.with_issuer("my-api");
verifier.verify(decoded);
return decoded.get_payload_claim("user_id").as_string();
} catch (const std::exception&) {
return std::nullopt;
}
}
};
// 인증 미들웨어
auto auth_middleware(JWTAuth& jwt_auth) {
return [&jwt_auth](const Request& req, Response& res, auto next) {
auto auth_header = req.header("Authorization");
if (auth_header.empty() || !auth_header.starts_with("Bearer ")) {
res.status(401).json({{"error", "Unauthorized"}});
return;
}
auto token = auth_header.substr(7); // "Bearer " 제거
auto user_id = jwt_auth.verify(token);
if (!user_id) {
res.status(401).json({{"error", "Invalid token"}});
return;
}
// req에 user_id 저장 (const_cast 필요)
const_cast<Request&>(req).set_user_id(*user_id);
next();
};
}
// 사용 예시
server.get("/profile", auth_middleware(jwt_auth),
[](const Request& req, Response& res) {
auto user_id = req.user_id();
auto profile = get_user_profile(user_id);
res.json(profile);
});
요청 검증과 전역 에러 핸들러
요청 검증
class Validator {
public:
struct Rule {
std::string field;
std::function<bool(const json&)> check;
std::string message;
};
std::vector<Rule> rules_;
Validator& required(const std::string& field) {
rules_.push_back({
field,
[field](const json& data) { return data.contains(field); },
field + " is required"
});
return *this;
}
Validator& string(const std::string& field) {
rules_.push_back({
field,
[field](const json& data) {
return data.contains(field) && data[field].is_string();
},
field + " must be a string"
});
return *this;
}
Validator& min_length(const std::string& field, size_t len) {
rules_.push_back({
field,
[field, len](const json& data) {
return data.contains(field) &&
data[field].is_string() &&
data[field].get<std::string>().length() >= len;
},
field + " must be at least " + std::to_string(len) + " characters"
});
return *this;
}
std::optional<std::vector<std::string>> validate(const json& data) {
std::vector<std::string> errors;
for (const auto& rule : rules_) {
if (!rule.check(data)) {
errors.push_back(rule.message);
}
}
return errors.empty() ? std::nullopt : std::make_optional(errors);
}
};
// 사용 예시
server.post("/users", [](const Request& req, Response& res) {
Validator validator;
validator.required("email")
.string("email")
.required("password")
.min_length("password", 8);
auto errors = validator.validate(req.json_body());
if (errors) {
res.status(400).json({{"errors", *errors}});
return;
}
// 검증 통과
auto user = create_user(req.body<User>());
res.status(201).json(user);
});
전역 에러 핸들러
class ErrorHandler {
public:
static void handle(const std::exception& e, Response& res) {
if (auto* ve = dynamic_cast<const ValidationError*>(&e)) {
res.status(400).json({
{"error", "Validation failed"},
{"details", ve->errors()}
});
}
else if (auto* ne = dynamic_cast<const NotFoundException*>(&e)) {
res.status(404).json({
{"error", ne->what()}
});
}
else if (auto* ae = dynamic_cast<const AuthError*>(&e)) {
res.status(401).json({
{"error", ae->what()}
});
}
else {
res.status(500).json({
{"error", "Internal server error"},
{"message", e.what()}
});
}
}
};
// 에러 핸들링 미들웨어
auto error_handler_middleware = [](const Request& req, Response& res, std::function<void()> next) {
try {
next();
} catch (const std::exception& e) {
ErrorHandler::handle(e, res);
}
};
server.use(error_handler_middleware);
Swagger 문서 생성
API 문서 정의
struct APIDoc {
std::string path;
std::string method;
std::string summary;
std::string description;
json parameters;
json request_body;
json responses;
};
class SwaggerGenerator {
std::vector<APIDoc> docs_;
public:
void add_doc(const APIDoc& doc) {
docs_.push_back(doc);
}
json generate() {
json swagger = {
{"openapi", "3.0.0"},
{"info", {
{"title", "My API"},
{"version", "1.0.0"}
}},
{"paths", json::object()}
};
for (const auto& doc : docs_) {
if (!swagger["paths"].contains(doc.path)) {
swagger["paths"][doc.path] = json::object();
}
swagger["paths"][doc.path][doc.method] = {
{"summary", doc.summary},
{"description", doc.description},
{"parameters", doc.parameters},
{"requestBody", doc.request_body},
{"responses", doc.responses}
};
}
return swagger;
}
};
// 사용 예시
SwaggerGenerator swagger;
swagger.add_doc({
"/users",
"post",
"Create user",
"Create a new user account",
json::array(),
{
{"content", {
{"application/json", {
{"schema", {
{"type", "object"},
{"properties", {
{"email", {{"type", "string"}}},
{"password", {{"type", "string"}}}
}},
{"required", {"email", "password"}}
}}
}}
}}
},
{
{"201", {
{"description", "User created"},
{"content", {
{"application/json", {
{"schema", {
{"type", "object"},
{"properties", {
{"id", {{"type", "string"}}},
{"email", {{"type", "string"}}}
}}
}}
}}
}}
}},
{"400", {{"description", "Validation error"}}}
}
});
// Swagger UI 엔드포인트
server.get("/api-docs", [&swagger](const Request& req, Response& res) {
res.json(swagger.generate());
});
사용자 관리 API 전체 예시와 cURL 테스트
요청 흐름 (미들웨어 → 라우트)
sequenceDiagram
participant C as 클라이언트
participant L as 로깅
participant CO as CORS
participant A as 인증
participant H as 핸들러
C->>L: GET /users
L->>CO: next()
CO->>A: next()
A->>A: JWT 검증
alt 인증 성공
A->>H: next()
H-->>C: 200 JSON
else 인증 실패
A-->>C: 401 Unauthorized
end
사용자 관리 API 전체 예시 (라우팅·미들웨어·CORS·JSON·인증)
// main.cpp - 사용자 CRUD API 완전 예시
int main() {
RestServer server;
JWTAuth jwt_auth("my-secret-key-change-in-production");
SwaggerGenerator swagger;
server.use(logging_middleware);
server.use(cors_middleware);
server.use(error_handler_middleware);
// 공개: 로그인, 회원가입
server.post("/auth/login", [&jwt_auth](const Request& req, Response& res) {
auto body = req.json_body();
auto user = authenticate(body["email"], body["password"]);
if (!user) { res.status(401).json({{"error", "Invalid credentials"}}); return; }
res.json({{"token", jwt_auth.generate(user->id)}, {"user", *user}});
});
server.post("/auth/register", [](const Request& req, Response& res) {
Validator v; v.required("email").required("password").min_length("password", 8);
auto err = v.validate(req.json_body());
if (err) { res.status(400).json({{"errors", *err}}); return; }
auto u = create_user(req.json_body());
res.status(201).json({{"id", u.id}, {"email", u.email}});
});
// 인증 필요: CRUD
server.get("/users", auth_middleware(jwt_auth), [](const Request& req, Response& res) {
res.json({{"users", get_users_paginated(req.query("page"), req.query("limit"))}});
});
server.get("/users/:id", auth_middleware(jwt_auth), [](const Request& req, Response& res) {
auto u = get_user(req.param("id"));
u ? res.json(*u) : res.status(404).json({{"error", "Not found"}});
});
server.put("/users/:id", auth_middleware(jwt_auth), [](const Request& req, Response& res) {
if (req.user_id() != req.param("id")) { res.status(403).send(); return; }
res.json(update_user(req.param("id"), req.json_body()));
});
server.del("/users/:id", auth_middleware(jwt_auth), [](const Request& req, Response& res) {
if (req.user_id() != req.param("id")) { res.status(403).send(); return; }
delete_user(req.param("id")) ? res.status(204).send() : res.status(404).send();
});
server.get("/api-docs", [&swagger](auto&, auto& res) { res.json(swagger.generate()); });
server.listen(8080);
return 0;
}
cURL로 전체 API 테스트
# 1. 회원가입
curl -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"secret1234","name":"홍길동"}'
# 2. 로그인
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"secret1234"}'
# 응답: {"token":"eyJ...", "user":{"id":"...","email":"..."}}
# 3. 사용자 목록 (인증 필요)
curl -X GET http://localhost:8080/users?page=1&limit=10 \
-H "Authorization: Bearer eyJ..."
# 4. 사용자 상세
curl -X GET http://localhost:8080/users/123 \
-H "Authorization: Bearer eyJ..."
# 5. 사용자 수정
curl -X PUT http://localhost:8080/users/123 \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{"name":"홍길동2","email":"[email protected]"}'
# 6. 사용자 삭제
curl -X DELETE http://localhost:8080/users/123 \
-H "Authorization: Bearer eyJ..."
CORS preflight, next() 누락, JWT 만료: 에러 해결
”Connection reset” / 요청 본문 과대
원인: 요청 본문이 너무 크거나 클라이언트 조기 종료. 해결: Beast parser.body_limit(1024*1024) 설정 후 초과 시 res.status(413).json({{"error","Request entity too large"}}) 반환.
JSON 파싱 실패 (400)
원인: Content-Type이 application/json이 아닌데 파싱 시도, 또는 잘못된 JSON. 해결: Content-Type 검사 후 json::parse() 예외 처리 시 BadRequestException throw.
JWT 토큰 만료 (401)
원인: exp 클레임 만료. 해결: POST /auth/refresh 엔드포인트로 리프레시 토큰 교환 후 새 액세스 토큰 발급.
CORS preflight 실패
원인: OPTIONS 요청 미처리 또는 Access-Control-Allow-* 헤더 누락. 해결:
if (req.method() == "OPTIONS") {
res.set_header("Access-Control-Allow-Origin", "*");
res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.status(204).send();
return; // next() 호출하지 않음!
}
next() 호출 누락
원인: 미들웨어에서 인증 성공 시 next()를 호출하지 않음. 해결: 인증 실패 시 res.status(401).json(...) 후 return, 성공 시 반드시 next() 호출.
// ✅ 올바른 패턴
if (!user_id) { res.status(401).json({{"error","Unauthorized"}}); return; }
const_cast<Request&>(req).set_user_id(*user_id);
next(); // 필수!
경로 파라미터 매칭 실패
원인: :id → ([^/]+) 변환 시 특수문자 이스케이프 누락. 해결: std::regex_replace로 :param을 ([^/]+)로 변환 후 ^...$로 전체 매칭.
const_cast로 인증 정보를 넣는 설계
원인: 미들웨어 시그니처가 const Request&라서 인증된 사용자 ID를 넣으려고 const_cast를 씁니다. 요청 객체가 원래 const로 만들어졌다면 이 수정은 정의되지 않은 동작이고, 그렇지 않더라도 “읽기 전용”이라는 시그니처의 약속을 깨므로 다른 미들웨어가 요청이 바뀌지 않는다고 가정한 코드가 틀어집니다. 요청 객체를 여러 스레드가 공유하는 구조(예: 요청을 캐시에 보관하거나 비동기 작업에 참조로 넘기는 경우)에서는 데이터 경쟁으로도 이어집니다. 해결: 요청별 RequestContext 구조체를 따로 두고 미들웨어가 Context&로 받아 인증 결과를 기록하게 합니다.
404 핸들러 누락
원인: 매칭 라우트 없을 때 미처리. 해결: router.match()가 nullopt일 때 res.status(404).json({{"error","Not found"}}) 반환.
미들웨어 순서·응답 형식·API 버저닝 원칙
미들웨어 등록 순서
미들웨어는 등록 순서대로 실행됩니다. 로깅 → CORS → 에러 핸들러를 먼저 등록하며, 인증은 라우트별로 적용합니다.
server.use(logging_middleware);
server.use(cors_middleware);
server.use(error_handler_middleware);
server.get("/users", auth_middleware(jwt_auth), handler);
JSON 응답 형식 통일
성공/실패 모두 data 또는 error 키로 통일하면 클라이언트 파싱이 단순해집니다. request_id를 포함하면 디버깅이 용이합니다.
HTTP 메서드와 상태 코드 준수
| 작업 | 메서드 | 성공 | 실패 |
|---|---|---|---|
| 생성 | POST | 201 | 400, 409 |
| 조회 | GET | 200 | 404 |
| 수정 | PUT/PATCH | 200 | 400, 404 |
| 삭제 | DELETE | 204 | 404 |
환경 변수로 설정 분리
JWT 시크릿 등은 절대 하드코딩 금지. std::getenv("JWT_SECRET") 사용.
비동기 I/O와 스레드 모델
io_context를 std::thread::hardware_concurrency()개 스레드에서 run()하는 패턴 사용.
API 버저닝
URL 경로에 버전 포함: /v1/users, /v2/users.
JSON 직렬화·Keep-Alive·정규식 캐싱으로 성능 올리기
JSON 직렬화 최적화
// JSON dump 시 불필요한 공백 제거 (프로덕션)
void json_response(const json& data) {
res_.set(beast::http::field::content_type, "application/json");
res_.body() = data.dump(-1); // -1: 들여쓰기 없음, 최소 크기
res_.prepare_payload();
}
연결 풀링 (Keep-Alive)
// HTTP Keep-Alive로 연결 재사용
void prepare_response() {
res_.set(beast::http::field::connection, "keep-alive");
res_.set(beast::http::field::keep_alive, "timeout=60");
}
응답 버퍼 사전 할당
// 큰 응답 시 버퍼 미리 예약
void json(const json& data) {
std::string body = data.dump(-1);
res_.body().reserve(body.size() + 256); // 헤더 여유
res_.body() = std::move(body);
res_.prepare_payload();
}
정규표현식 캐싱
// 라우트 매칭 시 정규표현식은 컴파일 비용이 큼
// 경로를 컴파일 타임에 생성해 두기
class Router {
std::unordered_map<std::string, std::regex> path_cache_;
std::regex get_or_compile(const std::string& path) {
auto it = path_cache_.find(path);
if (it != path_cache_.end()) return it->second;
auto compiled = compile_path_to_regex(path);
path_cache_[path] = compiled;
return compiled;
}
};
비동기 I/O 활용
// 동기 I/O 대신 Boost.Beast async 사용
void handle_request() {
http::async_read(socket_, buffer_, req_,
[this](beast::error_code ec, std::size_t) {
if (!ec) {
process_request();
}
});
}
성능을 측정하는 방법
처리량과 지연은 핸들러가 하는 일, 응답 크기, Keep-Alive 여부, 하드웨어에 따라 몇 배씩 달라지므로 다른 환경의 숫자보다 자기 서버로 직접 재는 것이 중요합니다. wrk -t4 -c100 -d30s http://localhost:8080/health처럼 부하 도구로 먼저 아무 일도 하지 않는 엔드포인트의 한계를 재 두면 프레임워크 자체의 오버헤드를 알 수 있고, 그다음 실제 엔드포인트와 비교하면 비용이 라우팅·JSON·DB 중 어디에 있는지 구분됩니다. 평균 지연보다 p99 지연을 봐야 하며, 동기 I/O 서버는 동시 연결 수가 스레드 수를 넘는 순간 p99가 급격히 나빠지는 모습을 보이는 것이 일반적입니다. 로깅 미들웨어가 요청마다 std::cout에 std::endl로 쓰는 것처럼 사소해 보이는 코드가 처리량을 크게 깎는 경우도 흔하므로, 프로파일러로 확인하는 습관이 필요합니다.
요청 ID 추적·속도 제한·구조화 로깅
헬스 체크 엔드포인트
// Kubernetes / 로드밸런서용
server.get("/health", [](const Request& req, Response& res) {
res.json({{"status", "ok"}, {"version", "1.0.0"}, {"timestamp", std::time(nullptr)}});
});
server.get("/health/ready", [](const Request& req, Response& res) {
if (!check_database_connection()) res.status(503).json({{"status", "unhealthy"}});
else res.json({{"status", "ready"}});
});
요청 ID 추적
auto request_id_middleware = [](const Request& req, Response& res, std::function<void()> next) {
auto req_id = req.header("X-Request-ID").empty() ? generate_uuid() : req.header("X-Request-ID");
res.set_header("X-Request-ID", req_id);
const_cast<Request&>(req).set_request_id(req_id);
next();
};
속도 제한 (Rate Limiting)
// 고정 윈도우 방식: client_id당 분당 100회 (단일 스레드 가정)
class RateLimiter {
std::unordered_map<std::string, std::pair<int, std::chrono::steady_clock::time_point>> limits_;
int max_ = 100;
std::chrono::seconds window_{60};
public:
bool allow(const std::string& client_id) {
auto now = std::chrono::steady_clock::now();
auto& [count, start] = limits_[client_id];
if (now - start > window_) { count = 0; start = now; }
return count++ < max_;
}
};
// 429 Too Many Requests 반환
이 구현은 “1분 창 안에서 횟수를 세는” 고정 윈도우 방식이라, 창이 바뀌는 경계 직전과 직후에 몰아서 보내면 짧은 시간에 한도의 두 배까지 통과합니다. 버스트를 부드럽게 제한하려면 시간에 따라 토큰이 채워지는 토큰 버킷이나 슬라이딩 윈도우를 씁니다. 또 여러 I/O 스레드가 동시에 allow()를 호출하면 unordered_map에 대한 데이터 경쟁이 되므로 뮤텍스나 샤딩이 필요하고, 한 번 본 클라이언트 ID가 영원히 맵에 남으므로 오래된 항목을 주기적으로 지워야 메모리가 새지 않습니다. 서버가 여러 대라면 인스턴스별 카운터로는 전체 한도를 지킬 수 없어 Redis 같은 공유 저장소로 옮기게 됩니다. 429 응답에는 Retry-After 헤더를 넣어 클라이언트가 언제 다시 시도할지 알려 주는 것이 좋습니다.
구조화된 로깅
// JSON 형식 로그 (ELK/CloudWatch 수집용)
void log_request(const Request& req, const Response& res, auto duration) {
std::cerr << json{{"method", req.method()}, {"path", req.path()},
{"status", res.status_code()}, {"duration_ms", duration.count()},
{"request_id", req.request_id()}}.dump() << std::endl;
}
Graceful Shutdown
// 시그널 핸들러는 일반 함수 포인터만 받으므로 캡처하는 람다를 쓸 수 없음
std::atomic<bool> g_running{true};
void on_sigterm(int) { g_running = false; }
int main() {
std::signal(SIGTERM, on_sigterm);
while (g_running) server.poll();
server.stop();
}
원래 형태처럼 [&](int) { ... }로 캡처하는 람다를 std::signal에 넘기면 함수 포인터로 변환되지 않아 컴파일 에러가 납니다. 시그널 핸들러 안에서는 락 획득, 메모리 할당, std::cout 같은 대부분의 작업이 안전하지 않고, lock-free std::atomic에 값을 쓰는 정도만 허용된다고 보는 것이 안전합니다. Boost.Asio를 쓴다면 boost::asio::signal_set으로 SIGTERM을 받아 io_context 안의 일반 핸들러에서 새 연결 수락을 멈추고, 진행 중인 요청이 끝날 때까지 기다린 뒤 종료하는 편이 깔끔합니다. Kubernetes는 SIGTERM 뒤 기본 30초 후 SIGKILL을 보내므로, 그 안에 정리를 마치도록 요청 타임아웃을 맞춰 둡니다.
배포 전 보안·운영 점검표
- [ ] JWT 시크릿을 환경 변수로 분리 (절대 하드코딩 금지)
- [ ] CORS Allow-Origin을 "*" 대신 허용 도메인으로 제한
- [ ] HTTPS 전용 (HTTP 리다이렉트)
- [ ] 요청 본문 크기 제한 (1MB~10MB)
- [ ] Rate limiting 적용
- [ ] 헬스 체크 엔드포인트 노출
- [ ] 구조화된 로깅 (JSON)
- [ ] Graceful shutdown 구현
- [ ] Swagger UI는 개발 환경에서만 노출
기능별 구현 방법 요약
| 기능 | 구현 방법 |
|---|---|
| 라우팅 | 정규표현식 + 파라미터 추출 |
| 미들웨어 | 함수 체인 + next() 콜백 |
| 인증 | JWT + Bearer 토큰 |
| 검증 | Rule 기반 Validator |
| 문서화 | Swagger/OpenAPI 자동 생성 |
핵심 원칙:
- Express 스타일 API로 생산성 향상
- 미들웨어로 횡단 관심사 분리
- JSON 자동 파싱으로 편의성 제공
- 타입 안전성 유지
- 자동 문서화로 유지보수성 확보
자주 묻는 질문 (FAQ)
Q. 브라우저에서만 CORS preflight 요청이 실패하는 이유는 무엇인가요?
A. 브라우저는 커스텀 헤더(Authorization 등)나 JSON 바디를 보내는 교차 출처 요청 전에 OPTIONS 메서드로 preflight 요청을 먼저 보냅니다. 서버에 OPTIONS를 처리하는 경로가 없거나, 인증 미들웨어가 토큰이 없는 OPTIONS 요청을 401로 막으면 본 요청이 전송되지도 않습니다. CORS 미들웨어를 인증 미들웨어보다 앞에 등록하고, OPTIONS 요청에는 Access-Control-Allow-Origin, Methods, Headers를 담아 바로 응답하도록 만들면 해결됩니다.
같이 보면 좋은 글
- C++ 초경량 HTTP 웹 프레임워크 바닥부터 만들기 [#48-2]
- Boost.Beast로 REST API 서버 만들기
- C++ REST API 클라이언트
- C++에서 HTTP 제대로 파싱하기