C++로 REST API 서버 만들기: 라우터·미들웨어 체인·JWT 인증·요청 검증

무엇을 만드는가

Node.js Express나 Python Flask처럼 간결한 라우팅, 미들웨어 체인, 문서 생성을 갖춘 서버를 C++로 직접 만들어 봅니다. Boost.Beast 위에 GET/POST/PUT/DELETE 라우터를 올리고, 로깅·CORS·JWT 인증 미들웨어 체인, JSON 요청/응답 처리, 요청 검증과 에러 핸들러, Swagger/OpenAPI 문서 생성을 차례로 구현합니다. 예제는 C++17, Boost.Beast, nlohmann/json, jwt-cpp를 기준으로 합니다.

C++로 REST API 서버를 만드는 경우

지연 시간이 중요한 게임 서버나 거래 시스템, 실시간 데이터 피드에서는 런타임 오버헤드를 줄이고 지연을 세밀하게 제어하려고 C++를 고르기도 합니다. 다만 대부분의 API 서버에서 응답 시간의 상당 부분은 DB와 외부 호출에서 생기므로, 언어를 바꾸기 전에 실제 병목이 어디인지부터 측정하는 편이 좋습니다.

이미 C++로 작성된 게임 엔진, 트레이딩 시스템, IoT 게이트웨이에 웹이나 모바일 클라이언트가 연동해야 하는 경우도 있습니다. 별도 Node.js 서버를 두면 프로세스 간 통신과 배포 단위가 하나 더 생기므로, C++ 프로세스 안에서 직접 REST API를 제공하면 통합이 단순해집니다. 대신 느린 클라이언트나 과도한 요청 같은 HTTP 처리 문제가 핵심 로직과 같은 프로세스에 영향을 줄 수 있으므로, I/O 스레드와 핵심 로직 스레드를 분리해 두는 설계가 필요합니다. 메모리가 빠듯한 임베디드·엣지 장비에서는 수십 MB를 쓰는 런타임 대신 의존성을 줄인 C++ 서버가 유리할 수 있습니다.

마지막으로, Express의 app.get(), app.use(), next()가 내부에서 어떻게 동작하는지 이해하는 데도 직접 구현해 보는 것이 효과적입니다. 이 글에서 만드는 구조는 그 학습용 골격에 가깝고, 운영 환경이라면 Crow, Drogon, Oat++ 같은 검증된 프레임워크를 먼저 검토하는 것이 현실적입니다.


Express 스타일 라우터 설계

아키텍처

flowchart TB
    C1[HTTP 요청]
    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 --> M1

목표 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.param("id");
    auto user = get_user(id);
    if (!user) {
        res.status(404).json({{"error", "User not found"}});
        return;
    }
    res.json(*user);
});
server.listen(8080);

req.body<User>()는 nlohmann/json의 get<User>()를 쓰므로 User에 from_json이 정의되어 있어야 하고, res.json(*user)도 마찬가지로 to_json이 필요합니다.

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*를 돌려주는 편이 맞습니다. 매칭되는 라우트가 없으면 nullopt가 돌아오므로, 서버는 이때 404를 응답해야 합니다.

add_route는 :id 부분만 정규식으로 바꾸고 나머지 경로 문자는 그대로 정규식에 넣습니다. 그래서 /v1.0/users처럼 경로에 ., +, ( 같은 정규식 메타문자가 들어 있으면 .이 임의의 한 글자와 매칭되는 식으로 의도와 다른 경로가 잡히거나, 괄호 때문에 정규식 생성 자체가 예외를 던집니다. 고정 부분은 메타문자를 이스케이프한 뒤 이어 붙여야 합니다. 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로 관리하고, 체인 끝에서 “응답 완료” 콜백을 받는 구조로 바꿔야 합니다.

미들웨어는 등록 순서대로 실행됩니다. 로깅, CORS, 에러 핸들러처럼 모든 요청에 적용할 것은 전역으로 먼저 등록하고, 인증은 보호가 필요한 라우트에만 붙입니다. 인증 미들웨어가 실패했을 때는 응답을 쓰고 next()를 호출하지 않은 채 반환해야 요청이 거기서 멈춥니다. 반대로 성공했는데 next()를 빠뜨리면 핸들러가 실행되지 않아 빈 응답이 나갑니다.

로깅 미들웨어

auto logging_middleware = [](const Request& req, Response& res, std::function<void()> next) {
    auto start = std::chrono::steady_clock::now();
    
    next();  // 다음 미들웨어와 핸들러 실행
    
    auto end = std::chrono::steady_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    std::cout << req.method() << ' ' << req.path() << " -> " << res.status_code()
              << " (" << duration.count() << "ms)\n";
};
server.use(logging_middleware);

요청마다 std::endl로 출력하면 매번 버퍼를 비우느라 처리량이 떨어지므로 '\n'을 쓰고, 운영 환경에서는 비동기 로거로 넘기는 편이 좋습니다.

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.set_header("Access-Control-Max-Age", "600");
        res.status(204).send();
        return;  // preflight는 여기서 끝냄
    }
    
    next();
};
server.use(cors_middleware);

프런트엔드가 http://localhost:3000에서 개발 중이고 API 서버가 http://localhost:8080이면 출처가 다르므로, 브라우저는 서버가 허용한다고 응답하지 않는 한 응답을 스크립트에 넘겨주지 않고 Access to fetch ... has been blocked by CORS policy 에러를 냅니다. Authorization 헤더나 Content-Type: application/json 본문을 보내는 요청은 본 요청 전에 OPTIONS 메서드로 preflight를 먼저 보냅니다. 서버에 OPTIONS를 처리하는 경로가 없거나, 인증 미들웨어가 토큰 없는 OPTIONS 요청을 401로 막으면 본 요청은 전송되지도 않습니다. 그래서 CORS 미들웨어를 인증보다 앞에 두고 OPTIONS에 바로 응답합니다.

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 헤더는 와일드카드에 포함되지 않으므로 위 코드처럼 이름을 명시해야 합니다. Access-Control-Max-Age는 브라우저가 preflight 결과를 캐시하는 시간(초)으로, 브라우저마다 상한이 따로 있습니다.


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_;
    bool body_parse_error_ = false;
    
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_;
    }

    bool body_parse_error() const {
        return body_parse_error_;
    }
    
    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);
        // key1=value1&key2=value2 형식을 나누고 %XX 퍼센트 인코딩을 디코딩해 query_에 저장
        // ... 구현 생략
    }
    
    void parse_body() {
        if (req_.body().empty()) return;
        auto content_type = req_[beast::http::field::content_type];
        if (content_type.substr(0, 16) != "application/json") return;
        
        try {
            body_json_ = json::parse(req_.body());
        } catch (const json::parse_error&) {
            body_parse_error_ = true;  // 핸들러나 미들웨어가 400으로 응답
        }
    }
};

POST 요청을 보냈는데 req.json_body()가 비어 있다면, 클라이언트가 Content-Type: application/json 헤더 없이 보냈거나 JSON 문법이 잘못된 경우입니다. 파싱 실패를 조용히 무시하면 핸들러가 빈 객체로 계속 진행하므로, 위처럼 실패 여부를 기록해 두고 400 Bad Request로 응답하는 편이 디버깅하기 쉽습니다. json::parse가 던지는 예외는 json::parse_error이고, 이후 get<T>()에서 타입이 맞지 않으면 json::type_error가 납니다.

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();  // 기본값이 들여쓰기 없는 compact 출력
        res_.prepare_payload();
        sent_ = true;
    }
    
    void send(const std::string& body = "") {
        res_.body() = body;
        res_.prepare_payload();
        sent_ = true;
    }
    
    unsigned status_code() const {
        return res_.result_int();
    }
    
    const auto& native() const { return res_; }
};

Response::json의 매개변수 타입을 ::json으로 쓴 것은, 클래스 안에서 json이라는 이름이 멤버 함수를 가리키기 때문입니다(using json = nlohmann::json;이 전역에 있다고 가정). nlohmann/json의 dump()는 기본값이 들여쓰기 없는 출력이라 별도 최적화 옵션이 필요 없습니다.


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::minutes(15))
            .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")
                .leeway(30);  // 서버 간 시계 오차 허용(초)
            
            verifier.verify(decoded);  // 서명, 알고리즘, iss, exp/nbf 검사
            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, std::function<void()> next) {
        auto auth_header = req.header("Authorization");
        const std::string prefix = "Bearer ";
        if (auth_header.compare(0, prefix.size(), prefix) != 0) {
            res.status(401).json({{"error", "Unauthorized"}});
            return;  // next()를 호출하지 않음
        }
        
        auto user_id = jwt_auth.verify(auth_header.substr(prefix.size()));
        if (!user_id) {
            res.status(401).json({{"error", "Invalid token"}});
            return;
        }
        
        // 설계상 문제: const_cast로 요청에 인증 정보를 기록 (아래 설명 참고)
        const_cast<Request&>(req).set_user_id(*user_id);
        
        next();
    };
}
// 라우트별 미들웨어를 받는 오버로드가 RestServer에 있다고 가정
server.get("/profile", auth_middleware(jwt_auth), 
     [](const Request& req, Response& res) {
        auto profile = get_user_profile(req.user_id());
        res.json(profile);
    });

std::string::starts_with는 C++20부터 있으므로, C++17에서는 위처럼 compare로 접두어를 확인합니다. jwt-cpp의 verifier는 allow_algorithm으로 지정한 알고리즘만 받아들이고, 토큰에 exp나 nbf가 있으면 leeway를 감안해 시간 검사를 합니다. 액세스 토큰의 수명을 짧게 두고 만료되면 POST /auth/refresh 같은 엔드포인트에서 리프레시 토큰으로 새 액세스 토큰을 받게 하는 것이 일반적인 구성이며, 만료된 토큰에는 401과 함께 클라이언트가 갱신을 시도할 수 있게 구분 가능한 에러 코드를 돌려줍니다.

인증된 사용자 ID를 넣으려고 const_cast를 쓰는 것은 미들웨어 시그니처가 const Request&이기 때문입니다. 요청 객체가 원래 const로 만들어졌다면 이 수정은 정의되지 않은 동작이고, 그렇지 않더라도 “읽기 전용”이라는 시그니처의 약속을 깨므로 다른 미들웨어가 요청이 바뀌지 않는다고 가정한 코드가 틀어집니다. 요청 객체를 여러 스레드가 공유하는 구조에서는 데이터 경쟁으로도 이어집니다. 요청별 RequestContext 구조체를 따로 두고 미들웨어가 Context&로 받아 인증 결과나 요청 ID를 기록하게 하는 것이 깔끔한 해법입니다.


요청 검증과 전역 에러 핸들러

요청 검증

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);
});

min_length는 std::string::length()를 쓰므로 UTF-8 문자열에서는 글자 수가 아니라 바이트 수를 셉니다. 한글 비밀번호 8자는 24바이트이므로, 글자 수 기준 제한이 필요하면 코드 포인트를 세는 함수를 따로 둬야 합니다. 본문이 JSON 객체가 아니라 배열이나 문자열이면 contains()는 false를 돌려주므로 required 규칙에서 걸러집니다.

전역 에러 핸들러

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 {
            log_error(e.what());  // 상세 내용은 서버 로그에만
            res.status(500).json({{"error", "Internal server error"}});
        }
    }
};
// 에러 핸들링 미들웨어
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);

예상하지 못한 예외의 what() 메시지를 500 응답에 그대로 넣으면 SQL 오류 문장, 파일 경로, 내부 호스트 이름 같은 정보가 클라이언트에 노출됩니다. 상세 내용은 요청 ID와 함께 서버 로그에 남기고, 응답에는 일반적인 메시지와 요청 ID만 담는 편이 안전합니다. 에러 핸들러보다 앞에 등록된 미들웨어(여기서는 로깅과 CORS)에서 던진 예외는 이 핸들러가 잡지 못한다는 점도 기억해 둡니다.


Swagger 문서 생성

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_) {
            json op = {
                {"summary", doc.summary},
                {"description", doc.description},
                {"responses", doc.responses}
            };
            if (!doc.parameters.empty()) op["parameters"] = doc.parameters;
            if (!doc.request_body.is_null()) op["requestBody"] = doc.request_body;
            swagger["paths"][doc.path][doc.method] = op;
        }
        
        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"}}}
    }
});
server.get("/api-docs", [&swagger](const Request& req, Response& res) {
    res.json(swagger.generate());
});

OpenAPI에서 requestBody는 GET처럼 본문이 없는 작업에는 넣지 않아야 하고, 빈 parameters나 null 값이 들어가면 검증 도구가 스펙 오류로 보고합니다. 그래서 값이 있을 때만 필드를 추가합니다. 경로 템플릿도 OpenAPI 형식은 /users/{id}이므로, 라우터의 /users/:id 표기를 문서에 넣을 때는 변환이 필요합니다. 문서를 라우트와 따로 관리하면 쉽게 어긋나므로, 라우트 등록 함수가 문서 정보도 함께 받게 하면 누락을 막을 수 있습니다. Swagger UI는 내부 API 구조를 그대로 보여 주므로 운영 환경에서는 노출 범위를 제한합니다.


사용자 관리 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

사용자 CRUD API

int main() {
    RestServer server;
    const char* secret = std::getenv("JWT_SECRET");
    if (!secret) {
        std::cerr << "JWT_SECRET is not set\n";
        return 1;
    }
    JWTAuth jwt_auth(secret);
    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) {
        const auto& body = req.json_body();
        auto user = authenticate(body.value("email", ""), body.value("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;
}

JWT 시크릿은 코드에 넣지 않고 환경 변수나 시크릿 관리 도구에서 읽습니다. 로그인 핸들러에서 body["email"]처럼 operator[]로 읽으면 필드가 없을 때 null이 들어가고, const 객체에서는 정의되지 않은 동작이 되므로 value()로 기본값을 지정합니다. 상태 코드는 생성 201, 조회·수정 200, 삭제 204를 쓰고, 실패는 검증 400, 인증 401, 권한 403, 없음 404, 중복 409로 구분합니다. 성공과 실패 모두 data나 error 같은 정해진 키로 응답 형식을 통일하면 클라이언트 처리가 단순해집니다.

cURL로 테스트

# 1. 회원가입
curl -X POST http://localhost:8080/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"secret1234","name":"홍길동"}'
# 2. 로그인
curl -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"secret1234"}'
# 응답: {"token":"eyJ...", "user":{"id":"...","email":"..."}}
# 3. 사용자 목록 (URL에 &가 있으므로 따옴표로 감쌈)
curl "http://localhost:8080/users?page=1&limit=10" \
  -H "Authorization: Bearer eyJ..."
# 4. 사용자 상세
curl 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":"new@example.com"}'
# 6. 사용자 삭제
curl -X DELETE http://localhost:8080/users/123 \
  -H "Authorization: Bearer eyJ..."

3번처럼 쿼리 문자열에 &가 있는 URL을 따옴표 없이 쓰면 셸이 &를 백그라운드 실행 연산자로 해석해 limit=10이 전달되지 않습니다.


운영에 필요한 것들

요청 본문 크기 제한

Beast의 request_parser는 기본 본문 한도가 1MB이고, parser.body_limit(n)으로 바꿀 수 있습니다. 한도를 넘으면 읽기 단계에서 http::error::body_limit 에러가 나므로, 이때 413 Payload Too Large로 응답하고 연결을 닫습니다. 한도를 두지 않으면 큰 본문 하나로 메모리를 소진시킬 수 있습니다.

헬스 체크

// Kubernetes / 로드밸런서용
server.get("/health", [](const Request& req, Response& res) {
    res.json({{"status", "ok"}});
});
server.get("/health/ready", [](const Request& req, Response& res) {
    if (!check_database_connection()) res.status(503).json({{"status", "unhealthy"}});
    else res.json({{"status", "ready"}});
});

liveness 체크(/health)는 프로세스가 응답하는지만 확인하고, readiness 체크(/health/ready)에서 DB 같은 의존성을 확인합니다. liveness에 DB 검사를 넣으면 DB 장애 때 모든 파드가 재시작되는 연쇄 장애가 생길 수 있습니다.

요청 ID 추적

auto request_id_middleware = [](const Request& req, Response& res, std::function<void()> next) {
    auto req_id = req.header("X-Request-ID");
    if (req_id.empty()) req_id = generate_uuid();
    res.set_header("X-Request-ID", req_id);
    const_cast<Request&>(req).set_request_id(req_id);  // RequestContext로 바꾸는 편이 좋음
    next();
};

요청 ID를 응답 헤더와 모든 로그에 함께 남기면, 사용자가 보고한 오류를 서버 로그에서 바로 찾을 수 있습니다. 로그는 {"method": ..., "path": ..., "status": ..., "duration_ms": ..., "request_id": ...}처럼 JSON 한 줄로 남기면 ELK나 CloudWatch 같은 수집 도구에서 필드별로 검색할 수 있습니다.

속도 제한 (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_;
    }
};
// allow()가 false면 429 Too Many Requests 반환

이 구현은 1분 창 안에서 횟수를 세는 고정 윈도우 방식이라, 창이 바뀌는 경계 직전과 직후에 몰아서 보내면 짧은 시간에 한도의 두 배까지 통과합니다. 버스트를 부드럽게 제한하려면 시간에 따라 토큰이 채워지는 토큰 버킷이나 슬라이딩 윈도우를 씁니다. 여러 I/O 스레드가 동시에 allow()를 호출하면 unordered_map에 대한 데이터 경쟁이 되므로 뮤텍스나 샤딩이 필요하고, 한 번 본 클라이언트 ID가 맵에 계속 남으므로 오래된 항목을 주기적으로 지워야 메모리가 새지 않습니다. 서버가 여러 대라면 인스턴스별 카운터로는 전체 한도를 지킬 수 없어 Redis 같은 공유 저장소로 옮기게 됩니다. 429 응답에는 Retry-After 헤더를 넣어 클라이언트가 언제 다시 시도할지 알려 줍니다.

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초(terminationGracePeriodSeconds)가 지나면 SIGKILL을 보내므로, 그 안에 정리를 마치도록 요청 타임아웃을 맞춰 둡니다.

플래그를 확인하는 루프를 ioc.run_one()으로 짜면 또 다른 함정이 생깁니다. run_one()은 처리할 이벤트가 생길 때까지 블록하므로, 요청이 없는 한가한 시간에 SIGTERM이 오면 다음 요청이 들어올 때까지 플래그를 확인하지 못하고 결국 SIGKILL로 끝납니다. signal_set은 시그널을 일반 비동기 이벤트로 바꿔 주므로 이 문제가 없습니다.

net::signal_set signals(ioc, SIGINT, SIGTERM);
signals.async_wait([&](beast::error_code const&, int) {
    listener->stop();  // acceptor만 닫아 새 연결 거부, 진행 중인 세션은 응답 후 스스로 종료
});
ioc.run();             // 남은 작업이 없으면 자연히 반환

모든 세션이 끝나면 io_context에 남은 작업이 없어 run()이 반환됩니다. 단, Keep-Alive 연결은 클라이언트가 끊지 않는 한 다음 요청을 계속 기다리므로 아래의 읽기 타임아웃이 있어야 종료가 무한히 늘어지지 않고, 종료 신호 이후의 응답에 keep_alive(false)를 설정해 클라이언트가 연결을 닫도록 유도하기도 합니다.

Beast 세션을 직접 짤 때 빠뜨리기 쉬운 것

Beast는 라우터도 타임아웃 정책도 없는 저수준 도구라서, 세션 클래스에서 다음 세 가지를 직접 챙겨야 합니다.

첫째, Keep-Alive로 다시 읽기 전에 요청·응답 객체를 비웁니다. 다음 async_read 직전에 request_ = {};, 응답을 만들기 전에 response_ = {}; 후 response_.version(request_.version()), response_.keep_alive(request_.keep_alive())를 설정합니다. HTTP/1.1은 기본이 지속 연결이므로 Connection: keep-alive 헤더를 직접 쓰기보다 이렇게 요청의 의사를 따라가게 합니다. 요청을 비우지 않으면 이전 요청의 헤더가 다음 요청에 섞이고, Beast 공식 예제도 요청을 비우지 않으면 동작이 정의되지 않는다고 명시합니다.

둘째, 읽기 타임아웃을 둡니다. 연결만 맺고 요청을 끝까지 보내지 않는 클라이언트가 세션을 무기한 붙잡는 slowloris 유형의 자원 고갈을 막기 위해, async_read 직전에 stream_.expires_after(std::chrono::seconds(30));를 호출합니다.

셋째, 완료 핸들러가 shared_from_this()를 캡처하게 합니다. self를 빼고 this만 캡처하면 세션을 만든 쪽의 shared_ptr이 사라지는 순간 세션이 파괴되고, 완료 핸들러가 해제된 메모리에 접근합니다. 요청이 적은 개발 환경에서는 재현되지 않다가 부하가 걸릴 때만 나타나는 크래시입니다.

Listener 쪽에서는 acceptor_.set_option(net::socket_base::reuse_address(true))를 bind 전에 설정해야 재시작 직후 이전 연결이 TIME_WAIT에 남아 있어도 bind: Address already in use 없이 뜹니다. 또 accept 에러가 나도 곧바로 다시 async_accept하는 구조라면, 파일 디스크립터 한도에 걸려 Too many open files가 반복될 때 accept 루프가 CPU를 태우며 로그만 쏟아내므로 ulimit -n을 충분히 올리고 에러 시 잠시 지연 후 재시도하는 편이 좋습니다. 스레드 모델은 하나의 io_context를 여러 스레드(보통 코어 수만큼)에서 run()하는 방식이 흔한데, 이때 한 세션의 핸들러가 동시에 실행되지 않도록 strand로 묶어야 합니다.

성능을 측정하는 방법

처리량과 지연은 핸들러가 하는 일, 응답 크기, Keep-Alive 여부, 하드웨어에 따라 몇 배씩 달라지므로 다른 환경의 숫자를 가져다 쓰지 말고 자기 서버로 직접 재야 합니다. wrk -t4 -c100 -d30s http://localhost:8080/health처럼 부하 도구로 먼저 아무 일도 하지 않는 엔드포인트의 한계를 재 두면 프레임워크 자체의 오버헤드를 알 수 있고, 그다음 실제 엔드포인트와 비교하면 비용이 라우팅·JSON·DB 중 어디에 있는지 구분됩니다. 평균 지연보다 p99 지연을 봐야 하며, 동기 I/O 서버는 동시 연결 수가 스레드 수를 넘는 순간 p99가 급격히 나빠지는 것이 일반적입니다. 요청마다 std::endl로 로그를 쓰는 것처럼 사소해 보이는 코드가 처리량을 크게 깎는 경우도 있으므로, 추측보다 프로파일러로 확인하는 것이 빠릅니다.


같이 보면 좋은 글