C++ REST API 클라이언트: cpr·cpp-httplib·libcurl 비교, 인증, 재시도와 서킷 브레이커

HTTP 요청을 보내는 것 자체보다 어려운 부분은 그다음입니다. {"data": null} 같은 응답에서 필드에 바로 접근하면 예외가 나고, 만료된 토큰으로 같은 요청을 재시도하면 401만 반복되며, 4xx와 5xx를 구분하지 않으면 재시도해도 소용없는 요청과 재시도해야 하는 요청이 뒤섞입니다. 타임아웃을 정하지 않으면 응답하지 않는 서버 하나가 호출 스레드를 붙잡습니다.

이 글은 앞 글의 HTTP 클라이언트와 JSON 파싱을 묶어 GET·POST·PUT·DELETE를 호출하는 REST 클라이언트를 만들고, Bearer 토큰·API 키·Basic 인증 주입, 연결 실패와 HTTP 4xx/5xx·파싱 에러의 분류, 재시도와 회로 차단기를 차례로 붙입니다. 먼저 전송 계층으로 무엇을 쓸지부터 정리합니다.

전송 계층 선택: cpr, cpp-httplib, libcurl

이 글의 RestApiClient는 구조를 보여 주기 위해 #21-1의 소켓 기반 클라이언트 위에 올렸지만, 실제 서비스에서 HTTP/1.1 파싱과 TLS를 직접 구현하는 것은 권하지 않습니다. 청크 전송 인코딩, 리다이렉트, 압축, 인증서 검증, 프록시 같은 세부 사항을 모두 올바르게 처리하기가 어렵기 때문입니다. 보통은 아래 세 라이브러리 중 하나를 request()의 내부 구현으로 쓰고, 이 글에서 다루는 인증·에러 분류·재시도 계층을 그 위에 얹습니다.

cpr는 libcurl을 C++17 인터페이스로 감싼 라이브러리입니다. 옵션을 타입이 있는 인자로 넘기기 때문에 읽기 쉽고, 네트워크 에러와 HTTP 상태를 구분해서 돌려줍니다.

#include <cpr/cpr.h>
cpr::Response r = cpr::Get(cpr::Url{"https://api.example.com/users/1"},
                           cpr::Bearer{"token"},
                           cpr::Header{{"Accept", "application/json"}},
                           cpr::Timeout{5000});           // 밀리초
if (r.error) {                                            // DNS 실패, 타임아웃, TLS 오류 등
    std::cerr << "network error: " << r.error.message << "\n";
} else if (r.status_code >= 400) {                        // HTTP 에러
    std::cerr << "HTTP " << r.status_code << ": " << r.text << "\n";
}

r.error가 설정되어 있으면 r.status_code는 0입니다. 이 둘을 구분하지 않고 status_code != 200만 검사하면 “서버가 에러를 줬다”와 “서버에 닿지도 못했다”가 같은 로그로 남아 원인 파악이 늦어집니다. r.header는 대소문자를 구분하지 않는 맵이라 r.header["content-type"]으로 읽어도 됩니다.

cpp-httplib는 헤더 파일 하나로 끝나서 의존성 관리가 가장 쉽습니다.

#define CPPHTTPLIB_OPENSSL_SUPPORT   // HTTPS에 필요 (OpenSSL 링크)
#include <httplib.h>
httplib::Client cli("https://api.example.com");
cli.set_bearer_token_auth("token");
cli.set_connection_timeout(5);       // 초
cli.set_read_timeout(10);
if (auto res = cli.Get("/users/1")) {
    std::cout << res->status << " " << res->body << "\n";
} else {
    std::cerr << "error: " << httplib::to_string(res.error()) << "\n";
}

처음 쓸 때 흔히 막히는 부분은 CPPHTTPLIB_OPENSSL_SUPPORT 매크로입니다. 이 매크로 없이 https:// URL로 Client를 만들면 요청이 전송조차 되지 않고 에러로 끝나는데, 에러 메시지만으로는 원인을 짐작하기 어렵습니다. 블로킹 I/O라 요청마다 스레드가 대기하므로, 동시에 많은 요청을 보내야 하는 서비스보다는 도구, 테스트, 소규모 내부 통신에 잘 맞습니다.

libcurl을 직접 쓰는 것은 C API라 코드가 길어지지만, curl_multi 인터페이스로 한 스레드에서 수천 개의 동시 요청을 다루거나 연결 재사용, HTTP/2 멀티플렉싱을 세밀하게 제어할 수 있습니다. cpr도 내부적으로 libcurl을 쓰므로, cpr로 시작했다가 이런 제어가 필요해지는 지점에서 해당 부분만 libcurl로 내려가는 방식이 현실적입니다. 어느 쪽이든 타임아웃은 기본값에 맡기지 말고 명시해야 합니다. libcurl의 전체 전송 타임아웃(CURLOPT_TIMEOUT) 기본값은 0, 즉 “무제한”이라, 응답하지 않는 서버 하나가 호출 스레드를 영원히 붙잡을 수 있습니다.

전형적인 실패 패턴과 해결 아키텍처

전형적인 실패 패턴

sequenceDiagram
  participant App as 애플리케이션
  participant Client as REST API 클라이언트
  participant Server as 외부 API 서버
  App->>Client: GET /api/users
  Client->>Server: HTTP 요청 (인증 없음?)
  Server-->>Client: 401 Unauthorized
  Note over Client: 토큰 없음, 재시도해도 401
  Client-->>App: 실패 (원인 불명)
  App->>Client: POST /api/orders
  Client->>Server: JSON 본문
  Server-->>Client: 200 OK {"data": {...}}
  Client->>Client: j[data] 접근 → 예외
  Note over Client: data가 null이거나 형식 다름

위 흐름에서는 인증 헤더가 빠져 401이 반복되고, 응답 형식을 가정한 접근이 예외를 일으키며, 호출자는 어느 쪽 실패인지 알 수 없습니다. 아래 구조는 인증 헤더를 요청마다 주입하고, 상태 코드에 따라 파싱·에러 반환·재시도로 갈라지게 합니다.

해결 아키텍처

flowchart TB
  subgraph 개선된 흐름
    A[요청] --> B[인증 헤더 주입]
    B --> C[HTTP 요청 전송]
    C --> D[응답 수신]
    D --> E{상태 코드}
    E -->|2xx| F[JSON 파싱]
    E -->|3xx| G[리다이렉트 처리]
    E -->|4xx| H[클라이언트 에러]
    E -->|5xx| I[재시도 가능?]
    F --> J[결과 반환]
    H --> K[에러 분류 반환]
    I -->|Yes| L[재시도]
    I -->|No| K
  end

클라이언트가 구분해야 하는 응답들

실제 API는 성공과 실패의 응답 구조가 다른 경우가 많습니다. 결제 API가 성공하면 {"transaction_id": "tx_123"}을, 실패하면 {"error": "invalid_card"}를 돌려준다면, 본문을 파싱하기 전에 상태 코드로 먼저 갈라야 합니다. 같은 4xx·5xx라도 의미가 다릅니다. 내부 서비스가 503을 돌려주면 일시적 장애이니 재시도할 가치가 있지만, 400은 요청 자체가 잘못된 것이라 몇 번을 보내도 결과가 같습니다. 401은 토큰 만료일 수 있으므로 갱신 후 한 번 재시도하고, 429는 Retry-After 헤더가 있으면 그 시간을 지켜야 합니다.

성공 응답이라도 형식을 가정하면 안 됩니다. 페이지네이션 응답 {"data": [...], "total": 5000}에서 data가 빈 배열이거나 total이 빠질 수 있으므로, null과 누락 필드를 처리하는 코드가 필요합니다. HTTP 응답이 아예 오지 않는 경우(연결 거부, 연결·읽기 타임아웃, TLS 인증서 검증 실패)는 상태 코드가 없으므로 별도의 에러 종류로 다뤄야 합니다.


구성 요소와 HTTP 응답 래퍼

구성 요소

구성 요소역할
HTTP 클라이언트TCP 연결, 요청 전송, 응답 수신
JSON 직렬화요청 본문 생성, 응답 파싱
인증 모듈토큰·API 키·Basic 헤더 주입
에러 처리연결 실패, HTTP 에러, JSON 파싱 에러 분류

HTTP 응답 래퍼

#include <string>
#include <map>
struct ApiResponse {
    int status_code;
    std::string status_message;
    std::map<std::string, std::string> headers;
    std::string body;  // JSON 문자열
    bool is_success() const {
        return status_code >= 200 && status_code < 300;
    }
    bool is_client_error() const {
        return status_code >= 400 && status_code < 500;
    }
    bool is_server_error() const {
        return status_code >= 500;
    }
    bool is_retryable() const {
        return status_code == 429 || status_code >= 500;
    }
};

is_retryable()이 5xx 전체를 재시도 대상으로 보는 것은 단순화입니다. 502·503·504는 게이트웨이나 과부하처럼 일시적인 경우가 많지만, 500은 서버 코드의 버그로 같은 요청을 몇 번 보내도 똑같이 실패하는 경우가 흔하고, 501(Not Implemented)은 절대 성공하지 않습니다. 또 HTTP 응답이 오지 않은 연결 실패·타임아웃은 이 구조체로는 표현되지 않으므로(상태 코드 0 등), 전송 계층 에러를 별도 필드로 두는 편이 분류가 정확해집니다.

아키텍처 다이어그램

flowchart LR
  subgraph 클라이언트
    A[RestApiClient] --> B[HTTP Layer]
    A --> C[Auth Layer]
    A --> D[Error Handler]
    B --> E[connectToHost]
    B --> F[send/recv]
    C --> G[Bearer/API Key/Basic]
  end
  B --> H[외부 API 서버]

기본 REST 클라이언트 클래스와 URL 파싱

의존성

HTTP 클라이언트는 #21-1 HTTP 클라이언트의 connectToHost, buildRequest, readAll, parseResponse 등을 사용합니다. JSON은 nlohmann/json을 사용합니다.

# vcpkg로 nlohmann-json 설치
vcpkg install nlohmann-json
#include <nlohmann/json.hpp>
using json = nlohmann::json;

기본 REST 클라이언트 클래스

#include <string>
#include <map>
class RestApiClient {
public:
    RestApiClient(const std::string& base_url, int timeout_sec = 10)
        : base_url_(base_url), timeout_sec_(timeout_sec) {}
    ApiResponse get(const std::string& path,
                    const std::map<std::string, std::string>& extra_headers = {}) {
        return request("GET", path, "", extra_headers);
    }
    ApiResponse post(const std::string& path, const std::string& json_body,
                     const std::map<std::string, std::string>& extra_headers = {}) {
        return request("POST", path, json_body, extra_headers);
    }
    ApiResponse put(const std::string& path, const std::string& json_body,
                    const std::map<std::string, std::string>& extra_headers = {}) {
        return request("PUT", path, json_body, extra_headers);
    }
    ApiResponse del(const std::string& path,
                    const std::map<std::string, std::string>& extra_headers = {}) {
        return request("DELETE", path, "", extra_headers);
    }
private:
    std::string base_url_;
    int timeout_sec_;
    ApiResponse request(const std::string& method, const std::string& path,
                        const std::string& body,
                        const std::map<std::string, std::string>& extra_headers) {
        ApiResponse res;
        // parse base_url_ → host, port, base_path
        // connectToHost, buildRequest, send, readAll, parseResponse
        // ApiResponse로 변환하여 반환
        return res;
    }
};

URL 파싱

#include <cstdint>
#include <regex>
#include <string>
#include <tuple>
std::tuple<std::string, uint16_t, std::string> parseUrl(const std::string& url) {
    // http://example.com:8080/api/v1 → ("example.com", 8080, "/api/v1")
    std::regex re(R"(^(?:https?://)?([^:/]+)(?::(\d+))?(/.*)?$)");
    std::smatch m;
    if (!std::regex_match(url, m, re)) {
        return {"", 80, ""};
    }
    std::string host = m[1].str();
    uint16_t port = m[2].matched ? static_cast<uint16_t>(std::stoi(m[2].str())) : 80;
    std::string path = m[3].matched ? m[3].str() : "/";
    return {host, port, path};
}

이 정규식 파서는 예제용이라 한계가 뚜렷합니다. https://로 시작해도 기본 포트를 80으로 돌려주므로 HTTPS에서는 443으로 바꿔야 하고, [::1] 같은 IPv6 주소, user:pass@host 형태의 인증 정보, 쿼리 문자열의 ?를 경로와 구분하지 못합니다. std::stoi는 포트가 65535를 넘어도 uint16_t로 조용히 잘려 들어갑니다. 실제 코드에서는 사용하는 HTTP 라이브러리의 URL 처리(libcurl의 curl_url API 등)를 쓰는 편이 안전합니다.

JSON 기본 헤더

std::map<std::string, std::string> default_json_headers() {
    return {
        {"Content-Type", "application/json"},
        {"Accept", "application/json"}
    };
}

GET, POST, PUT, DELETE 요청 예제

사용자 API 예제 (JSON Placeholder 스타일)

{
  "id": 1,
  "name": "Leanne Graham",
  "email": "user@example.com",
  "company": {
    "name": "Acme Corp"
  }
}

GET (조회)

#include <nlohmann/json.hpp>
#include <iostream>
void example_get_user(RestApiClient& client) {
    ApiResponse res = client.get("/users/1");
    if (!res.is_success()) {
        std::cerr << "GET failed: " << res.status_code << " " << res.status_message << "\n";
        return;
    }
    try {
        json j = json::parse(res.body);
        std::string name = j["name"].get<std::string>();
        std::string email = j["email"].get<std::string>();
        std::cout << "User: " << name << " (" << email << ")\n";
        if (j.contains("company") && j["company"].contains("name")) {
            std::cout << "Company: " << j["company"]["name"].get<std::string>() << "\n";
        }
    } catch (const json::exception& e) {
        std::cerr << "JSON parse error: " << e.what() << "\n";
    }
}

POST (생성)

void example_create_user(RestApiClient& client) {
    json body;
    body["name"] = "John Doe";
    body["email"] = "john@example.com";
    body["status"] = "active";
    std::map<std::string, std::string> headers;
    headers["Content-Type"] = "application/json";
    ApiResponse res = client.post("/users", body.dump(), headers);
    if (!res.is_success()) {
        std::cerr << "POST failed: " << res.status_code << "\n";
        std::cerr << "Response: " << res.body << "\n";
        return;
    }
    try {
        json j = json::parse(res.body);
        int id = j["id"].get<int>();
        std::cout << "Created user with id: " << id << "\n";
    } catch (const json::exception& e) {
        std::cerr << "JSON parse error: " << e.what() << "\n";
    }
}

PUT (수정)

void example_update_user(RestApiClient& client, int user_id) {
    json body;
    body["name"] = "Jane Doe";
    body["email"] = "jane@example.com";
    std::map<std::string, std::string> headers;
    headers["Content-Type"] = "application/json";
    std::string path = "/users/" + std::to_string(user_id);
    ApiResponse res = client.put(path, body.dump(), headers);
    if (!res.is_success()) {
        std::cerr << "PUT failed: " << res.status_code << "\n";
        return;
    }
    try {
        json j = json::parse(res.body);
        std::cout << "Updated: " << j["name"].get<std::string>() << "\n";
    } catch (const json::exception& e) {
        std::cerr << "JSON parse error: " << e.what() << "\n";
    }
}

DELETE (삭제)

void example_delete_user(RestApiClient& client, int user_id) {
    std::string path = "/users/" + std::to_string(user_id);
    ApiResponse res = client.del(path);
    if (res.is_success()) {
        std::cout << "User " << user_id << " deleted\n";
    } else if (res.status_code == 404) {
        std::cerr << "User not found\n";
    } else {
        std::cerr << "DELETE failed: " << res.status_code << "\n";
    }
}

CRUD 흐름 시퀀스

sequenceDiagram
  participant App as 애플리케이션
  participant Client as RestApiClient
  participant Server as API 서버
  App->>Client: get("/users/1")
  Client->>Server: GET /users/1 HTTP/1.1
  Server-->>Client: 200 OK {"id":1,"name":"..."}
  Client-->>App: ApiResponse
  App->>Client: post("/users", json_body)
  Client->>Server: POST /users HTTP/1.1 + JSON
  Server-->>Client: 201 Created {"id":2}
  Client-->>App: ApiResponse
  App->>Client: put("/users/2", json_body)
  Client->>Server: PUT /users/2 HTTP/1.1 + JSON
  Server-->>Client: 200 OK
  Client-->>App: ApiResponse
  App->>Client: del("/users/2")
  Client->>Server: DELETE /users/2 HTTP/1.1
  Server-->>Client: 200 OK
  Client-->>App: ApiResponse

쿼리 파라미터

std::string build_query(const std::map<std::string, std::string>& params) {
    if (params.empty()) return "";
    std::string q = "?";
    bool first = true;
    for (const auto& [k, v] : params) {
        if (!first) q += "&";
        q += k + "=" + v;  // 실제로는 URL 인코딩 필요
        first = false;
    }
    return q;
}
// GET /users?page=1&limit=10
void example_list_users(RestApiClient& client) {
    std::map<std::string, std::string> params{{"page", "1"}, {"limit", "10"}};
    std::string path = "/users" + build_query(params);
    ApiResponse res = client.get(path);
    // ...
}

build_query의 주석대로 값에 공백, &, =, 한글이 들어가면 반드시 퍼센트 인코딩해야 합니다. 검색어 "C++ & Rust"를 그대로 붙이면 서버는 &에서 쿼리를 잘라 q=C++ 와 Rust라는 두 조각으로 해석하고, 폼 인코딩 규칙에서는 +가 공백으로 읽히므로 q의 값은 “C” 뒤에 공백이 붙은 문자열이 됩니다. libcurl의 curl_easy_escape, cpr의 cpr::Parameters처럼 라이브러리가 인코딩까지 해 주는 기능을 쓰는 것이 가장 확실합니다.


Bearer 토큰, API 키, 401 토큰 갱신

Bearer 토큰

class AuthenticatedRestClient : public RestApiClient {
public:
    AuthenticatedRestClient(const std::string& base_url,
                            const std::string& bearer_token,
                            int timeout_sec = 10)
        : RestApiClient(base_url, timeout_sec), bearer_token_(bearer_token) {}
    ApiResponse get(const std::string& path,
                    const std::map<std::string, std::string>& extra_headers = {}) {
        auto headers = extra_headers;
        headers["Authorization"] = "Bearer " + bearer_token_;
        return RestApiClient::get(path, headers);
    }
    // post, put, del도 동일하게 Authorization 헤더 추가
private:
    std::string bearer_token_;
};

API 키 (헤더)

void add_api_key_header(std::map<std::string, std::string>& headers,
                        const std::string& api_key,
                        const std::string& header_name = "X-API-Key") {
    headers[header_name] = api_key;
}
// 사용 예
std::map<std::string, std::string> headers;
add_api_key_header(headers, "sk_live_abc123");
ApiResponse res = client.get("/api/data", headers);

Basic 인증

#include <cstdint>
std::string base64_encode(const std::string& input) {
    static const char* chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    std::string result;
    int val = 0, valb = -6;
    for (unsigned char c : input) {
        val = (val << 8) + c;
        valb += 8;
        while (valb >= 0) {
            result.push_back(chars[(val >> valb) & 0x3F]);
            valb -= 6;
        }
    }
    if (valb > -6) result.push_back(chars[((val << 8) >> (valb + 8)) & 0x3F]);
    while (result.size() % 4) result.push_back('=');
    return result;
}
void add_basic_auth(std::map<std::string, std::string>& headers,
                    const std::string& username, const std::string& password) {
    std::string cred = username + ":" + password;
    headers["Authorization"] = "Basic " + base64_encode(cred);
}

토큰 갱신 (401 처리)

class TokenRefreshClient {
public:
    bool get_with_refresh(const std::string& path, ApiResponse& out) {
        out = client_.get(path, auth_headers());
        if (out.status_code == 401 && !refresh_token_.empty()) {
            if (refresh_token()) {
                out = client_.get(path, auth_headers());
            }
        }
        return out.is_success();
    }
private:
    std::map<std::string, std::string> auth_headers() {
        std::map<std::string, std::string> h;
        h["Authorization"] = "Bearer " + access_token_;
        return h;
    }
    bool refresh_token() {
        json body;
        body["refresh_token"] = refresh_token_;
        ApiResponse res = client_.post("/auth/refresh", body.dump());
        if (!res.is_success()) return false;
        try {
            json j = json::parse(res.body);
            access_token_ = j["access_token"].get<std::string>();
            return true;
        } catch (...) {
            return false;
        }
    }
    RestApiClient client_;
    std::string access_token_;
    std::string refresh_token_;
};

이 클래스는 단일 스레드 기준의 뼈대입니다. 여러 스레드가 같은 클라이언트를 공유하는 서버에서 토큰이 만료되면, 그 순간 진행 중이던 요청들이 동시에 401을 받고 각자 /auth/refresh를 호출합니다. 리프레시 토큰을 한 번 쓰면 폐기하는(rotation) 인증 서버에서는 첫 번째 갱신만 성공하고 나머지는 모두 실패해서, 멀쩡한 세션이 로그아웃되는 증상으로 나타납니다. 이를 막으려면 갱신을 뮤텍스로 감싸고, 락을 잡은 뒤 “401을 받을 때 쓴 토큰이 아직 현재 토큰과 같은지”를 확인해, 다른 스레드가 이미 갱신했다면 갱신 요청 없이 새 토큰으로 바로 재시도하게 합니다. 가능하다면 응답의 expires_in을 저장해 두고 만료 직전에 미리 갱신하는 것이 401 자체를 줄이는 더 나은 방법입니다.


에러 타입과 Result로 실패 다루기

에러 타입

enum class ApiErrorType {
    None,
    ConnectionFailed,
    Timeout,
    HttpError,
    JsonParseError,
    InvalidResponse
};
struct ApiError {
    ApiErrorType type;
    int status_code;
    std::string message;
    std::string raw_body;
};

Result 타입

#include <variant>
template<typename T>
using ApiResult = std::variant<T, ApiError>;
ApiResult<json> get_json_safe(RestApiClient& client, const std::string& path) {
    ApiResponse res = client.get(path);
    if (!res.is_success()) {
        return ApiError{
            ApiErrorType::HttpError,
            res.status_code,
            res.status_message,
            res.body
        };
    }
    try {
        return json::parse(res.body);
    } catch (const json::exception& e) {
        return ApiError{
            ApiErrorType::JsonParseError,
            0,
            e.what(),
            res.body
        };
    }
}
// 사용 예
auto result = get_json_safe(client, "/users/1");
if (std::holds_alternative<json>(result)) {
    json j = std::get<json>(result);
    std::cout << j["name"] << "\n";
} else {
    ApiError err = std::get<ApiError>(result);
    std::cerr << "Error: " << static_cast<int>(err.type)
              << " " << err.message << "\n";
}

예외 대신 variant로 실패를 돌려주는 이유는 네트워크 실패가 예외적인 상황이 아니기 때문입니다. 외부 API 호출에서 타임아웃과 5xx는 언제든 일어날 수 있는 정상적인 경로이고, 반환 타입으로 실패를 드러내면 호출자가 처리를 빠뜨리기 어렵습니다. std::thread에서 잡히지 않은 예외는 std::terminate로 프로세스 전체를 끝내므로, 예외를 잡는 걸 깜빡한 대가가 큽니다. C++23을 쓸 수 있다면 std::expected<json, ApiError>가 같은 역할을 더 자연스럽게 합니다. has_value(), value(), error()로 의도가 분명하고, and_then으로 후속 처리를 연결할 수 있습니다.

에러 처리 흐름

flowchart TB
  subgraph 에러 처리
    A[요청] --> B{연결}
    B -->|실패| C[ConnectionFailed]
    B -->|성공| D[HTTP 응답]
    D --> E{상태 코드}
    E -->|2xx| F[JSON 파싱]
    E -->|4xx/5xx| G[HttpError]
    F --> H{파싱}
    H -->|성공| I[결과 반환]
    H -->|실패| J[JsonParseError]
    G --> K[에러 반환]
    C --> K
    J --> K
  end

JSON parse error, 401 반복, 429 Rate Limit 같은 에러

”JSON parse error: parse error at line 1”

응답 본문이 JSON이 아닐 때 납니다. 프록시나 게이트웨이가 돌려준 HTML 에러 페이지, 빈 본문(204 No Content 등)이 흔한 원인입니다. 파싱 전에 본문과 Content-Type을 확인하고, 실패하면 원문을 로그에 남깁니다.

// ❌ 잘못된 예
json j = json::parse(res.body);  // body가 HTML이면 예외
// ✅ 올바른 예 (헤더 이름은 파싱할 때 소문자로 정규화해 두었다고 가정)
if (res.body.empty()) return;
auto it = res.headers.find("content-type");
if (it != res.headers.end() && it->second.find("application/json") == std::string::npos) {
    return;  // JSON이 아님
}
try {
    json j = json::parse(res.body);
} catch (const json::exception& e) {
    std::cerr << "Parse error: " << e.what() << "\nRaw: " << res.body << "\n";
}

”type must be string, but is null” (type_error.302)

API가 {"data": null}이나 {"error": "..."}를 돌려준 경우입니다. nlohmann/json에서 비const 객체의 j["data"]는 키가 없으면 null 값을 새로 삽입하고, 그 null에 get<std::string>()을 호출하면 [json.exception.type_error.302] type must be string, but is null이 납니다. j.at("data")로 접근했다면 [json.exception.out_of_range.403] key 'data' not found가 납니다. const 객체에서 없는 키로 operator[]를 쓰면 예외 없이 assertion 실패(미정의 동작)이므로 특히 위험합니다. contains()와 is_null()로 먼저 확인하거나 value()로 기본값을 지정합니다.

// ❌ 잘못된 예
std::string name = j["data"]["name"].get<std::string>();
// ✅ 올바른 예
if (j.contains("data") && !j["data"].is_null()) {
    if (j["data"].contains("name")) {
        std::string name = j["data"]["name"].get<std::string>();
    }
}

401 Unauthorized 반복

토큰이 만료되었거나 Authorization 헤더가 빠진 경우입니다. 같은 요청을 그대로 반복하면 401만 계속 받으므로, 토큰을 갱신한 뒤 한 번만 재시도합니다.

// ❌ 잘못된 예: 무한 재시도
while (!res.is_success()) {
    res = client.get(path);
}
// ✅ 올바른 예: 401 시 토큰 갱신 후 1회 재시도
if (res.status_code == 401) {
    if (refresh_token()) {
        res = client.get(path, auth_headers());
    }
}

Content-Type 누락

POST·PUT 본문을 JSON으로 보내면서 Content-Type을 빠뜨리면, 서버 프레임워크가 본문을 JSON으로 해석하지 않아 415나 400, 혹은 빈 입력으로 처리되는 결과가 나옵니다.

// ❌ 잘못된 예
client.post("/users", body.dump());
// ✅ 올바른 예
std::map<std::string, std::string> headers;
headers["Content-Type"] = "application/json";
client.post("/users", body.dump(), headers);

타임아웃 후 소켓 누수

타임아웃이나 예외로 함수를 일찍 빠져나가는 경로마다 close()를 부르기는 어렵습니다. 소켓을 RAII 객체로 감싸 소멸자에서 닫게 하면 어느 경로로 나가든 누수가 생기지 않습니다.

#include <unistd.h>  // close (POSIX)
class Socket {
public:
    explicit Socket(int fd) : fd_(fd) {}
    ~Socket() { if (fd_ >= 0) ::close(fd_); }
    Socket(const Socket&) = delete;             // 복사되면 두 번 닫힘
    Socket& operator=(const Socket&) = delete;
    int get() const { return fd_; }
private:
    int fd_ = -1;
};

인코딩 문제 (한글 등)

JSON은 RFC 8259에 따라 UTF-8로 주고받아야 하며, application/json에는 charset 매개변수가 정의되어 있지 않습니다. 한글이 깨진다면 서버가 다른 인코딩으로 보냈거나, 클라이언트가 바이트를 로캘 인코딩(예: Windows의 CP949)으로 출력하는 경우가 대부분입니다. 본문 바이트는 UTF-8 그대로 다루고, 콘솔 출력 단계의 인코딩을 확인합니다.

429 Too Many Requests (Rate Limit)

API 호출 빈도 제한을 넘긴 경우이며, 서버가 Retry-After 헤더로 대기 시간을 알려 줄 수 있습니다. 헤더가 있으면 그만큼 기다린 뒤 재시도하고, 없으면 지수 백오프로 대기합니다. HTTP 헤더 이름은 대소문자를 구분하지 않으므로, 파싱할 때 헤더 이름을 소문자로 정규화해 맵에 넣어야 "Retry-After"로 온 헤더를 놓치지 않습니다. 또 Retry-After는 초 단위 숫자 대신 Wed, 21 Oct 2026 07:28:00 GMT 같은 날짜 형식으로 올 수도 있어서, std::stoi가 실패하는 경우의 기본값을 반드시 정해 두어야 합니다. 서버가 터무니없이 큰 값을 보낼 수 있으므로 최대 대기 시간에 상한을 두는 것도 좋습니다.

Connection refused / Connection timeout

서버가 내려가 있거나, 방화벽이 막고 있거나, 호스트·포트가 잘못된 경우입니다. HTTP 응답이 없으므로 connectToHost 실패를 ApiError{ApiErrorType::ConnectionFailed, ...}처럼 별도 종류로 돌려주고, 연결 타임아웃을 반드시 설정합니다.

SSL/TLS 인증서 검증 실패

자체 서명 인증서, 만료된 인증서, 호스트명 불일치가 원인입니다. 검증을 끄는 옵션(CURLOPT_SSL_VERIFYPEER = 0 등)을 환경 변수로 켤 수 있게 만들어 두면 그 설정이 운영 환경까지 새어 나가기 쉽습니다. 개발 환경에서도 검증은 켜 둔 채, 개발용 CA 인증서를 신뢰 저장소나 CURLOPT_CAINFO로 등록하는 편이 안전합니다.


타임아웃, 멱등성, 안전한 JSON 접근

기본 헤더

headers["Content-Type"] = "application/json";
headers["Accept"] = "application/json";
headers["User-Agent"] = "MyApp/1.0";

안전한 JSON 접근

#include <optional>
template<typename T>
std::optional<T> safe_get(const json& j, const std::string& key) {
    if (!j.contains(key)) return std::nullopt;
    try {
        return j[key].get<T>();
    } catch (...) {
        return std::nullopt;
    }
}

타임아웃 값 정하기

연결 타임아웃과 읽기(또는 전체) 타임아웃은 따로 정합니다. 연결은 정상이라면 네트워크 왕복 몇 번이면 끝나므로 짧게 잡고, 읽기 타임아웃은 상대 API의 평소 응답 시간 분포(특히 p99)를 보고 그보다 넉넉하게 잡습니다. 재시도를 한다면 “타임아웃 × 시도 횟수 + 대기 시간”이 호출자가 기다릴 수 있는 전체 시간 안에 들어오는지도 계산해 두어야 합니다.

요청 ID·멱등성·Rate Limit 헤더

요청마다 X-Request-ID를 붙이면 클라이언트 로그와 서버 로그를 이어서 추적할 수 있습니다. 여러 API가 X-RateLimit-Remaining 같은 헤더로 남은 호출 수를 알려 주므로, 이 값이 낮아지면 요청 속도를 미리 줄일 수 있습니다(헤더 이름은 API마다 다릅니다).

그중 가장 중요한 것은 멱등성입니다. GET, PUT, DELETE는 HTTP 의미상 같은 요청을 여러 번 보내도 결과가 같아야(멱등) 하므로 재시도해도 안전하지만, POST는 그렇지 않습니다. 결제 요청을 보냈는데 응답을 받기 직전에 타임아웃이 나면, 클라이언트는 서버가 결제를 처리했는지 알 수 없습니다. 이때 무작정 재시도하면 이중 결제가 됩니다. Stripe 같은 결제 API가 Idempotency-Key 헤더를 지원하는 이유가 이것으로, 클라이언트가 요청마다 고유한 키(UUID)를 만들어 재시도할 때도 같은 키를 보내면 서버는 두 번째 요청을 새 결제가 아니라 첫 요청의 결과 조회로 처리합니다. 아래 재시도 코드가 GET에만 적용되어 있는 것도 같은 이유입니다.


지수 백오프 재시도와 회로 차단기

재시도 (지수 백오프 + 지터)

#include <thread>
#include <chrono>
ApiResponse get_with_retry(RestApiClient& client, const std::string& path,
                           int max_retries = 3) {
    ApiResponse res = client.get(path);
    for (int i = 0; i < max_retries && res.is_retryable(); ++i) {
        int base_ms = 1000 * (1 << i);  // 1s, 2s, 4s
        int jitter = std::rand() % (base_ms / 4 + 1);  // 동시 재시도 폭주 방지
        int delay_ms = base_ms + jitter;
        // 429 시: res.headers["retry-after"] 존중 (초 단위)
        if (res.status_code == 429) {
            auto it = res.headers.find("retry-after");
            if (it != res.headers.end()) {
                try { delay_ms = std::stoi(it->second) * 1000; } catch (...) {}
            }
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
        res = client.get(path);
    }
    return res;
}

지터를 넣는 이유는 동시에 실패한 클라이언트들이 동시에 재시도하는 것을 막기 위해서입니다. 서버가 잠깐 멈췄다 살아났을 때 수백 개의 클라이언트가 정확히 1초, 2초, 4초 뒤에 일제히 몰려오면 막 복구된 서버가 다시 쓰러집니다. 이 예제의 지터(기본 지연의 0~25%)는 작은 편이고, AWS 아키텍처 블로그에서 널리 소개된 “full jitter” 방식(0 ~ base_ms 사이의 무작위 값)이 분산 효과가 더 큽니다. std::rand()는 시드를 주지 않으면 모든 프로세스가 같은 수열을 내므로, 실제로는 std::mt19937을 std::random_device로 초기화해 쓰는 것이 맞습니다. Retry-After 값도 그대로 믿지 말고 최대 대기 시간으로 잘라 두는 편이 안전합니다. 또 sleep_for는 호출 스레드를 그대로 막으므로, 이벤트 루프나 스레드 풀 위에서 돈다면 타이머로 다음 시도를 예약하는 방식으로 바꿔야 합니다.

회로 차단기

class CircuitBreaker {
public:
    bool allow_request() {
        if (state_ == State::Open) {
            if (std::chrono::steady_clock::now() > next_try_) {
                state_ = State::HalfOpen;
            } else {
                return false;
            }
        }
        return true;
    }
    void record_success() { failures_ = 0; state_ = State::Closed; }
    void record_failure() {
        ++failures_;
        if (failures_ >= threshold_) {
            state_ = State::Open;
            next_try_ = std::chrono::steady_clock::now() + timeout_;
        }
    }
private:
    enum class State { Closed, Open, HalfOpen };
    State state_ = State::Closed;
    int failures_ = 0;
    int threshold_ = 5;
    std::chrono::seconds timeout_{30};
    std::chrono::steady_clock::time_point next_try_;
};

회로 차단기의 목적은 이미 죽어 있는 의존 서비스에 요청을 계속 보내 내 쪽 스레드와 연결을 타임아웃 대기로 소진하지 않는 것입니다. Open 상태에서는 즉시 실패를 돌려주므로 호출자는 캐시된 값이나 기본값으로 빠르게 폴백할 수 있습니다. 이 구현은 개념을 보여 주는 최소 버전이라 실제로 쓰려면 두 가지를 보완해야 합니다. 첫째, 멤버 접근이 동기화되어 있지 않아 여러 스레드가 공유하면 데이터 경쟁입니다(std::mutex로 감싸거나 상태를 std::atomic으로). 둘째, HalfOpen 상태에서 모든 요청을 통과시키므로 복구 확인용 요청 한 개만 보내야 한다는 원래 의도와 다릅니다. HalfOpen에서는 한 요청만 허용하고 나머지는 계속 거절하도록 플래그를 두는 것이 일반적입니다.

또 하나 흔한 실수는 4xx 응답까지 실패로 세는 것입니다. 존재하지 않는 사용자를 조회한 404나 입력 검증에 걸린 400은 서버가 정상적으로 동작하고 있다는 뜻인데, 이것을 실패로 세면 잘못된 요청이 몰리는 것만으로 멀쩡한 서비스에 대한 회로가 열려 버립니다. 아래 통합 코드에서는 5xx와 429만 실패로 기록합니다.

회로 차단기와 REST 클라이언트 통합

// ✅ 회로 차단기로 보호된 요청
ApiResult<json> get_with_circuit_breaker(RestApiClient& client,
                                         CircuitBreaker& cb,
                                         const std::string& path) {
    if (!cb.allow_request())
        return ApiError{ApiErrorType::HttpError, 0, "Circuit open", ""};
    ApiResponse res = client.get(path);
    if (res.is_success()) {
        cb.record_success();
        try { return json::parse(res.body); }
        catch (const json::exception& e) {
            return ApiError{ApiErrorType::JsonParseError, 0, e.what(), res.body};
        }
    }
    if (res.is_retryable()) cb.record_failure();   // 5xx·429만 서버 장애로 간주
    else cb.record_success();                      // 4xx는 서버가 정상 응답한 것
    return ApiError{ApiErrorType::HttpError, res.status_code, res.status_message, res.body};
}

로깅과 메트릭

요청마다 경로, 상태 코드, 걸린 시간을 남겨 두면 느려진 의존 서비스나 늘어난 5xx를 숫자로 확인할 수 있습니다.

ApiResponse get_with_logging(RestApiClient& client, const std::string& path) {
    auto start = std::chrono::steady_clock::now();
    ApiResponse res = client.get(path);
    auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now() - start).count();
    // 로그: path, status_code, elapsed_ms
    return res;
}

통합 예제 (CRUD + 인증 + 에러 + 재시도 + 회로 차단기)

HTTP 클라이언트(#21-1)와 이 글의 패턴을 한 클래스로 묶은 예제입니다. 앞에서 짚은 한계(회로 차단기의 스레드 안전성, 재시도의 지터와 Retry-After, 토큰 갱신)는 그대로 남아 있으므로 실제로 쓰려면 함께 보완해야 합니다.

// ProductionRestClient: 인증 + 회로 차단기 + 재시도 + 에러 처리 통합
#include <chrono>
#include <iostream>
#include <map>
#include <string>
#include <thread>
#include <variant>
#include <nlohmann/json.hpp>
#include "rest_api_client.h"
using json = nlohmann::json;
class ProductionRestClient {
public:
    ProductionRestClient(const std::string& base_url,
                         const std::string& bearer_token, int timeout_sec = 10)
        : client_(base_url, timeout_sec), bearer_token_(bearer_token) {}
    ApiResult<json> get(const std::string& path) {
        if (!cb_.allow_request()) return ApiError{ApiErrorType::HttpError, 0, "Circuit open", ""};
        auto headers = auth_headers();
        ApiResponse res = get_with_retry(path, headers);
        return process_response(res);
    }
    ApiResult<json> post(const std::string& path, const json& body) {
        if (!cb_.allow_request()) return ApiError{ApiErrorType::HttpError, 0, "Circuit open", ""};
        auto headers = auth_headers();
        headers["Content-Type"] = "application/json";
        ApiResponse res = client_.post(path, body.dump(), headers);
        return process_response(res);
    }
    ApiResult<json> put(const std::string& path, const json& body) {
        if (!cb_.allow_request()) return ApiError{ApiErrorType::HttpError, 0, "Circuit open", ""};
        auto headers = auth_headers();
        headers["Content-Type"] = "application/json";
        return process_response(client_.put(path, body.dump(), headers));
    }
    ApiResult<json> del(const std::string& path) {
        if (!cb_.allow_request()) return ApiError{ApiErrorType::HttpError, 0, "Circuit open", ""};
        return process_response(client_.del(path, auth_headers()));
    }
private:
    RestApiClient client_;
    std::string bearer_token_;
    CircuitBreaker cb_;
    std::map<std::string, std::string> auth_headers() {
        return {{"Authorization", "Bearer " + bearer_token_},
                {"Accept", "application/json"}, {"User-Agent", "MyApp/1.0"}};
    }
    ApiResponse get_with_retry(const std::string& path,
                               const std::map<std::string, std::string>& headers) {
        ApiResponse res = client_.get(path, headers);
        for (int i = 0; i < 3 && res.is_retryable(); ++i) {
            std::this_thread::sleep_for(std::chrono::milliseconds(1000 * (1 << i)));
            res = client_.get(path, headers);
        }
        return res;
    }
    ApiResult<json> process_response(const ApiResponse& res) {
        if (res.is_success()) {
            cb_.record_success();
            try { return json::parse(res.body); }
            catch (const json::exception& e) {
                return ApiError{ApiErrorType::JsonParseError, 0, e.what(), res.body};
            }
        }
        if (res.is_retryable()) cb_.record_failure();  // 4xx는 회로 차단기에 반영하지 않음
        else cb_.record_success();
        return ApiError{ApiErrorType::HttpError, res.status_code, res.status_message, res.body};
    }
};
// 사용 예: CRUD + ApiResult 패턴
int main() {
    ProductionRestClient client("https://api.example.com", "your-token");
    auto result = client.get("/users/1");
    if (auto* j = std::get_if<json>(&result))
        std::cout << "User: " << (*j)["name"] << "\n";
    else
        std::cerr << "Error: " << std::get<ApiError>(result).message << "\n";
    json body{{"name", "New User"}, {"email", "new@example.com"}};
    result = client.post("/users", body);
    if (auto* j = std::get_if<json>(&result))
        std::cout << "Created id: " << (*j)["id"] << "\n";
    return 0;
}

간단한 CRUD 테스트 (JSON Placeholder)

// 인증 없이 CRUD 테스트
RestApiClient client("https://jsonplaceholder.typicode.com", 10);
std::map<std::string, std::string> headers{{"Content-Type", "application/json"}};
ApiResponse res = client.get("/users/1");
if (res.is_success()) {
    auto j = nlohmann::json::parse(res.body);
    std::cout << "User: " << j["name"] << "\n";
}
nlohmann::json body{{"title", "Test"}, {"body", "Content"}, {"userId", 1}};
res = client.post("/posts", body.dump(), headers);
if (res.is_success()) {
    auto j = nlohmann::json::parse(res.body);
    std::cout << "Created post id: " << j["id"] << "\n";
}
# 빌드: g++ -std=c++17 -I/path/to/nlohmann main.cpp rest_api_client.cpp -o rest_client

같이 보면 좋은 글

이전 글: C++ 작업 큐 설계: 워크 스틸링으로 스레드 풀 성능 끌어올리기 다음 글: C++20 Concepts: SFINAE를 대체하는 제약 문법과 Subsumption

참고 자료