Boost.Beast로 REST API 서버 만들기: 정규식 라우터, 미들웨어 체인, JSON, Graceful Shutdown

들어가며: “REST API 라우팅이 복잡해요”

문제 상황

// ❌ 문제: if-else 체인으로 라우팅하면 유지보수 지옥
void handle_request(const Request& req, Response& res) {
    if (req.method() == "GET" && req.path() == "/api/users") {
        // 사용자 목록
    } else if (req.method() == "GET" && req.path().starts_with("/api/users/")) {
        // 사용자 상세 (ID 추출 어려움!)
    } else if (req.method() == "POST" && req.path() == "/api/users") {
        // 사용자 생성
    } else if (req.method() == "PUT" && req.path().starts_with("/api/users/")) {
        // 사용자 수정
    } else if (req.method() == "DELETE" && req.path().starts_with("/api/users/")) {
        // 사용자 삭제
    } else if (req.method() == "GET" && req.path() == "/api/orders") {
        // 주문 목록
    } // ... 100개 이상의 엔드포인트!
    else {
        res.status(404);
    }
}

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

  • 라우팅 복잡도: 엔드포인트가 늘어날수록 if-else 체인이 길어짐
  • 경로 파라미터 추출: /users/:id에서 ID를 추출하기 어려움
  • 미들웨어: 인증, 로깅, CORS를 모든 핸들러에 중복 작성
  • 에러 처리: 각 핸들러마다 try-catch 반복
  • JSON 파싱: 요청 본문 검증이 산재함 해결책:
  1. Router 클래스: 정규식 기반 경로 매칭
  2. 미들웨어 체인: 로깅 → CORS → 인증 → 핸들러
  3. Request/Response 래퍼: JSON 파싱/생성 간소화
  4. 에러 핸들러: 전역 예외 처리 목표:
  • Beast HTTP 서버 구조 이해
  • Router 구현 (정규식 경로 매칭)
  • 미들웨어 체인 구현
  • JSON 요청/응답 처리
  • 에러 처리와 CORS
  • 성능 측정과 프로덕션 배포

요구 환경: Boost.Beast 1.70+, nlohmann/json 3.0+, C++20(예제의 std::string::starts_with 때문이며, C++17이라면 rfind(prefix, 0) == 0으로 바꾸면 됩니다)

C++로 REST API 서버를 직접 짜는 선택은 흔하지 않습니다. 대부분의 서비스는 Go, Node.js, Java 프레임워크가 라우팅·미들웨어·검증을 기본 제공하므로 생산성이 훨씬 좋습니다. 그럼에도 C++ 서버가 필요한 경우는 대개 “이미 C++로 된 엔진(시뮬레이션, 영상 처리, 추론, 게임 서버)을 HTTP로 노출해야 할 때”입니다. 이때 별도 언어의 API 게이트웨이를 두면 직렬화와 프로세스 간 통신 비용, 배포 단위가 하나 더 늘어나므로, 엔진 옆에 얇은 HTTP 층을 붙이는 편이 단순해집니다. Beast는 그 얇은 층을 만들기 위한 저수준 도구라서 라우터도, 미들웨어도, JSON 처리도 없습니다. 이 글은 그 빈자리를 직접 채우는 최소 구조를 보여 줍니다. 처음부터 완성형 프레임워크가 필요하다면 Drogon이나 Crow 같은 C++ 웹 프레임워크를 먼저 검토하는 것이 현실적입니다.


시스템 아키텍처

전체 구조

flowchart TB
    subgraph Client[클라이언트]
        C1[모바일 앱]
        C2[웹 브라우저]
        C3[다른 서비스]
    end
    
    subgraph Server[REST API 서버]
        Acceptor[TCP Acceptor]
        
        subgraph Session[HTTP 세션]
            Read[async_read]
            Router[Router]
            MW[미들웨어 체인]
            Handler[핸들러]
            Write[async_write]
        end
        
        subgraph Resources[리소스]
            DB["(데이터베이스)"]
            Cache[캐시]
        end
    end
    
    C1 --> Acceptor
    C2 --> Acceptor
    C3 --> Acceptor
    
    Acceptor --> Read
    Read --> MW
    MW --> Router
    Router --> Handler
    Handler --> DB
    Handler --> Cache
    Handler --> Write
    
    style Router fill:#4caf50
    style MW fill:#ff9800

요청 처리 흐름

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버
    participant R as Router
    participant M as 미들웨어
    participant H as 핸들러
    
    C->>S: HTTP Request
    S->>S: async_read
    S->>M: 로깅 미들웨어
    M->>M: CORS 미들웨어
    M->>M: 인증 미들웨어
    M->>R: 경로 매칭
    R->>H: 핸들러 실행
    H->>H: 비즈니스 로직
    H->>S: Response 생성
    S->>S: async_write
    S->>C: HTTP Response

미들웨어를 라우팅보다 먼저 실행하는 이유는, CORS preflight나 인증 실패처럼 “어느 핸들러로 갈지 정하기 전에 끝나야 하는” 요청이 있기 때문입니다. 뒤의 전역 에러 핸들러(handle_request_safe)도 이 순서로 구현돼 있습니다. 반대로 라우트별로 다른 인증 규칙(공개 엔드포인트와 관리자 엔드포인트)이 필요해지면, 전역 체인 대신 라우트 등록 시점에 미들웨어를 붙이는 구조로 확장해야 합니다.


Beast HTTP 서버 구조

기본 세션 클래스

#include <boost/beast.hpp>
#include <boost/asio.hpp>
#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> request_;
    http::response<http::string_body> response_;
    
public:
    explicit HttpSession(tcp::socket socket)
        : stream_(std::move(socket)) {}
    
    void start() {
        do_read();
    }
    
private:
    void do_read() {
        auto self = shared_from_this();
        request_ = {};  // Keep-Alive로 재사용하므로 이전 요청을 비움
        stream_.expires_after(std::chrono::seconds(30));  // 읽기 타임아웃
        
        // 요청 읽기
        http::async_read(stream_, buffer_, request_,
            [this, self](beast::error_code ec, std::size_t) {
                if (ec) {
                    if (ec != http::error::end_of_stream)
                        std::cerr << "read error: " << ec.message() << "\n";
                    return;
                }
                
                handle_request();
            });
    }
    
    void handle_request() {
        // 라우팅 및 핸들러 실행
        // (다음 섹션에서 구현)
        
        do_write();
    }
    
    void do_write() {
        auto self = shared_from_this();
        
        // 응답 전송
        http::async_write(stream_, response_,
            [this, self](beast::error_code ec, std::size_t) {
                if (ec) {
                    std::cerr << "write error: " << ec.message() << "\n";
                    return;
                }
                
                // Keep-Alive 지원
                if (request_.keep_alive()) {
                    do_read();
                } else {
                    stream_.socket().shutdown(tcp::socket::shutdown_send, ec);
                }
            });
    }
};

이 세션 클래스는 구조를 보여 주기 위한 뼈대라서, 운영에 가져가기 전에 반드시 보완해야 할 부분이 세 가지 있습니다.

첫째, 요청·응답 객체를 매번 초기화해야 합니다. Keep-Alive로 do_read()를 다시 호출할 때 request_와 response_는 이전 요청의 내용을 그대로 갖고 있습니다. Beast 공식 예제는 읽기 직전에 req_ = {};를 두고 “요청을 비우지 않으면 동작이 정의되지 않는다”고 명시합니다. 응답도 마찬가지여서, 첫 요청에서 설정한 헤더가 두 번째 응답에 남아 엉뚱한 Content-Type이나 CORS 헤더가 나갈 수 있습니다. handle_request() 시작 부분에서 response_ = {};로 새로 만들고 response_.version(request_.version());, response_.keep_alive(request_.keep_alive());를 설정하는 것이 안전합니다.

둘째, 타임아웃이 없습니다. 이대로 두면 연결만 맺고 요청을 끝까지 보내지 않는 클라이언트가 세션을 무기한 붙잡습니다(slowloris 유형의 자원 고갈). beast::tcp_stream은 이 용도로 stream_.expires_after(std::chrono::seconds(30));를 제공하므로, async_read 직전에 호출해 두면 시간이 지나면 beast::error::timeout으로 읽기가 끝납니다.

셋째, 람다가 this와 self를 함께 캡처하는 이유를 알아 둬야 합니다. self(shared_ptr)는 비동기 작업이 끝날 때까지 세션 객체가 살아 있게 하는 역할이고, this는 멤버 접근을 편하게 할 뿐입니다. self를 빼면 start()를 호출한 쪽의 shared_ptr이 사라지는 순간 세션이 파괴되고, 완료 핸들러가 해제된 메모리에 접근하는 크래시가 납니다. 이런 크래시는 요청이 적은 개발 환경에서는 재현되지 않다가 부하가 걸릴 때만 나타나서 원인을 찾기 어렵습니다.

Listener 클래스

class Listener : public std::enable_shared_from_this<Listener> {
    net::io_context& ioc_;
    tcp::acceptor acceptor_;
    
public:
    Listener(net::io_context& ioc, tcp::endpoint endpoint)
        : ioc_(ioc), acceptor_(ioc) {
        
        beast::error_code ec;
        
        acceptor_.open(endpoint.protocol(), ec);
        if (ec) throw beast::system_error{ec};
        
        acceptor_.set_option(net::socket_base::reuse_address(true), ec);
        if (ec) throw beast::system_error{ec};
        
        acceptor_.bind(endpoint, ec);
        if (ec) throw beast::system_error{ec};
        
        acceptor_.listen(net::socket_base::max_listen_connections, ec);
        if (ec) throw beast::system_error{ec};
    }
    
    void run() {
        do_accept();
    }
    
private:
    void do_accept() {
        acceptor_.async_accept(
            net::make_strand(ioc_),
            [self = shared_from_this()](beast::error_code ec, tcp::socket socket) {
                if (!ec) {
                    std::make_shared<HttpSession>(std::move(socket))->start();
                }
                self->do_accept();
            });
    }
};

reuse_address(true)는 서버를 재시작했을 때 이전 연결이 TIME_WAIT 상태로 남아 있어도 같은 포트에 바로 bind할 수 있게 해 줍니다. 이 옵션이 없으면 재시작 직후 bind: Address already in use 에러로 서버가 뜨지 않는 일이 흔합니다. net::make_strand(ioc_)로 소켓마다 strand를 주는 것은 나중에 io_context를 여러 스레드에서 돌릴 때를 대비한 것으로, 같은 세션의 완료 핸들러가 동시에 실행되지 않도록 보장합니다. 또 accept 에러가 나도 do_accept()를 다시 호출하는 구조이므로, 파일 디스크립터 한도에 걸려 Too many open files가 반복되면 accept 루프가 CPU를 태우며 에러 로그만 쏟아낼 수 있습니다. 운영에서는 ulimit -n을 충분히 올리고, 에러가 나면 잠시 지연 후 재시도하는 편이 좋습니다.


Router 구현

정규식 기반 경로 매칭

#include <regex>
#include <unordered_map>
#include <functional>
struct MatchResult {
    bool matched = false;
    std::unordered_map<std::string, std::string> params;
};
class Router {
public:
    using Handler = std::function<void(
        const http::request<http::string_body>&,
        http::response<http::string_body>&,
        const MatchResult&
    )>;
    
private:
    struct Route {
        http::verb method;
        std::regex pattern;
        std::vector<std::string> param_names;
        Handler handler;
    };
    
    std::vector<Route> routes_;
    
public:
    // 경로 등록: /users/:id → /users/([^/]+)
    void add_route(http::verb method, const std::string& path, Handler handler) {
        std::regex pattern;
        std::vector<std::string> param_names;
        
        // :id, :name 등을 정규식으로 변환
        std::string regex_path = path;
        std::regex param_regex(":([a-zA-Z_][a-zA-Z0-9_]*)");
        std::smatch match;
        
        std::string::const_iterator search_start(regex_path.cbegin());
        while (std::regex_search(search_start, regex_path.cend(), match, param_regex)) {
            param_names.push_back(match[1].str());
            search_start = match.suffix().first;
        }
        
        regex_path = std::regex_replace(regex_path, param_regex, "([^/]+)");
        regex_path = "^" + regex_path + "$";
        
        routes_.push_back({method, std::regex(regex_path), param_names, handler});
    }
    // (참고) 라우트를 등록할 때 ":id" 같은 경로 세그먼트를 정규식 캡처 그룹으로 바꿔두면,
    // handle()이 실제 요청을 받을 때는 매번 정규식을 새로 만들 필요 없이 이미 컴파일된
    // std::regex와 매칭만 하면 되므로, 등록 시점의 변환 비용을 요청 시점으로 옮기지 않습니다.
    
    // GET 라우트 등록
    void get(const std::string& path, Handler handler) {
        add_route(http::verb::get, path, handler);
    }
    
    // POST 라우트 등록
    void post(const std::string& path, Handler handler) {
        add_route(http::verb::post, path, handler);
    }
    
    // PUT 라우트 등록
    void put(const std::string& path, Handler handler) {
        add_route(http::verb::put, path, handler);
    }
    
    // DELETE 라우트 등록
    void del(const std::string& path, Handler handler) {
        add_route(http::verb::delete_, path, handler);
    }
    
    // 요청 처리
    void handle(
        const http::request<http::string_body>& req,
        http::response<http::string_body>& res
    ) {
        std::string target = std::string(req.target());
        
        // 쿼리 스트링 제거
        size_t query_pos = target.find('?');
        if (query_pos != std::string::npos) {
            target = target.substr(0, query_pos);
        }
        
        for (const auto& route : routes_) {
            if (route.method != req.method()) continue;
            
            std::smatch match;
            if (std::regex_match(target, match, route.pattern)) {
                MatchResult result;
                result.matched = true;
                
                // 파라미터 추출
                for (size_t i = 0; i < route.param_names.size(); ++i) {
                    result.params[route.param_names[i]] = match[i + 1].str();
                }
                
                route.handler(req, res, result);
                return;
            }
        }
        
        // 404 Not Found
        res.result(http::status::not_found);
        res.set(http::field::content_type, "application/json");
        res.body() = R"({"error":"Not Found"})";
        res.prepare_payload();
    }
};

사용 예시

Router router;
// GET /api/users
router.get("/api/users", [](const http::request<http::string_body>& req, http::response<http::string_body>& res, const MatchResult& match) {
    res.result(http::status::ok);
    res.set(http::field::content_type, "application/json");
    res.body() = R"([{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}])";
    res.prepare_payload();
});
// GET /api/users/:id
router.get("/api/users/:id", [](const http::request<http::string_body>& req, http::response<http::string_body>& res, const MatchResult& match) {
    std::string id = match.params.at("id");
    
    res.result(http::status::ok);
    res.set(http::field::content_type, "application/json");
    res.body() = R"({"id":)" + id + R"(,"name":"Alice"})";
    res.prepare_payload();
});
// POST /api/users
router.post("/api/users", [](const http::request<http::string_body>& req, http::response<http::string_body>& res, const MatchResult& match) {
    // JSON 파싱 (다음 섹션에서 구현)
    res.result(http::status::created);
    res.set(http::field::content_type, "application/json");
    res.body() = R"({"id":3,"name":"Charlie"})";
    res.prepare_payload();
});

라우트는 등록한 순서대로 검사하고 처음 매칭된 것을 실행합니다. 그래서 /api/users/me처럼 고정 경로와 /api/users/:id가 겹치면 고정 경로를 먼저 등록해야 합니다. 순서를 바꾸면 me가 id 파라미터로 잡혀 std::stoi("me")에서 std::invalid_argument가 터집니다. 또 이 Router는 경로가 있지만 메서드가 다른 경우(DELETE /api/users를 등록하지 않았는데 요청이 온 경우)에도 404를 돌려줍니다. HTTP 의미상으로는 405 Method Not Allowed와 Allow 헤더가 맞으므로, 경로 매칭 여부를 따로 기억해 두었다가 구분하는 것이 좋습니다.

두 번째 예제의 R"({"id":)" + id + ...처럼 문자열을 이어 붙여 JSON을 만드는 방식은 위험합니다. id는 [^/]+로 잡힌 임의 문자열이라 1,"admin":true처럼 따옴표나 쉼표가 들어오면 응답 JSON 구조가 바뀝니다. 경로 파라미터는 URL 디코딩도 되지 않은 원문이라는 점까지 생각하면, 사용자 입력이 섞이는 응답은 항상 뒤의 nlohmann/json처럼 라이브러리로 직렬화해야 합니다.

std::regex를 라우터에 쓰는 것은 구현이 짧다는 장점 때문이지, 빠르기 때문이 아닙니다. libstdc++의 std::regex는 백트래킹 기반이라 느린 편이고, 라우트가 N개면 요청마다 최대 N번의 정규식 매칭이 일어납니다. 라우트가 수십 개 수준이면 네트워크 I/O에 비해 무시할 만하지만, 프로파일러에서 regex_match가 상위에 보이기 시작하면 경로를 / 단위로 나눠 트리로 찾는 방식으로 바꾸는 것이 정석입니다.


미들웨어 체인

미들웨어 타입

using Middleware = std::function<bool(
    const http::request<http::string_body>&,
    http::response<http::string_body>&
)>;
class MiddlewareChain {
    std::vector<Middleware> middlewares_;
    
public:
    void use(Middleware mw) {
        middlewares_.push_back(mw);
    }
    
    // 모든 미들웨어 실행, false 반환 시 중단
    bool execute(
        const http::request<http::string_body>& req,
        http::response<http::string_body>& res
    ) {
        for (const auto& mw : middlewares_) {
            if (!mw(req, res)) {
                return false;  // 체인 중단
            }
        }
        return true;
    }
};

미들웨어가 bool을 반환하도록 설계한 것이 이 체인의 핵심입니다. false를 반환하면 execute()가 즉시 순회를 멈추므로, 예를 들어 인증 미들웨어가 토큰을 검증하다 실패하면 그 뒤에 등록된 로깅이나 실제 핸들러가 아예 실행되지 않습니다. 등록 순서 자체가 곧 실행 순서이자 “누가 먼저 요청을 걸러낼 권한을 갖는가”를 결정하므로, 아래에서 CORS를 인증보다 먼저 등록하는 것도 의도적인 선택입니다 — preflight OPTIONS 요청은 인증 토큰 없이도 통과해야 하기 때문입니다.

로깅 미들웨어

Middleware logging_middleware = [](const http::request<http::string_body>& req, http::response<http::string_body>& res) {
    auto now = std::chrono::system_clock::now();
    auto time = std::chrono::system_clock::to_time_t(now);
    
    std::cout << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S")
              << " " << req.method_string()
              << " " << req.target() << "\n";
    
    return true;  // 계속 진행
};

std::localtime은 내부 정적 버퍼를 돌려주므로 스레드 안전하지 않습니다. 지금처럼 io_context를 스레드 하나로 돌릴 때는 문제가 없지만, 여러 스레드로 늘리면 로그 시각이 뒤섞일 수 있어 POSIX의 localtime_r(Windows는 localtime_s)이나 spdlog 같은 로깅 라이브러리로 바꾸는 편이 좋습니다. 또 이 미들웨어는 핸들러 실행 전에 찍히므로 상태 코드와 처리 시간을 남기지 못합니다. 장애 분석에 실제로 필요한 것은 “어떤 요청이 몇 ms 걸려 몇 번으로 끝났는가”이므로, 운영에서는 응답을 쓴 뒤에 한 줄로 기록하는 구조가 더 유용합니다.

CORS 미들웨어

Middleware cors_middleware = [](const http::request<http::string_body>& req, http::response<http::string_body>& res) {
    res.set(http::field::access_control_allow_origin, "*");
    res.set(http::field::access_control_allow_methods, "GET, POST, PUT, DELETE, OPTIONS");
    res.set(http::field::access_control_allow_headers, "Content-Type, Authorization");
    
    // OPTIONS preflight 요청 처리
    if (req.method() == http::verb::options) {
        res.result(http::status::no_content);
        res.prepare_payload();
        return false;  // 핸들러 실행 안 함
    }
    
    return true;
};

인증 미들웨어

Middleware auth_middleware = [](const http::request<http::string_body>& req, http::response<http::string_body>& res) {
    auto auth_header = req.find(http::field::authorization);
    
    if (auth_header == req.end()) {
        res.result(http::status::unauthorized);
        res.set(http::field::content_type, "application/json");
        res.body() = R"({"error":"Missing Authorization header"})";
        res.prepare_payload();
        return false;
    }
    
    std::string token(auth_header->value());  // value()는 string_view라서 명시적 생성 필요
    
    // Bearer 토큰 검증 (실제로는 JWT 검증 등)
    if (!token.starts_with("Bearer ")) {
        res.result(http::status::unauthorized);
        res.set(http::field::content_type, "application/json");
        res.body() = R"({"error":"Invalid token format"})";
        res.prepare_payload();
        return false;
    }
    
    return true;
};

Beast의 헤더 값은 boost::string_view(버전에 따라 boost::core::string_view)라서 std::string token = auth_header->value();처럼 복사 초기화로 받으면 컴파일 에러가 납니다. std::string의 string_view 생성자가 explicit이기 때문이며, 위처럼 직접 초기화로 바꾸면 됩니다. 이 미들웨어는 형식만 확인할 뿐 토큰의 진위는 검사하지 않습니다. 실제로는 jwt-cpp 같은 라이브러리로 서명과 만료를 검증하고, 검증된 사용자 정보를 핸들러에 넘겨야 합니다. 그런데 지금의 Middleware 시그니처는 const 요청과 응답만 받으므로 “인증된 사용자 ID”를 전달할 통로가 없습니다. 요청마다 생성하는 컨텍스트 구조체(사용자, 요청 ID, 시작 시각 등)를 미들웨어와 핸들러에 함께 넘기도록 시그니처를 확장하는 것이 다음 단계입니다.


Request/Response 래퍼

Request 래퍼

class Request {
    const http::request<http::string_body>& req_;
    const MatchResult& match_;
    
public:
    Request(const http::request<http::string_body>& req, const MatchResult& match)
        : req_(req), match_(match) {}
    
    std::string path() const {
        std::string target = std::string(req_.target());
        size_t query_pos = target.find('?');
        return query_pos != std::string::npos ? target.substr(0, query_pos) : target;
    }
    
    std::string param(const std::string& name) const {
        auto it = match_.params.find(name);
        return it != match_.params.end() ? it->second : "";
    }
    
    std::string query(const std::string& name) const {
        std::string target = std::string(req_.target());
        size_t query_pos = target.find('?');
        if (query_pos == std::string::npos) return "";
        
        std::string query_string = target.substr(query_pos + 1);
        // 간단한 쿼리 파싱 (실제로는 URL 디코딩 필요)
        size_t pos = query_string.find(name + "=");
        if (pos == std::string::npos) return "";
        
        pos += name.size() + 1;
        size_t end = query_string.find('&', pos);
        return end != std::string::npos 
            ? query_string.substr(pos, end - pos)
            : query_string.substr(pos);
    }
    
    std::string header(const std::string& name) const {
        auto it = req_.find(name);
        return it != req_.end() ? std::string(it->value()) : "";
    }
    
    const std::string& body() const {
        return req_.body();
    }
    
    nlohmann::json json_body() const {
        return nlohmann::json::parse(req_.body());
    }
};

Response 래퍼

class Response {
    http::response<http::string_body>& res_;
    
public:
    explicit Response(http::response<http::string_body>& res) : res_(res) {}
    
    Response& status(http::status code) {
        res_.result(code);
        return *this;
    }
    
    Response& header(const std::string& name, const std::string& value) {
        res_.set(name, value);
        return *this;
    }
    
    Response& json(const nlohmann::json& data) {
        res_.set(http::field::content_type, "application/json");
        res_.body() = data.dump();
        res_.prepare_payload();
        return *this;
    }
    
    Response& text(const std::string& data) {
        res_.set(http::field::content_type, "text/plain");
        res_.body() = data;
        res_.prepare_payload();
        return *this;
    }
};

JSON 요청/응답 처리

nlohmann/json 사용

#include <nlohmann/json.hpp>
// POST /api/users
router.post("/api/users", [](const http::request<http::string_body>& req_raw, http::response<http::string_body>& res_raw, const MatchResult& match) {
    Request req(req_raw, match);
    Response res(res_raw);
    
    try {
        auto body = req.json_body();
        
        // 검증
        if (!body.contains("name") || !body.contains("email")) {
            return res.status(http::status::bad_request)
                      .json({{"error", "Missing required fields"}});
        }
        
        std::string name = body["name"];
        std::string email = body["email"];
        
        // 비즈니스 로직 (DB 저장 등)
        int new_id = 123;  // 실제로는 DB에서 생성
        
        return res.status(http::status::created)
                  .json({
                      {"id", new_id},
                      {"name", name},
                      {"email", email}
                  });
        
    } catch (const nlohmann::json::exception& e) {
        return res.status(http::status::bad_request)
                  .json({{"error", "Invalid JSON"}});
    }
});

contains로 필드 존재만 확인하는 것은 절반의 검증입니다. {"name": 123}처럼 타입이 다르면 std::string name = body["name"];에서 [json.exception.type_error.302] type must be string, but is number 예외가 나고, 위 catch가 이를 “Invalid JSON”으로 뭉뚱그립니다. 클라이언트 입장에서는 JSON 문법 오류인지 필드 타입 오류인지 구분할 수 없으므로, body["name"].is_string()처럼 타입까지 확인하고 어떤 필드가 문제인지 응답에 담아 주는 편이 디버깅에 훨씬 도움이 됩니다. 또 string_body는 요청 본문 전체를 메모리에 올리므로, 파서의 body_limit(기본 1MB)을 서비스에 맞게 정하지 않으면 큰 요청으로 메모리를 소모시키는 공격에 노출됩니다.


에러 처리와 상태 코드

HTTP 상태 코드 매핑

코드의미사용 시점
200 OK성공GET, PUT, DELETE 성공
201 Created생성됨POST 성공
204 No Content내용 없음DELETE 성공 (본문 없음)
400 Bad Request잘못된 요청JSON 파싱 실패, 검증 실패
401 Unauthorized인증 필요토큰 없음, 만료
403 Forbidden권한 없음인증됐지만 권한 부족
404 Not Found없음리소스 없음
500 Internal Server Error서버 에러예외 발생

전역 에러 핸들러

void handle_request_safe(
    const http::request<http::string_body>& req,
    http::response<http::string_body>& res,
    Router& router,
    MiddlewareChain& middleware
) {
    try {
        // 미들웨어 실행
        if (!middleware.execute(req, res)) {
            return;  // 미들웨어에서 응답 완료
        }
        
        // 라우터 실행
        router.handle(req, res);
        
    } catch (const nlohmann::json::exception& e) {
        res.result(http::status::bad_request);
        res.set(http::field::content_type, "application/json");
        res.body() = nlohmann::json{{"error", "Invalid JSON"}}.dump();
        res.prepare_payload();
        
    } catch (const std::exception& e) {
        res.result(http::status::internal_server_error);
        res.set(http::field::content_type, "application/json");
        res.body() = nlohmann::json{{"error", "Internal Server Error"}}.dump();
        res.prepare_payload();
        
        std::cerr << "Exception: " << e.what() << "\n";
    }
}

클라이언트에는 “Internal Server Error”만 보내고 e.what()은 서버 로그에만 남기는 것이 핵심입니다. 예외 메시지에는 SQL 문, 파일 경로, 내부 식별자가 들어 있는 경우가 많아 응답에 그대로 실으면 정보 노출이 됩니다. 한 가지 더 주의할 점은, 미들웨어가 CORS 헤더를 설정한 뒤 핸들러에서 예외가 나도 res에는 그 헤더가 남아 있다는 것입니다. 이 구조에서는 그게 오히려 다행입니다. 에러 응답에 CORS 헤더가 없으면 브라우저는 500 대신 CORS 에러만 보여 줘서, 프론트엔드 개발자가 서버 버그를 CORS 설정 문제로 오해하게 됩니다.


CORS 처리

CORS 헤더 설명

Access-Control-Allow-Origin: *
  → 모든 도메인 허용 (프로덕션에서는 특정 도메인만)
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
  → 허용할 HTTP 메서드
Access-Control-Allow-Headers: Content-Type, Authorization
  → 허용할 요청 헤더
Access-Control-Max-Age: 86400
  → Preflight 캐시 시간 (초)

OPTIONS Preflight 처리

// 브라우저는 실제 요청 전에 OPTIONS 요청을 보냄
if (req.method() == http::verb::options) {
    res.result(http::status::no_content);
    res.set(http::field::access_control_allow_origin, "*");
    res.set(http::field::access_control_allow_methods, "GET, POST, PUT, DELETE");
    res.set(http::field::access_control_allow_headers, "Content-Type, Authorization");
    res.set(http::field::access_control_max_age, "86400");
    res.prepare_payload();
    return;
}

Access-Control-Allow-Origin: *은 쿠키나 HTTP 인증을 함께 보내는 요청(credentials: 'include')에는 쓸 수 없습니다. 브라우저가 와일드카드를 거부하므로, 그런 클라이언트가 있다면 요청의 Origin 헤더를 허용 목록과 비교해 일치할 때 그 값을 그대로 돌려주고 Vary: Origin을 함께 설정해야 캐시가 다른 출처의 응답을 섞어 쓰지 않습니다. Authorization 헤더에 Bearer 토큰을 싣는 방식은 쿠키가 아니므로 *로도 동작하지만, 그렇다고 아무 사이트에서나 API를 호출하게 두는 것이 안전하다는 뜻은 아닙니다.


완전한 REST API 서버 예시

#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <nlohmann/json.hpp>
#include <memory>
#include <iostream>
// (앞서 정의한 Router, Middleware, Request, Response 클래스 포함)
int main() {
    try {
        net::io_context ioc{1};  // 단일 스레드
        
        // 라우터 설정
        Router router;
        
        // GET /api/users
        router.get("/api/users", [](const http::request<http::string_body>& req_raw, http::response<http::string_body>& res_raw, const MatchResult& match) {
            Response res(res_raw);
            res.status(http::status::ok)
               .json({
                   {"users", nlohmann::json::array({
                       {{"id", 1}, {"name", "Alice"}},
                       {{"id", 2}, {"name", "Bob"}}
                   })}
               });
        });
        
        // GET /api/users/:id
        router.get("/api/users/:id", [](const http::request<http::string_body>& req_raw, http::response<http::string_body>& res_raw, const MatchResult& match) {
            Request req(req_raw, match);
            Response res(res_raw);
            
            std::string id = req.param("id");
            
            res.status(http::status::ok)
               .json({
                   {"id", std::stoi(id)},
                   {"name", "Alice"}
               });
        });
        
        // POST /api/users
        router.post("/api/users", [](const http::request<http::string_body>& req_raw, http::response<http::string_body>& res_raw, const MatchResult& match) {
            Request req(req_raw, match);
            Response res(res_raw);
            
            auto body = req.json_body();
            
            res.status(http::status::created)
               .json({
                   {"id", 3},
                   {"name", body["name"]},
                   {"email", body["email"]}
               });
        });
        
        // 미들웨어 설정
        MiddlewareChain middleware;
        middleware.use(logging_middleware);
        middleware.use(cors_middleware);
        
        // 서버 시작
        auto const address = net::ip::make_address("0.0.0.0");
        auto const port = static_cast<unsigned short>(8080);
        
        std::make_shared<Listener>(ioc, tcp::endpoint{address, port})->run();
        
        std::cout << "REST API server running on http://0.0.0.0:8080\n";
        
        ioc.run();
        
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
        return EXIT_FAILURE;
    }
    
    return EXIT_SUCCESS;
}

이 main만으로는 요청이 라우터에 도달하지 않는다는 점에 주의해야 합니다. Listener는 HttpSession을 소켓만으로 만들고, 2절의 handle_request()는 비어 있기 때문입니다. 실제로 연결하려면 세션이 라우터와 미들웨어 체인을 참조로 들고 있다가 7절의 handle_request_safe를 호출해야 합니다.

// HttpSession에 참조 멤버 추가
Router& router_;
MiddlewareChain& middleware_;

HttpSession(tcp::socket socket, Router& router, MiddlewareChain& mw)
    : stream_(std::move(socket)), router_(router), middleware_(mw) {}

void handle_request() {
    response_ = {};
    response_.version(request_.version());
    response_.keep_alive(request_.keep_alive());
    handle_request_safe(request_, response_, router_, middleware_);
    do_write();
}

// Listener도 Router&, MiddlewareChain&를 받아 세션 생성 시 넘김
std::make_shared<HttpSession>(std::move(socket), router_, middleware_)->start();

라우터와 체인은 main의 지역 변수이고 ioc.run()이 끝날 때까지 살아 있으므로 참조로 넘겨도 안전합니다. 다만 서버가 돌아가는 동안 라우트를 추가하면 요청 처리와 경합이 생기므로, 모든 등록은 run() 전에 끝내야 합니다. 핸들러 안에서 DB 조회처럼 오래 걸리는 동기 작업을 하면 스레드 하나짜리 io_context 전체가 멈춰 다른 연결도 응답하지 못한다는 점도 기억해야 합니다. 이런 작업은 별도 스레드 풀로 넘기고, 결과가 나오면 net::post로 세션의 strand에 돌아와 응답을 쓰는 구조가 필요합니다.


성능 측정

wrk로 측정하기

# 설치
brew install wrk  # macOS
sudo apt install wrk  # Ubuntu
# 테스트
wrk -t4 -c100 -d30s http://localhost:8080/api/users

결과를 읽는 법

언어별 “요청/초” 비교표는 인터넷에 많지만, 핸들러가 하는 일, 로깅 여부, 빌드 옵션, 연결 수에 따라 수치가 몇 배씩 달라지므로 다른 사람의 표를 그대로 믿기보다는 자신의 핸들러로 직접 재는 것이 유일하게 의미 있는 방법입니다. wrk 결과에서는 평균 지연보다 --latency 옵션으로 얻는 99퍼센타일 지연을 보는 것이 중요합니다. 단일 스레드 io_context에서는 핸들러 하나가 느리면 뒤에 줄 선 모든 요청의 꼬리 지연이 늘어나기 때문에, 평균은 멀쩡한데 p99만 크게 튀는 형태로 나타납니다.

측정할 때 자주 하는 실수는 디버그 빌드로 재는 것입니다. cmake -B build는 빌드 타입을 지정하지 않으면 최적화 없이 컴파일되어 Beast와 nlohmann/json처럼 템플릿이 많은 코드가 크게 느려지므로, -DCMAKE_BUILD_TYPE=Release를 반드시 붙여야 합니다. 또 로깅 미들웨어가 std::cout에 매 요청을 쓰면 터미널 출력 자체가 병목이 되어 서버가 아니라 콘솔 속도를 재게 됩니다. wrk와 서버를 같은 머신에서 돌리면 둘이 CPU를 나눠 쓰므로, 가능하면 부하 생성기는 다른 머신에 둡니다.

최적화 팁

  1. Keep-Alive 사용: 연결 재사용으로 3-way handshake 제거
  2. JSON 파싱 최소화: 필요한 필드만 파싱
  3. 스레드 풀: io_context 여러 스레드에서 실행
  4. 커넥션 풀: DB 연결 재사용

프로덕션 배포

체크리스트

  • 로깅: 구조화된 로그 (JSON, spdlog)
  • 에러 처리: 전역 예외 핸들러
  • CORS: 특정 도메인만 허용
  • 인증: JWT 검증
  • Rate Limiting: 요청 제한
  • HTTPS: SSL/TLS 인증서
  • Health Check: /health 엔드포인트
  • Graceful Shutdown: SIGTERM 처리

Graceful Shutdown

#include <csignal>
// Listener에 추가: 새 연결 수락 중단
// void stop() {
//     net::post(acceptor_.get_executor(),
//         [self = shared_from_this()] { self->acceptor_.close(); });
// }
int main() {
    net::io_context ioc;
    
    // 서버 설정...
    auto listener = std::make_shared<Listener>(ioc, tcp::endpoint{address, port});
    listener->run();
    
    // 시그널을 io_context 이벤트로 받음
    net::signal_set signals(ioc, SIGINT, SIGTERM);
    signals.async_wait([&](beast::error_code const&, int) {
        std::cout << "Shutting down gracefully...\n";
        listener->stop();  // 새 연결 거부, 진행 중인 요청은 계속 처리
    });
    
    // 처리할 작업이 모두 끝나면 run()이 반환됨
    ioc.run();
    return 0;
}

처음 이 부분을 짤 때 흔히 쓰는 방식은 시그널 핸들러에서 전역 플래그를 세우고 while (!flag) ioc.run_one();으로 도는 것인데, 여기에는 함정이 있습니다. run_one()은 처리할 이벤트가 생길 때까지 블록하므로, 요청이 없는 한가한 시간에 SIGTERM이 오면 다음 요청이 들어올 때까지 루프가 플래그를 확인하지 못합니다. Kubernetes는 SIGTERM 후 기본 30초가 지나면 SIGKILL을 보내므로, 결과적으로 “우아한 종료”가 아니라 강제 종료가 됩니다. 또 루프를 빠져나와 ioc.stop()을 부르면 진행 중인 요청까지 즉시 끊깁니다.

net::signal_set은 시그널을 일반 비동기 이벤트로 바꿔 주므로 이 문제가 없습니다. 위 코드는 acceptor만 닫아 새 연결을 거부하고, 진행 중인 세션은 응답을 마친 뒤 스스로 끝나게 둡니다. 모든 세션이 사라지면 io_context에 남은 작업이 없어 run()이 자연히 반환됩니다. 단, Keep-Alive 연결은 클라이언트가 끊지 않는 한 계속 다음 요청을 기다리므로, 2절에서 설명한 expires_after 타임아웃을 두어야 종료가 무한히 늘어지지 않습니다. 종료 신호를 받은 뒤부터는 응답에 keep_alive(false)를 설정해 클라이언트가 연결을 닫도록 유도하는 방법도 함께 쓰입니다.

Nginx 리버스 프록시

upstream api_backend {
    server localhost:8080;
    server localhost:8081;
    server localhost:8082;
}
server {
    listen 80;
    server_name api.example.com;
    
    location /api/ {
        proxy_pass http://api_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Docker 배포

FROM ubuntu:22.04 AS builder
RUN apt-get update && apt-get install -y \
    g++ cmake libboost-all-dev nlohmann-json3-dev
WORKDIR /app
COPY . .
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y libboost-system1.74.0
COPY --from=builder /app/build/api_server /usr/local/bin/
EXPOSE 8080
CMD ["api_server"]

참고 자료


자주 묻는 질문 (FAQ)

Q. curl로는 되는데 브라우저에서만 CORS 에러가 나는 이유는 무엇인가요?

A. 브라우저는 다른 출처로 Authorization 헤더나 JSON Content-Type을 담은 요청을 보내기 전에 OPTIONS preflight 요청을 먼저 보냅니다. 서버가 OPTIONS를 라우팅하지 않거나 Access-Control-Allow-* 헤더를 돌려주지 않으면 브라우저는 실제 요청을 보내지 않습니다. curl은 CORS 검사를 하지 않아서 문제가 드러나지 않으므로, OPTIONS에 204와 허용 헤더를 응답하고 프로덕션에서는 * 대신 허용할 도메인을 명시합니다.

Q. Node.js/Python으로 만든 API보다 빠른가요?

A. 핸들러가 단순한 JSON 응답이라면 C++ 서버가 요청당 CPU와 메모리를 적게 쓰는 편이지만, 실제 API의 응답 시간은 대부분 DB 조회와 외부 호출이 차지해서 언어 차이가 체감되지 않는 경우가 많습니다. C++를 택하는 이유는 보통 속도 자체보다 “기존 C++ 엔진을 같은 프로세스에서 바로 호출할 수 있다”는 점이며, 성능이 목적이라면 자신의 핸들러로 Release 빌드를 직접 측정해 판단해야 합니다. Beast·Router·미들웨어로 확장 가능한 REST API 서버를 구현할 수 있습니다. 다음 글: [C++ 실전 가이드 #31-3] 데이터베이스 연동: SQLite와 PostgreSQL 이전 글: [C++ 실전 가이드 #31-1] 채팅 서버 만들기: 다중 클라이언트와 메시지 브로드캐스트


같이 보면 좋은 글