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

들어가며: “REST API 호출은 되는데 응답 처리가 막막해요”

// ❌ 문제: HTTP 클라이언트로 요청은 보냈는데...
// - 결제 API 응답 {"status": "ok", "transaction_id": "tx_123"} 를 어떻게 파싱하지?
// - 401 Unauthorized 응답 시 토큰 갱신 로직은 어디에 넣어야 하지?
// - 서버가 503을 반환하면 재시도할지, 폴백할지 어떻게 결정하지?
// - JSON 본문이 잘못된 형식이면 크래시가 나요

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

  • JSON 파싱 실패: {"data": null} 응답에서 j["data"][0] 접근 시 예외 발생
  • 인증 만료: Bearer 토큰이 만료되면 401 응답, 수동 갱신 없이 재시도만 하면 무한 루프
  • 에러 처리 불일치: HTTP 4xx는 클라이언트 에러, 5xx는 서버 에러인데 구분 없이 처리
  • 타임아웃·재시도 부재: 느린 API에서 무한 대기, 일시적 장애 시 즉시 실패
  • CRUD 패턴 불일치: GET/POST/PUT/DELETE 각각에 맞는 헤더·본문 형식이 혼란 해결책:
  1. 완전한 REST API 클라이언트: CRUD 메서드, JSON 직렬화/역직렬화, 통일된 응답 타입
  2. 인증 처리: Bearer 토큰, API 키, Basic 인증을 헤더에 자동 주입
  3. 에러 분류: 연결 실패, 타임아웃, HTTP 4xx/5xx, JSON 파싱 에러 구분
  4. 재시도·회로 차단기: 일시적 실패에만 재시도, 연속 실패 시 요청 중단

앞 글의 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의 기본 전송 타임아웃은 “무제한”이라, 응답하지 않는 서버 하나가 호출 스레드를 영원히 붙잡을 수 있습니다.

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

전형적인 실패 패턴

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 반복
  • JSON 응답 형식 가정 → 파싱 예외
  • 에러 원인 불명 → 디버깅 어려움

해결 아키텍처

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 연동

결제 서버에 POST /payments로 요청을 보냈는데, 응답이 {"error": "invalid_card"} 형태로 오거나, 성공 시 {"transaction_id": "tx_123"} 형태로 옵니다. 에러와 성공 응답 구조가 달라서 단일 파싱 로직으로 처리하기 어렵습니다.

마이크로서비스 간 호출

Order 서비스가 Inventory 서비스에 재고 확인 요청을 보냅니다. Inventory가 503을 반환하면 “일시적 장애”로 재시도해야 하는데, 400 Bad Request면 재시도하면 안 됩니다. 상태 코드별 분기 처리가 필요합니다.

OAuth 토큰 갱신

액세스 토큰이 만료되면 401이 옵니다. 리프레시 토큰으로 /auth/refresh를 호출해 새 액세스 토큰을 받아야 합니다. 이 과정을 클라이언트가 자동으로 처리하지 않으면, 매번 사용자가 재로그인해야 합니다.

대량 데이터 페이지네이션

GET /users?page=1&limit=100으로 사용자 목록을 가져옵니다. 응답이 {"data": [...], "total": 5000, "page": 1} 형태인데, data가 빈 배열일 수 있으며, total이 없을 수도 있습니다. null·빈 배열·누락 필드 처리가 필요합니다.

429 Rate Limit 초과

외부 API가 분당 100회 제한을 두고 있습니다. Retry-After 헤더 없이 429를 받으면 언제 재시도해야 할지 모릅니다. 무작위 재시도는 오히려 제한을 더 악화시킬 수 있습니다.

연결 타임아웃·연결 거부

서버가 다운되었거나 네트워크가 불안정할 때 connect()가 무한 대기합니다. 타임아웃 없이 블로킹되면 전체 애플리케이션이 멈춥니다.

SSL/TLS 인증서 검증 실패

HTTPS API 호출 시 자체 서명 인증서나 만료된 인증서로 연결이 거부됩니다. 개발 환경과 프로덕션 환경의 인증서 설정이 달라 혼란이 발생합니다.


구성 요소와 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 <regex>
#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": "[email protected]",
  "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"] = "[email protected]";
    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"] = "[email protected]";
    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라는 이상한 두 파라미터로 해석합니다(+는 쿼리에서 공백으로 읽히기도 합니다). 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는 하루에도 여러 번 일어나는 정상적인 경로이고, 호출자가 매번 명시적으로 처리하게 만드는 편이 “예외를 잡는 걸 깜빡해 스레드가 죽는” 사고를 줄입니다. 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 에러 페이지, 빈 문자열, 잘못된 인코딩 해결:

// ❌ 잘못된 예
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 실패(미정의 동작)이므로 특히 위험합니다. 해결:

// ❌ 잘못된 예
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 헤더 누락 해결:

// ❌ 잘못된 예: 무한 재시도
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 없음 해결:

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

타임아웃 후 소켓 누수

해결: RAII로 소켓 관리

class SecureSocket {
public:
    ~SecureSocket() { if (fd_ >= 0) close(fd_); }
private:
    int fd_ = -1;
};

인코딩 문제 (한글 등)

해결: Content-Type: application/json; charset=utf-8 확인, UTF-8 해석

429 Too Many Requests (Rate Limit)

원인: API 호출 빈도 초과. Retry-After 헤더로 대기 시간 제공 가능 해결: res.headers["retry-after"] 확인 후 해당 초만큼 대기 후 1회 재시도. 없으면 60초 기본값 사용. HTTP 헤더 이름은 대소문자를 구분하지 않으므로, 파싱할 때 헤더 이름을 소문자로 정규화해 맵에 넣어야 "Retry-After"로 온 헤더를 놓치지 않습니다. 또 Retry-After는 초 단위 숫자 대신 Wed, 21 Oct 2026 07:28:00 GMT 같은 날짜 형식으로 올 수도 있어서, std::stoi가 실패하는 경우의 기본값을 반드시 정해 두어야 합니다. 서버가 터무니없이 큰 값을 보낼 수 있으므로 최대 대기 시간에 상한을 두는 것도 좋습니다.

Connection refused / Connection timeout

원인: 서버 다운, 방화벽, 잘못된 호스트/포트 해결: connectToHost 실패 시 ApiError{ApiErrorType::ConnectionFailed, ...} 반환. 타임아웃 설정 필수.

SSL/TLS 인증서 검증 실패

원인: 자체 서명·만료 인증서, 호스트명 불일치 해결: 프로덕션에서는 반드시 검증 활성화. 개발 시에만 DEV_MODE 환경 변수로 완화.


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

요청 전 준비

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

안전한 JSON 접근

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 유형연결읽기
내부 API2~5초5~10초
외부 API5~10초10~30초
느린 API10~15초30~60초

요청 ID·멱등성·Rate Limit

  • X-Request-ID: 디버깅·분산 추적
  • Idempotency-Key: POST/PUT 재시도 시 중복 방지 (결제 API)
  • X-RateLimit-Remaining: 낮으면 요청 속도 조절

이 중 가장 중요한 것은 멱등성입니다. 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로 초기화해 쓰는 것이 맞습니다. 또 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};
}

로깅과 메트릭

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

REST 클라이언트 운영 체크리스트

  • Content-Type: application/json
  • 인증 헤더 자동 주입
  • 401 시 토큰 갱신 후 1회 재시도
  • JSON 안전 접근 (contains, value)
  • 타임아웃 적용
  • 429, 5xx에만 재시도
  • 에러 로깅

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

HTTP 클라이언트(#21-1)와 이 글의 패턴을 결합한 프로덕션 수준 전체 흐름 예제입니다.

// ProductionRestClient: 인증 + 회로 차단기 + 재시도 + 에러 처리 통합
#include <chrono>
#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", "[email protected]"}};
    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

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 401 Unauthorized 응답을 받으면 토큰 갱신은 어떻게 처리하나요?

A. 401을 받았다고 같은 요청을 그대로 재시도하면 만료된 토큰으로 계속 401을 받는 무한 루프가 됩니다. refresh token으로 새 access token을 받은 뒤 원래 요청을 한 번만 다시 보내고, 갱신마저 실패하면 재로그인이 필요한 에러로 호출자에게 올려야 합니다. 여러 스레드가 동시에 401을 받는 환경이라면 갱신 요청이 한 번만 나가도록 뮤텍스로 보호합니다.

Q. HTTPS는 어떻게 하나요?

A. #21-1 HTTP 클라이언트에서 설명한 대로 OpenSSL 또는 Boost.Beast, libcurl로 TLS를 처리합니다. REST API는 대부분 HTTPS이므로 TLS 지원이 필수입니다. HTTP 클라이언트와 JSON을 결합해 REST API를 호출하며, 인증·에러 처리·재시도로 프로덕션 수준으로 끌어올릴 수 있습니다. 다음 글: [C++ 실전 가이드 #22-1] Concepts 기초 이전 글: [C++ 실전 가이드 #21-2] 간단한 작업 큐 구현

참고 자료