C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴

C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴

이 글의 핵심

REST API 호출 시 연결 실패·타임아웃·성능 저하 문제를 해결합니다. TCP 소켓 기반 완전한 HTTP 클라이언트(GET/POST, 헤더, 에러 처리), Boost.Beast HTTPS 예제, Keep-Alive 연결 풀, 자주 발생하는 에러와 해결법, 모범 사례, 프로덕션 ...

들어가며: “REST API 호출이 필요한데 연결이 안 돼요”

실제 겪는 문제 시나리오

// ❌ 문제: 외부 API를 호출해야 하는데...
// - Order 서비스가 Payment 서비스에 결제 요청 보내기
// - 로그 수집기가 중앙 서버로 데이터 전송
// - 게임 클라이언트가 랭킹 API 조회

// 요청마다 새 TCP 연결 → 3-way handshake 반복 → 지연 누적
// 서버가 느리면 recv에서 무한 대기
// 연결 실패 시 원인 파악 어려움

시나리오 1 (마이크로서비스): Order→Inventory 재고 차감. 초당 100건 요청 시 매번 새 연결이면 handshake 오버헤드로 지연 폭증. 서버 장애 시 connect 무한 대기.

시나리오 2 (로그 수집): 중앙 서버로 JSON POST. Content-Type 누락 시 파싱 실패. recv가 중간에 끊긴 데이터만 반환 시 파싱 에러.

시나리오 3 (외부 API): 결제/SMS API 호출. Authorization 헤더로 API 키 전달. 429 응답 시 재시도 로직 없으면 데이터 손실.

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

  • 연결 실패: getaddrinfo 실패, connect 타임아웃, 방화벽 차단
  • 응답 대기 무한 블로킹: 서버가 응답하지 않아 recv에서 영원히 대기
  • 성능 저하: 요청마다 새 TCP 연결 → 3-way handshake(약 1 RTT) + TLS 핸드셰이크(추가 1-2 RTT) 반복
  • 메모리/리소스 누수: close 누락, freeaddrinfo 누락
  • 불완전한 응답 파싱: recv가 한 번에 전체를 반환하지 않음

해결책:

  1. 완전한 HTTP 클라이언트: 연결 → 요청 전송 → 응답 수신 → 파싱까지 한 흐름으로
  2. 연결 풀(Connection Pool): Keep-Alive로 연결 재사용, handshake 비용 절감
  3. 타임아웃 처리: SO_RCVTIMEO, SO_SNDTIMEO 또는 select로 무한 대기 방지
  4. 에러 분류: 연결 실패, 타임아웃, 파싱 에러 구분해 재시도/폴백 결정

목표:

  • 완전한 HTTP 클라이언트 구현 (GET, POST, 헤더 파싱)
  • Keep-Alive 연결 풀로 성능 최적화
  • 타임아웃에러 처리 패턴
  • 자주 발생하는 에러와 해결법
  • 성능 벤치마크 (풀 vs 새 연결)
  • 프로덕션 배포 체크리스트

요구 환경: POSIX 소켓 (Linux, macOS). Windows에서는 WSL 사용 또는 Winsock으로 별도 구현 필요.

이 글을 읽으면:

  • TCP 소켓으로 HTTP 요청을 보내고 응답을 받는 전체 흐름을 이해할 수 있습니다.
  • 연결 풀을 적용해 성능을 2~3배 개선할 수 있습니다.
  • 타임아웃, 재시도, 에러 분류를 실전에 적용할 수 있습니다.

목차

  1. 문제 시나리오와 해결 방향
  2. HTTP 프로토콜 기초
  3. TCP 소켓 연결
  4. 완전한 HTTP 클라이언트 구현
  5. 연결 풀 (Connection Pool)
  6. 타임아웃 처리
  7. 자주 발생하는 에러와 해결법
  8. 성능 벤치마크
  9. 프로덕션 패턴

1. 문제 시나리오와 해결 방향

1.1 전형적인 실패 패턴

sequenceDiagram
  participant App as 애플리케이션
  participant Client as HTTP 클라이언트
  participant Server as 외부 API 서버

  App->>Client: GET /api/users
  Client->>Client: socket() → connect() (handshake)
  Note over Client: 서버 응답 지연 시<br/>connect/recv에서 무한 대기
  Client->>Server: GET 요청
  Server-->>Client: (응답 없음...)
  Note over App: 스레드 블로킹,<br/>다른 요청 처리 불가

문제 요약:

  • 요청마다 새 연결 → RTT 지연 누적
  • 타임아웃 없음 → 느린 서버에서 영원히 대기
  • 에러 원인 불명 → 디버깅 어려움

1.2 해결 아키텍처

flowchart TB
  subgraph 개선된 흐름
    A[요청] --> B{풀에 연결 있음?}
    B -->|Yes| C[기존 연결 재사용]
    B -->|No| D[새 연결 생성]
    C --> E[요청 전송]
    D --> E
    E --> F[타임아웃 설정]
    F --> G[응답 수신]
    G --> H{성공?}
    H -->|Yes| I[연결 풀에 반환]
    H -->|No| J[연결 닫기, 재시도]
  end

2. HTTP 프로토콜 기초

2.1 HTTP/1.1 GET 요청 형식

GET /path?query=value HTTP/1.1\r\n
Host: example.com\r\n
Connection: close\r\n
\r\n
  • 첫 줄: 메서드, 경로, 프로토콜 버전
  • 헤더: Host(필수), Connection: close(요청 후 연결 종료)
  • 빈 줄 (\r\n): 헤더 끝. GET은 본문 없음

2.2 HTTP 응답 형식

HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 27\r\n
\r\n
{"message":"Hello, World!"}
  • 첫 줄: 프로토콜, 상태 코드, 메시지
  • 헤더: Content-Length로 본문 크기 결정
  • 빈 줄 이후: 본문

2.3 요청/응답 구조체

#include <string>
#include <map>

struct HttpRequest {
    std::string method;
    std::string path;
    std::map<std::string, std::string> headers;
    std::string body;
};

struct HttpResponse {
    int status_code;
    std::string status_message;
    std::map<std::string, std::string> headers;
    std::string body;
};

3. TCP 소켓 연결

3.1 연결 흐름

sequenceDiagram
  participant C as 클라이언트
  participant DNS as DNS
  participant S as 서버

  C->>DNS: getaddrinfo(host, port)
  DNS-->>C: IP 주소 목록
  C->>S: socket() → connect()
  Note over C,S: 3-way handshake
  S-->>C: 연결 수립
  C->>S: send(HTTP 요청)
  S-->>C: recv(HTTP 응답)

3.2 connectToHost 구현

getaddrinfo로 호스트 이름을 IP로 해석하고, socket + connect로 TCP 연결을 맺습니다. 실패 시 리소스 정리가 필수입니다.

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <string>
#include <cstring>

int connectToHost(const std::string& host, uint16_t port) {
    addrinfo hints{}, *result = nullptr;
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_protocol = IPPROTO_TCP;

    if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &result) != 0) {
        return -1;  // DNS 실패
    }

    int sock = socket(result->ai_family, result->ai_socktype, result->ai_protocol);
    if (sock < 0) {
        freeaddrinfo(result);
        return -1;
    }

    if (connect(sock, result->ai_addr, result->ai_addrlen) != 0) {
        close(sock);
        freeaddrinfo(result);
        return -1;  // 연결 실패 (ECONNREFUSED, ETIMEDOUT 등)
    }

    freeaddrinfo(result);
    return sock;
}

위 코드 설명: getaddrinfohost:portsockaddr 구조체로 변환합니다. hintsAF_INET, SOCK_STREAM, IPPROTO_TCP를 지정해 IPv4 TCP만 받습니다. connect 실패 시 close(sock)freeaddrinfo(result)를 반드시 호출해 리소스 누수를 막습니다.


4. 완전한 HTTP 클라이언트 구현

4.1 요청 빌드

std::string buildRequest(const std::string& method, const std::string& host,
                        const std::string& path, const std::string& body,
                        const std::map<std::string, std::string>& extraHeaders) {
    std::string req;
    req += method + " " + path + " HTTP/1.1\r\n";
    req += "Host: " + host + "\r\n";
    req += "Connection: close\r\n";

    if (!body.empty()) {
        req += "Content-Length: " + std::to_string(body.size()) + "\r\n";
    }

    for (const auto& [k, v] : extraHeaders) {
        req += k + ": " + v + "\r\n";
    }
    req += "\r\n";
    req += body;
    return req;
}

4.2 응답 수신 (readAll)

recv는 한 번에 전체를 반환하지 않을 수 있습니다. readAll은 연결이 닫힐 때까지 반복해 읽습니다. Connection: close 응답 시 서버가 본문 전송 후 연결을 끊으므로 이 루프로 전체를 받을 수 있습니다.

#include <string>
#include <algorithm>

std::string readAll(int fd) {
    std::string result;
    char buf[4096];
    ssize_t n;
    while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
        result.append(buf, static_cast<size_t>(n));
    }
    return result;
}

4.3 응답 파싱

헤더와 본문은 \r\n\r\n으로 구분됩니다. Content-Length가 있으면 해당 바이트만 본문으로 사용하고, 없으면 빈 줄 이후 전부를 본문으로 둡니다.

#include <sstream>
#include <algorithm>

HttpResponse parseResponse(const std::string& raw) {
    HttpResponse res;
    size_t pos = raw.find("\r\n\r\n");
    if (pos == std::string::npos) return res;

    std::string headerPart = raw.substr(0, pos);
    res.body = raw.substr(pos + 4);

    std::istringstream iss(headerPart);
    std::string line;

    // 상태 라인: "HTTP/1.1 200 OK"
    std::getline(iss, line);
    if (line.back() == '\r') line.pop_back();
    std::istringstream statusLine(line);
    std::string protocol;
    statusLine >> protocol >> res.status_code;
    std::getline(statusLine, res.status_message);
    if (!res.status_message.empty() && res.status_message[0] == ' ') {
        res.status_message = res.status_message.substr(1);
    }

    // 헤더 파싱
    while (std::getline(iss, line)) {
        if (line.back() == '\r') line.pop_back();
        if (line.empty()) break;
        size_t colon = line.find(':');
        if (colon != std::string::npos) {
            std::string name = line.substr(0, colon);
            std::string value = line.substr(colon + 1);
            value.erase(0, value.find_first_not_of(" \t"));
            value.erase(value.find_last_not_of(" \t") + 1);
            std::transform(name.begin(), name.end(), name.begin(), ::tolower);
            res.headers[name] = value;
        }
    }

    // Content-Length 기반 본문 조정 (불완전한 수신 대비)
    auto it = res.headers.find("content-length");
    if (it != res.headers.end()) {
        size_t len = std::stoull(it->second);
        if (res.body.size() > len) {
            res.body = res.body.substr(0, len);
        }
    }
    return res;
}

4.4 SimpleHttpClient 클래스 (완전 구현)

class SimpleHttpClient {
public:
    bool get(const std::string& host, const std::string& path,
             std::string& outBody, int timeoutSec = 10) {
        return request("GET", host, path, "", {}, outBody, timeoutSec);
    }

    bool post(const std::string& host, const std::string& path,
              const std::string& body, std::string& outBody, int timeoutSec = 10) {
        return request("POST", host, path, body, {}, outBody, timeoutSec);
    }

    bool request(const std::string& method, const std::string& host,
                 const std::string& path, const std::string& body,
                 const std::map<std::string, std::string>& extraHeaders,
                 std::string& outBody, int timeoutSec = 10) {
        int fd = connectToHost(host, 80);
        if (fd < 0) return false;

        setSocketTimeouts(fd, timeoutSec);

        std::string req = buildRequest(method, host, path, body, extraHeaders);
        ssize_t sent = send(fd, req.data(), req.size(), 0);
        if (sent != static_cast<ssize_t>(req.size())) {
            close(fd);
            return false;
        }

        std::string raw = readAll(fd);
        close(fd);

        HttpResponse res = parseResponse(raw);
        outBody = res.body;
        return res.status_code >= 200 && res.status_code < 300;
    }

private:
    void setSocketTimeouts(int fd, int seconds) {
        timeval tv{};
        tv.tv_sec = seconds;
        tv.tv_usec = 0;
        setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
        setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
    }
};

사용 예:

SimpleHttpClient client;
std::string body;
if (client.get("example.com", "/api/users", body)) {
    std::cout << body << "\n";
} else {
    std::cerr << "Request failed\n";
}

4.5 완전한 HTTP 예제: GET/POST, 헤더, 에러 처리

User-Agent, Authorization, Content-Type 등 커스텀 헤더와 에러 분류(HttpError)를 포함한 실전 패턴입니다.

#include <iostream>
#include <string>
#include <map>
#include <sys/socket.h>
#include <sys/time.h>

// connectToHost, readAll, parseResponse, HttpResponse는 앞선 3~4절 구현 사용

enum class HttpError {
    None, ConnectionFailed, Timeout, SendFailed, RecvFailed,
    ParseError, Http4xx, Http5xx
};

struct HttpResult {
    bool ok = false;
    int status_code = 0;
    std::string body;
    HttpError error = HttpError::None;
};

void setSocketTimeouts(int fd, int seconds) {
    timeval tv{};
    tv.tv_sec = seconds;
    tv.tv_usec = 0;
    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}

HttpResult doGet(const std::string& host, const std::string& path,
                const std::map<std::string, std::string>& headers,
                int timeoutSec = 10) {
    HttpResult result;
    int fd = connectToHost(host, 80);
    if (fd < 0) {
        result.error = HttpError::ConnectionFailed;
        return result;
    }
    setSocketTimeouts(fd, timeoutSec);

    std::string req = "GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\n";
    req += "Connection: close\r\n";
    for (const auto& [k, v] : headers) req += k + ": " + v + "\r\n";
    req += "\r\n";

    if (send(fd, req.data(), req.size(), 0) != static_cast<ssize_t>(req.size())) {
        result.error = HttpError::SendFailed;
        close(fd);
        return result;
    }
    std::string raw = readAll(fd);
    close(fd);

    HttpResponse res = parseResponse(raw);
    result.status_code = res.status_code;
    result.body = res.body;
    if (res.status_code == 0) result.error = HttpError::ParseError;
    else if (res.status_code >= 500) result.error = HttpError::Http5xx;
    else if (res.status_code >= 400) result.error = HttpError::Http4xx;
    else result.ok = true;
    return result;
}

HttpResult doPost(const std::string& host, const std::string& path,
                  const std::string& body, const std::string& contentType,
                  const std::map<std::string, std::string>& headers,
                  int timeoutSec = 10) {
    HttpResult result;
    int fd = connectToHost(host, 80);
    if (fd < 0) {
        result.error = HttpError::ConnectionFailed;
        return result;
    }
    setSocketTimeouts(fd, timeoutSec);

    std::string req = "POST " + path + " HTTP/1.1\r\nHost: " + host + "\r\n";
    req += "Connection: close\r\nContent-Type: " + contentType + "\r\n";
    req += "Content-Length: " + std::to_string(body.size()) + "\r\n";
    for (const auto& [k, v] : headers) req += k + ": " + v + "\r\n";
    req += "\r\n" + body;

    if (send(fd, req.data(), req.size(), 0) != static_cast<ssize_t>(req.size())) {
        result.error = HttpError::SendFailed;
        close(fd);
        return result;
    }
    std::string raw = readAll(fd);
    close(fd);

    HttpResponse res = parseResponse(raw);
    result.status_code = res.status_code;
    result.body = res.body;
    result.ok = (res.status_code >= 200 && res.status_code < 300);
    return result;
}

실전 사용 예:

std::map<std::string, std::string> headers;
headers["Authorization"] = "Bearer your-api-key";
headers["User-Agent"] = "MyApp/1.0";
auto res = doGet("api.example.com", "/v1/users", headers);
if (res.ok) { /* JSON 파싱 */ }
else if (res.error == HttpError::Http5xx) { /* 재시도 */ }

std::string jsonBody = R"({"name":"test","value":42})";
auto postRes = doPost("api.example.com", "/v1/orders", jsonBody,
                     "application/json", headers);

4.6 Boost.Beast로 HTTP 클라이언트 구현

Boost.Beast는 Boost.Asio 기반 HTTP/WebSocket 라이브러리로, TLS(HTTPS), 비동기 I/O, 파싱을 지원합니다. 프로덕션 HTTPS 클라이언트에 권장합니다.

설치: vcpkg install boost-beast 또는 apt install libboost-all-dev

#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl/stream.hpp>

namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
namespace ssl = boost::asio::ssl;
using tcp = boost::asio::ip::tcp;

beast::error_code httpsGet(const std::string& host, const std::string& path,
                           std::string& outBody) {
    net::io_context ioc;
    ssl::context ctx{ssl::context::tlsv12_client};
    ctx.set_default_verify_paths();
    tcp::resolver resolver{ioc};
    auto results = resolver.resolve(host, "443");
    beast::ssl_stream<beast::tcp_stream> stream{ioc, ctx};
    beast::get_lowest_layer(stream).connect(results);
    stream.handshake(ssl::stream_base::client);

    http::request<http::string_body> req{http::verb::get, path, 11};
    req.set(http::field::host, host);
    http::write(stream, req);

    beast::flat_buffer buffer;
    http::response<http::dynamic_body> res;
    http::read(stream, buffer, res);
    outBody = beast::buffers_to_string(res.body().data());

    beast::error_code ec;
    stream.shutdown(ec);
    return ec;
}

Beast vs 직접 구현: Beast는 HTTPS(ssl_stream), Chunked, 파싱을 자동 처리. 직접 소켓은 의존성 없으나 OpenSSL 연동·파싱을 수동 구현해야 함.


5. 연결 풀 (Connection Pool)

5.1 왜 연결 풀인가?

방식요청당 비용100회 요청 시
요청마다 새 연결1 RTT (handshake) + 1 RTT (요청/응답)~200 RTT
Keep-Alive 풀1 RTT (첫 연결) + 1 RTT (요청/응답)~101 RTT

연결 풀을 쓰면 handshake 비용을 대부분 제거할 수 있습니다.

5.2 연결 풀 구현

#include <queue>
#include <mutex>
#include <chrono>
#include <unordered_map>

struct PooledConnection {
    int fd;
    std::string host;
    uint16_t port;
    std::chrono::steady_clock::time_point last_used;
};

class HttpConnectionPool {
    std::unordered_map<std::string, std::queue<PooledConnection>> pool_;
    std::mutex mutex_;
    size_t max_per_host_ = 4;
    std::chrono::seconds idle_timeout_{30};

public:
    ~HttpConnectionPool() {
        std::lock_guard<std::mutex> lock(mutex_);
        for (auto& [key, q] : pool_) {
            while (!q.empty()) {
                close(q.front().fd);
                q.pop();
            }
        }
    }

    int acquire(const std::string& host, uint16_t port) {
        std::lock_guard<std::mutex> lock(mutex_);
        std::string key = host + ":" + std::to_string(port);
        auto& q = pool_[key];

        while (!q.empty()) {
            auto conn = q.front();
            q.pop();
            auto now = std::chrono::steady_clock::now();
            if (now - conn.last_used < idle_timeout_) {
                return conn.fd;
            }
            close(conn.fd);
        }
        return connectToHost(host, port);
    }

    void release(int fd, const std::string& host, uint16_t port) {
        std::lock_guard<std::mutex> lock(mutex_);
        std::string key = host + ":" + std::to_string(port);
        auto& q = pool_[key];
        if (q.size() < max_per_host_) {
            q.push({fd, host, port, std::chrono::steady_clock::now()});
        } else {
            close(fd);
        }
    }

    void discard(int fd) {
        close(fd);  // 에러 시 풀에 반환하지 않고 닫기
    }
};

5.3 Keep-Alive 요청 형식

연결 풀을 쓰려면 Connection: keep-alive로 요청하고, 서버도 Keep-Alive를 지원해야 합니다.

std::string buildKeepAliveRequest(const std::string& method, const std::string& host,
                                  const std::string& path, const std::string& body) {
    std::string req;
    req += method + " " + path + " HTTP/1.1\r\n";
    req += "Host: " + host + "\r\n";
    req += "Connection: keep-alive\r\n";  // close 대신 keep-alive
    if (!body.empty()) {
        req += "Content-Length: " + std::to_string(body.size()) + "\r\n";
    }
    req += "\r\n";
    req += body;
    return req;
}

5.4 풀 사용 HTTP 클라이언트

class PooledHttpClient {
    HttpConnectionPool pool_;

public:
    bool get(const std::string& host, const std::string& path,
             std::string& outBody, int timeoutSec = 10) {
        int fd = pool_.acquire(host, 80);
        if (fd < 0) return false;

        timeval tv{};
        tv.tv_sec = timeoutSec;
        tv.tv_usec = 0;
        setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
        setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));

        std::string req = buildKeepAliveRequest("GET", host, path, "");
        if (send(fd, req.data(), req.size(), 0) != static_cast<ssize_t>(req.size())) {
            pool_.discard(fd);
            return false;
        }

        std::string raw = readAll(fd);
        HttpResponse res = parseResponse(raw);

        // Connection: close 응답이면 풀에 반환하지 않음
        auto it = res.headers.find("connection");
        if (it != res.headers.end() && it->second == "close") {
            pool_.discard(fd);
        } else {
            pool_.release(fd, host, 80);
        }

        outBody = res.body;
        return res.status_code >= 200 && res.status_code < 300;
    }
};

6. 타임아웃 처리

6.1 소켓 타임아웃 (SO_RCVTIMEO, SO_SNDTIMEO)

void setRecvTimeout(int fd, int seconds) {
    timeval tv{};
    tv.tv_sec = seconds;
    tv.tv_usec = 0;
    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
}

void setSendTimeout(int fd, int seconds) {
    timeval tv{};
    tv.tv_sec = seconds;
    tv.tv_usec = 0;
    setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}

동작: recv/send가 지정 시간 동안 데이터를 받지 못하거나 보내지 못하면 블로킹에서 풀리고 -1을 반환합니다. errnoEAGAIN 또는 EWOULDBLOCK입니다.

6.2 connect 타임아웃 (select 사용)

connectSO_SNDTIMEO로 타임아웃을 줄 수 있지만, 일부 플랫폼에서는 동작이 다릅니다. select로 소켓이 쓰기 가능해질 때까지 대기하는 방식이 더 이식성이 좋습니다.

#include <sys/select.h>
#include <fcntl.h>
#include <cerrno>

int connectWithTimeout(const std::string& host, uint16_t port, int timeoutSec) {
    addrinfo hints{}, *result = nullptr;
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_protocol = IPPROTO_TCP;

    if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &result) != 0) {
        return -1;
    }

    int sock = socket(result->ai_family, result->ai_socktype, result->ai_protocol);
    if (sock < 0) {
        freeaddrinfo(result);
        return -1;
    }

    // 논블로킹으로 설정
    int flags = fcntl(sock, F_GETFL, 0);
    fcntl(sock, F_SETFL, flags | O_NONBLOCK);

    int ret = connect(sock, result->ai_addr, result->ai_addrlen);
    freeaddrinfo(result);

    if (ret == 0) {
        fcntl(sock, F_SETFL, flags);  // 블로킹으로 복원
        return sock;
    }

    if (errno != EINPROGRESS) {
        close(sock);
        return -1;
    }

    fd_set wfds;
    FD_ZERO(&wfds);
    FD_SET(sock, &wfds);
    timeval tv{};
    tv.tv_sec = timeoutSec;
    tv.tv_usec = 0;

    if (select(sock + 1, nullptr, &wfds, nullptr, &tv) <= 0) {
        close(sock);
        return -1;  // 타임아웃
    }

    int err = 0;
    socklen_t len = sizeof(err);
    if (getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &len) != 0 || err != 0) {
        close(sock);
        return -1;
    }

    fcntl(sock, F_SETFL, flags);
    return sock;
}

6.3 타임아웃 값 권장

상황연결 타임아웃읽기/쓰기 타임아웃
로컬 LAN2~5초5~10초
인터넷5~10초10~30초
느린 API10~15초30~60초

7. 자주 발생하는 에러와 해결법

7.1 연결 실패 (ECONNREFUSED)

원인: 서버가 해당 포트에서 리스닝하지 않음, 서버 다운, 잘못된 포트

해결:

  • 포트 번호 확인 (HTTP: 80, HTTPS: 443)
  • 서버 프로세스 상태 확인
  • 방화벽/보안 그룹 확인
// errno 확인
#include <cerrno>
if (connect(sock, addr, len) != 0) {
    if (errno == ECONNREFUSED) {
        // 서버가 연결 거부
    } else if (errno == ETIMEDOUT) {
        // 연결 타임아웃
    }
}

7.2 getaddrinfo 실패

원인: 잘못된 호스트 이름, DNS 서버 불가, 네트워크 단절

해결:

  • 호스트 이름 철자 확인
  • nslookup example.com으로 DNS 해석 테스트
  • IP 주소로 직접 연결 시도 (DNS 문제 격리)
if (getaddrinfo(host.c_str(), portStr.c_str(), &hints, &result) != 0) {
    // gai_strerror()로 에러 메시지 확인
    // #include <cstring>
    // fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ret));
    return -1;
}

7.3 recv에서 불완전한 데이터

원인: TCP는 스트림이므로 recv가 한 번에 전체를 반환하지 않음

해결: readAll처럼 루프로 recv 호출. Content-Length 또는 \r\n\r\n 기준으로 수신 완료 판단.

// ❌ 잘못된 예: 한 번만 recv
char buf[4096];
recv(fd, buf, sizeof(buf), 0);  // 일부만 들어올 수 있음

// ✅ 올바른 예: 연결 닫힐 때까지 반복
std::string readAll(int fd) {
    std::string result;
    char buf[4096];
    ssize_t n;
    while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
        result.append(buf, n);
    }
    return result;
}

7.4 메모리 누수 (freeaddrinfo 누락)

원인: getaddrinfo가 할당한 메모리를 freeaddrinfo로 해제하지 않음

해결: 모든 경로(성공/실패)에서 freeaddrinfo(result) 호출

// ❌ 잘못된 예
getaddrinfo(...);
int sock = socket(...);
if (sock < 0) return -1;  // freeaddrinfo 누락!
connect(...);
freeaddrinfo(result);

// ✅ 올바른 예: RAII 또는 모든 경로에서 해제
if (getaddrinfo(...) != 0) return -1;
int sock = socket(...);
if (sock < 0) {
    freeaddrinfo(result);
    return -1;
}
if (connect(...) != 0) {
    close(sock);
    freeaddrinfo(result);
    return -1;
}
freeaddrinfo(result);

7.5 EINTR (시그널에 의한 중단)

원인: recv, send, connect 등이 시그널로 중단되면 EINTR 반환

해결: EINTR 시 재시도

ssize_t safeRecv(int fd, void* buf, size_t len) {
    ssize_t n;
    do {
        n = recv(fd, buf, len, 0);
    } while (n < 0 && errno == EINTR);
    return n;
}

7.6 Content-Length·Host 헤더 누락

Content-Length: POST 시 본문 경계를 위해 필수. 누락 시 서버가 본문을 제대로 수신하지 못함.

Host: HTTP/1.1 필수. 가상 호스팅 시 서버가 사이트를 구분하지 못함.

// ✅ POST: Content-Length, Content-Type 필수
req += "Content-Length: " + std::to_string(body.size()) + "\r\n";
req += "Content-Type: application/json\r\n";

// ✅ 모든 요청: Host 필수
req += "Host: " + host + "\r\n";

7.7 Chunked 응답

서버가 Transfer-Encoding: chunked로 응답하면 Content-Length가 없음. Connection: close 사용 시 readAll()로 연결 종료까지 수신하면 대부분 처리 가능.

7.8 HTTP 클라이언트 모범 사례

  • 헤더: User-Agent, Content-Type(POST), Authorization(Bearer/Basic) 설정
  • URL 인코딩: 쿼리 파라미터 특수문자(&, =, 공백)는 퍼센트 인코딩
  • 타임아웃: 연결 510초, 읽기 1030초, 쓰기 5~10초
  • 리소스 정리: 모든 경로에서 close, freeaddrinfo 호출

8. 성능 벤치마크

8.1 측정 시나리오 및 예상 결과

  • 대상: httpbin.org/get, 100회 요청
  • 비교: SimpleHttpClient(매번 새 연결) vs PooledHttpClient(Keep-Alive)
  • 예상: 새 연결 1525초, 풀 510초 → 약 2~3배 개선

8.2 벤치마크 코드 예시

#include <chrono>
#include <iostream>

void benchmarkSimple(int iterations) {
    SimpleHttpClient client;
    std::string body;
    auto start = std::chrono::steady_clock::now();
    for (int i = 0; i < iterations; ++i) {
        client.get("httpbin.org", "/get", body, 10);
    }
    auto end = std::chrono::steady_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "Simple: " << ms << " ms for " << iterations << " requests\n";
}

void benchmarkPooled(int iterations) {
    PooledHttpClient client;
    std::string body;
    auto start = std::chrono::steady_clock::now();
    for (int i = 0; i < iterations; ++i) {
        client.get("httpbin.org", "/get", body, 10);
    }
    auto end = std::chrono::steady_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "Pooled: " << ms << " ms for " << iterations << " requests\n";
}

8.3 병목 지점

DNS(510%, 캐싱) → TCP handshake(3050%, 연결 풀) → TLS(2040%, 세션 재사용) → 전송(2030%, 압축)


9. 프로덕션 패턴

9.1 재시도 정책

bool getWithRetry(const std::string& host, const std::string& path,
                  std::string& outBody, int maxRetries = 3) {
    for (int i = 0; i < maxRetries; ++i) {
        if (client.get(host, path, outBody, 10)) {
            return true;
        }
        // 지수 백오프: 1초, 2초, 4초...
        std::this_thread::sleep_for(std::chrono::seconds(1 << i));
    }
    return false;
}

9.2 회로 차단기 (Circuit Breaker) 개념

연속 실패 시 일정 시간 요청을 보내지 않고, “열림” 상태에서 주기적으로 “절반 열림”으로 전환해 재시도합니다. 실패율이 높은 서비스에 대한 연쇄 장애를 막습니다.

stateDiagram-v2
  [*] --> Closed: 초기
  Closed --> Open: 실패 N회 연속
  Open --> HalfOpen: timeout 후
  HalfOpen --> Closed: 성공
  HalfOpen --> Open: 실패

9.3 로깅과 모니터링

bool getWithLogging(const std::string& host, const std::string& path,
                    std::string& outBody) {
    auto start = std::chrono::steady_clock::now();
    bool ok = client.get(host, path, outBody, 10);
    auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now() - start).count();

    if (ok) {
        // 로그: 성공, 경로, 소요 시간
    } else {
        // 로그: 실패, 경로, 에러 (메트릭 수집)
    }
    return ok;
}

9.4 프로덕션 체크리스트

  • 타임아웃 설정: connect, send, recv 모두 타임아웃 적용
  • 연결 풀: 동일 호스트에 반복 요청 시 Keep-Alive 사용
  • 재시도: 일시적 실패(타임아웃, 5xx)에 대해 지수 백오프 재시도
  • 에러 분류: 연결 실패 vs 타임아웃 vs HTTP 4xx/5xx 구분
  • 리소스 정리: 모든 경로에서 close, freeaddrinfo 호출
  • HTTPS: 프로덕션 API는 OpenSSL 또는 라이브러리(Beast, libcurl)로 TLS 처리
  • 로깅: 요청/응답 시간, 실패율, 에러 유형 메트릭 수집

9.5 회로 차단기 구현 예시

연속 실패 시 일정 시간 요청을 차단하고, cooldown 후 “절반 열림” 상태에서 재시도합니다.

class CircuitBreaker {
    std::atomic<int> failures_{0};
    std::atomic<bool> open_{false};
    std::chrono::steady_clock::time_point last_failure_;
    int threshold_ = 5;
    std::chrono::seconds cooldown_{30};

public:
    bool allowRequest() {
        if (!open_) return true;
        if (std::chrono::steady_clock::now() - last_failure_ > cooldown_) {
            open_ = false;
            failures_ = 0;
            return true;
        }
        return false;
    }
    void recordSuccess() { failures_ = 0; open_ = false; }
    void recordFailure() {
        last_failure_ = std::chrono::steady_clock::now();
        if (failures_.fetch_add(1) + 1 >= threshold_) open_ = true;
    }
};

9.6 프로덕션 패턴 요약

패턴목적적용 시점
지수 백오프 재시도일시적 장애 극복타임아웃, 5xx, 연결 실패
회로 차단기연쇄 장애 방지동일 호스트 연속 실패
연결 풀handshake 비용 절감동일 호스트 반복 요청
타임아웃 계층화무한 대기 방지모든 네트워크 호출
에러 분류재시도 여부 판단4xx(재시도 X) vs 5xx(재시도 O)
로깅/메트릭장애 감지·분석요청/응답 시간, 실패율

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 디자인 패턴 | Observer·Strategy
  • C++ 작업 큐 완벽 가이드 | 스레드 풀·워크 스틸링·성능 벤치마크 [#21-2]
  • C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]
  • C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩

이 글에서 다루는 키워드 (관련 검색어)

C++ HTTP 클라이언트, REST API, TCP 소켓, 연결 풀, Keep-Alive, 타임아웃, libcurl, Boost.Beast 등으로 검색하시면 이 글이 도움이 됩니다.


정리

항목내용
연결getaddrinfo + socket + connect, 실패 시 리소스 정리
요청GET/POST path HTTP/1.1, Host, Connection: close/keep-alive
수신recv 반복, \r\n\r\n 기준 헤더/본문 분리, Content-Length 활용
타임아웃SO_RCVTIMEO, SO_SNDTIMEO, connect는 select
연결 풀Keep-Alive로 handshake 비용 절감, 2~3배 성능 개선
에러ECONNREFUSED, ETIMEDOUT, getaddrinfo 실패, EINTR 재시도

실전에서는:

  • HTTPS는 OpenSSL 또는 Beast/libcurl 사용
  • 대량 요청 시 연결 풀 필수
  • 타임아웃·재시도·로깅으로 안정성 확보

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. REST API 클라이언트, 마이크로서비스 통신, 외부 API 연동, 웹 크롤러 등 HTTP 요청이 필요한 모든 C++ 프로젝트에서 활용합니다. Beast나 libcurl을 쓰더라도 프로토콜 이해가 디버깅에 필수입니다.

Q. HTTPS는 어떻게 하나요?

A. OpenSSL의 SSL_* API로 TLS 핸드셰이크 후 SSL_read/SSL_write를 사용하거나, Boost.Beast, libcurl 같은 라이브러리를 쓰는 편이 안전합니다. 직접 구현 시 인증서 검증을 반드시 수행해야 합니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. RFC 7230-7235 (HTTP/1.1), Boost.Beast, libcurl 문서를 참고하세요.

한 줄 요약: TCP 소켓으로 HTTP 클라이언트를 구현하고, 연결 풀·타임아웃·에러 처리로 프로덕션 수준으로 끌어올릴 수 있습니다. 다음으로 작업 큐·스레드 풀(#21-2)를 읽어보면 좋습니다.

다음 글: [C++ 실전 가이드 #21-2] 간단한 작업 큐 구현: 스레드 풀과 비동기 실행

이전 글: [C++ 실전 가이드 #20-2] 디자인 패턴 종합: Singleton·Factory·Observer·Strategy·PIMPL

참고 자료


관련 글

  • C++ JSON 파싱 완벽 가이드 | nlohmann·RapidJSON·커스텀 타입·에러 처리·프로덕션 패턴
  • C++ REST API 클라이언트 완벽 가이드 | CRUD·인증·에러 처리·프로덕션 패턴 [#21-3]
  • C++ 작업 큐 완벽 가이드 | 스레드 풀·워크 스틸링·성능 벤치마크 [#21-2]
  • C++ 디자인 패턴 | Observer·Strategy
  • C++ RAII 완벽 가이드 |