C++ HTTP 클라이언트 직접 만들기: TCP 소켓부터 Boost.Beast, 연결 풀, 타임아웃까지
들어가며: 외부 API 호출에서 생기는 문제들
서비스 간 결제 요청, 중앙 서버로의 로그 전송, 게임 클라이언트의 랭킹 조회처럼 C++ 프로그램이 HTTP API를 호출해야 하는 경우는 흔합니다. 소켓으로 요청 문자열을 보내고 응답을 받는 것까지는 금방 되지만, 실제로 돌려 보면 다음 문제들이 차례로 나타납니다.
- 서버가 응답하지 않으면
connect나recv에서 스레드가 영원히 멈춥니다. recv한 번으로는 응답이 다 들어오지 않아 본문이 잘리거나, 반대로 연결이 닫힐 때까지 기다리느라 Keep-Alive 연결에서 멈춥니다.- 요청마다 새 TCP 연결을 맺으면 3-way handshake(1 RTT)와 HTTPS라면 TLS 핸드셰이크(1~2 RTT)가 매번 추가됩니다.
- 실패했을 때 DNS 문제인지, 연결 거부인지, 타임아웃인지, HTTP 에러인지 구분이 안 되어 재시도 여부를 정할 수 없습니다.
이 글은 POSIX 소켓 위에 HTTP/1.1 요청·응답을 직접 구현하면서 응답 경계를 정확히 읽는 법, 타임아웃, 에러 분류를 붙이고, HTTPS는 Boost.Beast로 처리한 뒤, Keep-Alive 연결 풀과 재시도·회로 차단기까지 다룹니다. 예제는 Linux·macOS 기준이며, Windows에서는 Winsock 초기화(WSAStartup)와 closesocket 등으로 바꿔야 합니다.
sequenceDiagram participant App as 애플리케이션 participant Client as HTTP 클라이언트 participant Server as 외부 API 서버 App->>Client: GET /api/users Client->>Client: socket() → connect() (handshake) Client->>Server: GET 요청 Server-->>Client: (응답 없음...) Note over Client: 타임아웃이 없으면 recv에서 무한 대기 Note over App: 호출한 스레드가 블로킹되어 다른 작업 불가
목표로 하는 흐름은 다음과 같습니다.
flowchart TB
A[요청] --> B{풀에 쓸 수 있는 연결?}
B -->|Yes| C[기존 연결 재사용]
B -->|No| D[타임아웃 걸고 새 연결]
C --> E[요청 전송]
D --> E
E --> G[응답을 경계까지 정확히 수신]
G --> H{성공이고 연결 유지 가능?}
H -->|Yes| I[연결 풀에 반환]
H -->|No| J[연결 닫기, 필요하면 재시도]
HTTP/1.1 요청과 응답 형식
GET /path?query=value HTTP/1.1\r\n
Host: example.com\r\n
Connection: close\r\n
\r\n
요청은 메서드·경로·버전을 담은 첫 줄, 헤더들, 그리고 헤더의 끝을 알리는 빈 줄(\r\n)로 이루어집니다. HTTP/1.1에서 Host 헤더는 필수입니다. 한 IP에서 여러 사이트를 서비스하는 가상 호스팅 서버는 이 헤더로 사이트를 구분합니다. Connection: close는 응답을 보낸 뒤 연결을 닫아 달라는 뜻이고, 이 헤더가 없으면 HTTP/1.1은 기본으로 연결을 유지합니다.
HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 27\r\n
\r\n
{"message":"Hello, World!"}
응답은 상태 줄, 헤더, 빈 줄, 본문 순서입니다. 본문이 어디서 끝나는지는 세 가지 방식 중 하나로 결정됩니다(RFC 9112).
Transfer-Encoding: chunked: 본문이 “16진수 길이 줄 + 데이터” 청크의 연속이고, 길이 0인 청크가 끝을 알립니다.Content-Length: N: 빈 줄 뒤 정확히 N바이트가 본문입니다.- 둘 다 없으면: 서버가 연결을 닫을 때까지가 본문입니다.
HEAD 요청의 응답과 204, 304 응답은 헤더와 무관하게 본문이 없습니다. 이 규칙을 지키지 않으면 Keep-Alive 연결에서 다음 응답의 앞부분을 이번 응답의 본문으로 읽거나, 오지 않을 데이터를 기다리며 멈추게 됩니다.
#include <map>
#include <string>
struct HttpResponse {
int status_code = 0;
std::string status_message;
std::map<std::string, std::string> headers; // 이름은 소문자로 저장
std::string body;
};
getaddrinfo와 connect로 TCP 연결하기
#include <netdb.h>
#include <sys/socket.h>
#include <unistd.h>
#include <string>
int connectToHost(const std::string& host, uint16_t port) {
addrinfo hints{};
hints.ai_family = AF_UNSPEC; // IPv4와 IPv6 모두
hints.ai_socktype = SOCK_STREAM;
addrinfo* result = nullptr;
if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &result) != 0) {
return -1; // DNS 실패
}
int sock = -1;
for (addrinfo* ai = result; ai != nullptr; ai = ai->ai_next) {
sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (sock < 0) continue;
if (connect(sock, ai->ai_addr, ai->ai_addrlen) == 0) break; // 성공
close(sock);
sock = -1;
}
freeaddrinfo(result);
return sock; // 모든 주소에 실패하면 -1
}
getaddrinfo는 호스트 이름을 주소 목록으로 바꿉니다. 하나의 이름에 IPv4·IPv6 주소가 여러 개 돌아오는 경우가 많으므로, 첫 번째 주소만 시도하지 않고 성공할 때까지 목록을 순회합니다. AF_INET으로 IPv4만 요청하면 IPv6 전용 환경에서 연결이 안 됩니다. 성공·실패 어느 경로에서든 freeaddrinfo로 목록을 해제하고, 실패한 소켓은 close합니다. 이 함수는 블로킹 connect를 쓰므로, 응답 없는 주소에서는 커널의 SYN 재전송 한도(Linux 기본 설정에서 2분 안팎)까지 기다립니다. 타임아웃이 있는 버전은 뒤에서 다룹니다.
요청 전송과 응답 수신
요청 빌드와 전송
#include <cerrno>
#include <map>
#include <string>
#include <sys/socket.h>
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,
bool keepAlive) {
std::string req = method + " " + path + " HTTP/1.1\r\n";
req += "Host: " + host + "\r\n";
req += keepAlive ? "Connection: keep-alive\r\n" : "Connection: close\r\n";
if (!body.empty() || method == "POST" || method == "PUT") {
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;
}
// send는 요청 일부만 보낼 수 있으므로 끝까지 반복
bool sendAll(int fd, const std::string& data) {
size_t sent = 0;
while (sent < data.size()) {
ssize_t n = send(fd, data.data() + sent, data.size() - sent, MSG_NOSIGNAL);
if (n < 0) {
if (errno == EINTR) continue;
return false; // EAGAIN이면 SO_SNDTIMEO 타임아웃
}
sent += static_cast<size_t>(n);
}
return true;
}
send가 요청한 길이보다 적게 보내고 돌아오는 경우는 소켓 송신 버퍼가 찼을 때 실제로 생기므로, 반환값만큼 진행하며 반복합니다. MSG_NOSIGNAL은 상대가 이미 연결을 닫은 소켓에 쓸 때 프로세스를 죽이는 SIGPIPE 대신 EPIPE 에러를 받게 합니다(Linux 기준, macOS는 SO_NOSIGPIPE 소켓 옵션 사용). POST 본문이 있으면 Content-Length가 필수이고, JSON이라면 Content-Type: application/json도 extraHeaders로 넣습니다.
응답을 경계까지 정확히 읽기
연결이 닫힐 때까지 recv를 반복하는 방식은 Connection: close일 때만 동작하고, Keep-Alive 연결에서는 서버가 연결을 닫지 않으므로 타임아웃까지 멈춥니다. 앞에서 본 세 가지 본문 경계 규칙을 그대로 구현해야 합니다.
#include <algorithm>
#include <cctype>
#include <sstream>
// 한 번 recv해서 buf 뒤에 붙임. >0: 받은 바이트 수, 0: 상대가 연결 종료, <0: 에러·타임아웃
ssize_t recvSome(int fd, std::string& buf) {
char tmp[4096];
ssize_t n;
do {
n = recv(fd, tmp, sizeof(tmp), 0);
} while (n < 0 && errno == EINTR);
if (n > 0) buf.append(tmp, static_cast<size_t>(n));
return n;
}
// 상태 줄과 헤더 파싱
bool parseHead(const std::string& head, HttpResponse& res) {
std::istringstream iss(head);
std::string line;
if (!std::getline(iss, line)) return false;
if (!line.empty() && line.back() == '\r') line.pop_back();
std::istringstream status(line);
std::string version;
if (!(status >> version >> res.status_code)) return false;
std::getline(status >> std::ws, res.status_message);
while (std::getline(iss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
size_t colon = line.find(':');
if (colon == std::string::npos) continue;
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(),
[](unsigned char c) { return std::tolower(c); });
res.headers[name] = value;
}
return true;
}
bool readChunkedBody(int fd, std::string& buf, std::string& body) {
for (;;) {
size_t lineEnd;
while ((lineEnd = buf.find("\r\n")) == std::string::npos) {
if (recvSome(fd, buf) <= 0) return false;
}
// 청크 크기는 16진수, 뒤에 ";확장"이 붙을 수 있음 (stoul은 앞의 숫자만 읽음)
size_t chunkSize = std::stoul(buf.substr(0, lineEnd), nullptr, 16);
buf.erase(0, lineEnd + 2);
if (chunkSize == 0) {
// 트레일러 헤더(대개 없음)를 건너뛰고 마지막 빈 줄까지 소비
for (;;) {
size_t e;
while ((e = buf.find("\r\n")) == std::string::npos) {
if (recvSome(fd, buf) <= 0) return false;
}
buf.erase(0, e + 2);
if (e == 0) return true;
}
}
while (buf.size() < chunkSize + 2) { // 데이터 + CRLF
if (recvSome(fd, buf) <= 0) return false;
}
body.append(buf, 0, chunkSize);
buf.erase(0, chunkSize + 2);
}
}
// 응답 하나를 읽음. canReuse: 이 연결을 다음 요청에 다시 써도 되는지
bool readResponse(int fd, const std::string& method, HttpResponse& res, bool& canReuse) {
std::string buf;
size_t headEnd;
while ((headEnd = buf.find("\r\n\r\n")) == std::string::npos) {
if (buf.size() > 64 * 1024) return false; // 비정상적으로 큰 헤더
if (recvSome(fd, buf) <= 0) return false;
}
if (!parseHead(buf.substr(0, headEnd), res)) return false;
buf.erase(0, headEnd + 4);
auto header = [&](const char* name) -> std::string {
auto it = res.headers.find(name);
return it == res.headers.end() ? "" : it->second;
};
canReuse = header("connection") != "close";
if (method == "HEAD" || res.status_code == 204 || res.status_code == 304) {
return true; // 본문 없음
}
if (header("transfer-encoding").find("chunked") != std::string::npos) {
return readChunkedBody(fd, buf, res.body);
}
std::string cl = header("content-length");
if (!cl.empty()) {
size_t len = std::stoul(cl);
while (buf.size() < len) {
if (recvSome(fd, buf) <= 0) return false;
}
res.body = buf.substr(0, len);
return true;
}
// 길이 정보가 없으면 연결 종료가 본문의 끝
canReuse = false;
ssize_t n;
while ((n = recvSome(fd, buf)) > 0) {}
res.body = std::move(buf);
return n == 0;
}
이 구현은 요청 하나를 보내고 응답 하나를 읽는 방식을 가정합니다(파이프라이닝 없음). 그래서 응답 뒤에 남는 바이트가 없다고 보고 buf를 버립니다. 헤더 이름은 대소문자를 구분하지 않으므로 소문자로 바꿔 저장합니다. 실제 서비스 코드라면 stoul이 던지는 예외를 잡아 파싱 에러로 바꾸고, 본문 크기 상한도 두어야 합니다. 직접 구현해 보면 HTTP 파싱의 세부 규칙이 많다는 것을 알 수 있는데, 이 부분을 검증된 파서에 맡기려는 것이 Beast나 libcurl을 쓰는 가장 큰 이유입니다.
에러를 분류하는 요청 함수
실패했을 때 재시도할지 판단하려면 실패 원인을 구분해서 돌려줘야 합니다.
#include <sys/time.h>
enum class HttpError {
None, ConnectionFailed, SendFailed, RecvFailedOrTimeout, 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;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}
HttpResult doRequest(const std::string& method, const std::string& host,
const std::string& path, const std::string& body,
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);
if (!sendAll(fd, buildRequest(method, host, path, body, headers, false))) {
close(fd);
result.error = HttpError::SendFailed;
return result;
}
HttpResponse res;
bool canReuse = false;
bool received = readResponse(fd, method, res, canReuse);
close(fd);
if (!received) {
result.error = HttpError::RecvFailedOrTimeout;
return result;
}
result.status_code = res.status_code;
result.body = std::move(res.body);
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;
}
std::map<std::string, std::string> headers{
{"Authorization", "Bearer your-api-key"},
{"User-Agent", "MyApp/1.0"},
};
auto res = doRequest("GET", "api.example.com", "/v1/users", "", headers);
if (res.ok) { /* JSON 파싱 */ }
else if (res.error == HttpError::Http5xx) { /* 재시도 후보 */ }
headers["Content-Type"] = "application/json";
auto postRes = doRequest("POST", "api.example.com", "/v1/orders",
R"({"name":"test","value":42})", headers);
이 예제는 평문 HTTP(80번 포트)만 다룹니다. Authorization 헤더에 API 키를 실어 보내는 실제 API 호출은 반드시 HTTPS로 해야 하며, 다음 절의 Beast나 libcurl을 씁니다.
Boost.Beast로 HTTPS 요청하기
Boost.Beast는 Boost.Asio 위에서 동작하는 HTTP·WebSocket 라이브러리로, 메시지 직렬화·파싱(chunked 포함)을 맡고 TLS는 Asio의 OpenSSL 래퍼로 처리합니다. vcpkg install boost-beast 또는 배포판 패키지(libboost-all-dev)로 설치하고, OpenSSL을 함께 링크합니다.
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/ssl.hpp>
#include <openssl/ssl.h>
#include <chrono>
#include <string>
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
namespace ssl = net::ssl;
using tcp = net::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::tls_client};
ctx.set_default_verify_paths(); // 시스템 CA 인증서 사용
ctx.set_verify_mode(ssl::verify_peer); // 인증서 검증 켜기 (기본값은 검증 안 함)
beast::ssl_stream<beast::tcp_stream> stream{ioc, ctx};
// SNI: 한 IP에서 여러 도메인을 서비스하는 서버가 올바른 인증서를 고르게 함
if (!SSL_set_tlsext_host_name(stream.native_handle(), host.c_str())) {
return beast::error_code{static_cast<int>(::ERR_get_error()),
net::error::get_ssl_category()};
}
// 인증서의 호스트 이름이 접속한 host와 일치하는지 검증
stream.set_verify_callback(ssl::host_name_verification(host));
beast::error_code ec;
tcp::resolver resolver{ioc};
auto results = resolver.resolve(host, "443", ec);
if (ec) return ec;
beast::get_lowest_layer(stream).expires_after(std::chrono::seconds(10));
beast::get_lowest_layer(stream).connect(results, ec);
if (ec) return ec;
stream.handshake(ssl::stream_base::client, ec);
if (ec) return ec;
http::request<http::empty_body> req{http::verb::get, path, 11};
req.set(http::field::host, host);
req.set(http::field::user_agent, "MyApp/1.0");
http::write(stream, req, ec);
if (ec) return ec;
beast::flat_buffer buffer;
http::response<http::string_body> res;
http::read(stream, buffer, res, ec);
if (ec) return ec;
outBody = res.body();
stream.shutdown(ec);
// 많은 서버가 close_notify 없이 연결을 끊으므로 이 두 에러는 정상 종료로 취급
if (ec == net::error::eof || ec == ssl::error::stream_truncated) ec = {};
return ec;
}
TLS 코드에서 가장 흔한 실수는 인증서 검증을 켜지 않는 것입니다. Asio의 ssl::context는 기본 검증 모드가 verify_none이라, set_verify_mode(ssl::verify_peer)를 빠뜨리면 아무 인증서나 받아들여 중간자 공격에 그대로 노출됩니다. 그런데도 연결은 정상적으로 되기 때문에 테스트로는 드러나지 않습니다. 호스트 이름 검증(host_name_verification, Boost 1.73 이상)까지 걸어야 “신뢰할 수 있는 CA가 발급했지만 다른 도메인의 인증서”를 거부합니다. SNI를 설정하지 않으면 CDN이나 가상 호스팅 서버가 기본 인증서를 돌려줘 핸드셰이크가 실패하기도 합니다.
tcp_stream::expires_after는 다음 비동기 연산까지 남은 시간을 정하는 Beast의 타임아웃 기능인데, 공식적으로는 비동기 연산(async_connect, async_read 등)에만 적용됩니다. 위처럼 동기 호출을 쓰면 타임아웃이 걸리지 않으므로, 실제 코드에서는 비동기 API나 코루틴(net::co_spawn)을 쓰거나 소켓 수준의 타임아웃을 따로 둡니다. Beast는 chunked 응답, 헤더 파싱, 본문 경계를 모두 처리해 주므로 앞 절에서 직접 짠 readResponse 같은 코드가 필요 없습니다.
Keep-Alive 연결 풀로 연결 비용 줄이기
왜 연결 풀인가
| 방식 | 요청당 비용 (평문 HTTP) | 같은 서버에 100회 요청 |
|---|---|---|
| 요청마다 새 연결 | handshake 1 RTT + 요청·응답 1 RTT | 약 200 RTT |
| Keep-Alive 연결 재사용 | 첫 요청만 handshake 추가 | 약 101 RTT |
HTTPS라면 새 연결마다 TLS 핸드셰이크가 1~2 RTT 더 붙으므로 차이가 더 커집니다. 연결 풀은 응답을 다 읽은 연결을 닫지 않고 보관했다가, 같은 호스트로 가는 다음 요청에 다시 씁니다.
연결 풀 구현
#include <chrono>
#include <deque>
#include <mutex>
#include <unordered_map>
class HttpConnectionPool {
struct Idle {
int fd;
std::chrono::steady_clock::time_point last_used;
};
std::unordered_map<std::string, std::deque<Idle>> pool_;
std::mutex mutex_;
size_t max_idle_per_host_ = 4;
std::chrono::seconds idle_timeout_{30};
static std::string key(const std::string& host, uint16_t port) {
return host + ":" + std::to_string(port);
}
public:
~HttpConnectionPool() {
for (auto& [k, q] : pool_)
for (auto& c : q) close(c.fd);
}
// 보관 중인 연결을 꺼냄. 없으면 -1 (새 연결은 호출자가 만든다)
int takeIdle(const std::string& host, uint16_t port) {
std::lock_guard<std::mutex> lock(mutex_);
auto& q = pool_[key(host, port)];
auto now = std::chrono::steady_clock::now();
while (!q.empty()) {
Idle c = q.back(); // 가장 최근에 쓴 연결부터
q.pop_back();
if (now - c.last_used < idle_timeout_) return c.fd;
close(c.fd);
}
return -1;
}
void release(int fd, const std::string& host, uint16_t port) {
std::lock_guard<std::mutex> lock(mutex_);
auto& q = pool_[key(host, port)];
if (q.size() < max_idle_per_host_) {
q.push_back({fd, std::chrono::steady_clock::now()});
} else {
close(fd);
}
}
};
유휴 타임아웃은 서버의 Keep-Alive 타임아웃보다 짧게 잡습니다. 서버는 일정 시간 요청이 없는 연결을 먼저 닫는데(Nginx 기본 75초, 많은 로드밸런서는 60초 안팎), 클라이언트가 그보다 오래 들고 있던 연결을 꺼내 쓰면 서버가 이미 닫은 연결에 요청을 보내게 됩니다.
끊긴 연결을 재사용했을 때의 재시도
유휴 타임아웃을 짧게 잡아도, 서버가 재시작되거나 중간 장비가 연결을 끊는 경우는 막을 수 없습니다. 이때 클라이언트는 요청을 보낸 뒤 바로 recv가 0(연결 종료)이나 ECONNRESET을 받습니다. 그래서 풀에서 꺼낸 연결로 실패했을 때는 새 연결로 한 번 다시 보내는 처리가 연결 풀의 사실상 필수 요소입니다.
class PooledHttpClient {
HttpConnectionPool pool_;
bool tryOnce(int fd, const std::string& host, const std::string& path,
HttpResponse& res, bool& canReuse) {
if (!sendAll(fd, buildRequest("GET", host, path, "", {}, true))) return false;
return readResponse(fd, "GET", res, canReuse);
}
public:
bool get(const std::string& host, const std::string& path,
std::string& outBody, int timeoutSec = 10) {
HttpResponse res;
bool canReuse = false;
int fd = pool_.takeIdle(host, 80);
if (fd >= 0) {
setSocketTimeouts(fd, timeoutSec);
if (!tryOnce(fd, host, path, res, canReuse)) {
close(fd); // 서버가 이미 닫은 연결이었을 가능성 → 새 연결로 재시도
fd = -1;
res = HttpResponse{};
}
}
if (fd < 0) {
fd = connectToHost(host, 80);
if (fd < 0) return false;
setSocketTimeouts(fd, timeoutSec);
if (!tryOnce(fd, host, path, res, canReuse)) {
close(fd);
return false;
}
}
if (canReuse) pool_.release(fd, host, 80);
else close(fd);
outBody = std::move(res.body);
return res.status_code >= 200 && res.status_code < 300;
}
};
이 자동 재시도는 GET처럼 여러 번 실행해도 결과가 같은(멱등) 요청에만 안전합니다. POST는 첫 요청이 서버에 도달해 처리된 뒤에 연결이 끊긴 것일 수도 있으므로, 재사용 연결에서 실패했다고 무조건 다시 보내면 주문이 두 번 생길 수 있습니다. POST를 재시도하려면 서버가 Idempotency-Key 같은 중복 방지 수단을 제공해야 합니다.
타임아웃 걸기
SO_RCVTIMEO와 SO_SNDTIMEO
setSocketTimeouts처럼 소켓 옵션을 걸면 recv·send가 지정 시간 안에 진행하지 못할 때 -1을 반환하고 errno가 EAGAIN 또는 EWOULDBLOCK이 됩니다. 주의할 점은 이것이 “호출 하나당” 타임아웃이라는 것입니다. 서버가 9초마다 1바이트씩 보내면 10초 타임아웃은 영영 터지지 않습니다. 요청 전체에 상한을 두려면 시작 시각을 기록해 두고, 루프마다 남은 시간을 계산해 poll로 기다리는 방식이 필요합니다.
connect 타임아웃 (poll 사용)
블로킹 connect는 응답 없는 서버에 대해 커널의 재전송 한도까지 기다립니다. 소켓을 논블로킹으로 바꿔 connect를 시작하고, poll로 쓰기 가능 상태가 될 때까지 시간을 제한해 기다리는 방식이 이식성이 좋습니다.
#include <fcntl.h>
#include <poll.h>
int connectWithTimeout(const addrinfo* ai, int timeoutMs) {
int sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (sock < 0) return -1;
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);
int ret = connect(sock, ai->ai_addr, ai->ai_addrlen);
if (ret != 0 && errno != EINPROGRESS) {
close(sock);
return -1;
}
if (ret != 0) {
pollfd pfd{sock, POLLOUT, 0};
int pr;
do {
pr = poll(&pfd, 1, timeoutMs);
} while (pr < 0 && errno == EINTR);
if (pr <= 0) { // 0: 타임아웃, <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); // err에 ECONNREFUSED, ETIMEDOUT 등이 들어 있음
return -1;
}
}
fcntl(sock, F_SETFL, flags); // 블로킹 모드로 복원
return sock;
}
select로도 같은 일을 할 수 있지만, fd_set은 파일 디스크립터 번호가 FD_SETSIZE(보통 1024) 이상이면 쓸 수 없어 연결이 많은 프로세스에서 메모리를 망가뜨립니다. poll에는 이 제한이 없습니다. connectToHost의 주소 순회 루프에서 connect 대신 이 함수를 호출하면 됩니다.
타임아웃 값은 고정된 정답이 없습니다. 호출하는 API의 평소 응답 시간 분포(특히 p99)를 보고 그보다 넉넉하게 잡되, 호출하는 쪽이 사용자에게 약속한 응답 시간을 넘지 않도록 정합니다. 연결 타임아웃은 대개 읽기 타임아웃보다 짧게 둡니다. 연결 자체가 오래 걸린다는 것은 이미 네트워크나 서버에 문제가 있다는 신호이기 때문입니다.
자주 만나는 에러
ECONNREFUSED와 ETIMEDOUT
ECONNREFUSED는 대상 호스트에 도달했지만 그 포트에서 리스닝하는 프로세스가 없어 RST를 받은 경우입니다. 포트 번호(HTTP 80, HTTPS 443)와 서버 프로세스 상태를 확인합니다. ETIMEDOUT은 SYN에 아무 응답이 없는 경우로, 방화벽이나 보안 그룹이 패킷을 조용히 버리거나 호스트가 꺼져 있을 때 납니다. 두 에러를 구분하면 “서버는 살아 있는데 서비스가 죽음”과 “네트워크 경로 문제”를 빨리 가를 수 있습니다.
getaddrinfo 실패
int ret = getaddrinfo(host.c_str(), port.c_str(), &hints, &result);
if (ret != 0) {
std::fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ret));
return -1;
}
getaddrinfo는 errno가 아니라 반환값으로 에러 코드를 돌려주므로 gai_strerror(ret)로 메시지를 얻습니다. 호스트 이름 오타, DNS 서버 불가, 네트워크 단절이 흔한 원인이며, nslookup이나 dig로 같은 이름을 조회해 보면 DNS 문제인지 바로 알 수 있습니다. 컨테이너 환경에서는 /etc/resolv.conf 설정 때문에 호스트에서는 되는 이름이 컨테이너 안에서만 안 풀리는 경우도 있습니다.
recv가 응답 일부만 돌려줌
TCP는 메시지 경계가 없는 바이트 스트림이라, 한 번의 recv가 응답 전체를 돌려준다는 보장이 없습니다. 로컬 테스트에서는 응답이 작고 지연이 짧아 한 번에 다 들어오다가, 실제 네트워크에서만 잘리는 일이 흔합니다. 앞의 readResponse처럼 헤더 끝까지, 그다음 본문 경계 규칙에 따라 끝까지 반복해서 읽어야 합니다.
freeaddrinfo·close 누락
getaddrinfo가 할당한 목록과 socket이 만든 파일 디스크립터는 모든 경로에서 해제해야 합니다. 조기 반환이 많아질수록 빠뜨리기 쉬우므로, 실제 코드에서는 std::unique_ptr<addrinfo, decltype(&freeaddrinfo)>나 소멸자에서 close하는 소켓 클래스로 감싸 RAII로 관리하는 편이 안전합니다. 디스크립터 누수는 한동안 아무 증상이 없다가 “Too many open files”로 갑자기 드러납니다.
EINTR
시그널 핸들러가 실행되면 블로킹 중이던 recv, send, poll이 EINTR로 실패할 수 있습니다. SA_RESTART 없이 등록한 핸들러가 있는 프로그램에서 생기며, 위 예제들처럼 EINTR이면 같은 호출을 다시 하도록 감쌉니다. 논블로킹 connect가 EINTR로 실패한 경우에는 다시 connect하지 말고 poll로 완료를 기다려야 합니다.
요청 처리량 비교와 병목 지점
#include <chrono>
#include <iostream>
template <typename F>
void measure(const char* label, int iterations, F&& fn) {
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < iterations; ++i) fn();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
std::cout << label << ": " << ms << " ms for " << iterations << " requests\n";
}
int main() {
std::string body;
measure("new connection", 100, [&] {
doRequest("GET", "httpbin.org", "/get", "", {});
});
PooledHttpClient pooled;
measure("pooled", 100, [&] {
pooled.get("httpbin.org", "/get", body);
});
}
새 연결 방식은 요청마다 TCP 핸드셰이크 1 RTT가 더 들고, 풀 방식은 첫 요청에서만 이 비용을 냅니다. 따라서 두 결과의 차이는 대략 요청 수 × RTT만큼 벌어집니다. RTT가 100ms인 먼 서버라면 100회 요청에서 10초 가까이 차이가 날 수 있고, 같은 데이터센터 안이라면 차이가 미미합니다. 결과를 기록할 때는 ping으로 잰 RTT를 함께 남겨야 해석이 가능합니다.
요청 하나의 시간은 DNS 조회(결과 캐싱으로 줄임), TCP 핸드셰이크(연결 재사용으로 제거), TLS 핸드셰이크(연결 재사용·세션 재개로 제거), 요청·응답 전송(압축, 본문 크기 축소)으로 나뉩니다. 각 단계의 비중은 네트워크 거리와 응답 크기에 따라 크게 달라지므로, curl -o /dev/null -s -w "%{time_namelookup} %{time_connect} %{time_appconnect} %{time_total}\n" URL로 단계별 시간을 먼저 재 보고 가장 큰 단계부터 줄입니다.
재시도와 회로 차단기
재시도 정책
#include <random>
#include <thread>
bool getWithRetry(PooledHttpClient& client, const std::string& host,
const std::string& path, std::string& outBody, int maxAttempts = 3) {
std::mt19937 rng{std::random_device{}()};
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
if (client.get(host, path, outBody, 10)) return true;
if (attempt + 1 == maxAttempts) break;
// 지수 백오프(0.5s, 1s, 2s...)에 무작위 지터를 섞어 여러 클라이언트가 동시에 재시도하지 않게 함
int baseMs = 500 << attempt;
std::uniform_int_distribution<int> jitter(0, baseMs);
std::this_thread::sleep_for(std::chrono::milliseconds(baseMs + jitter(rng)));
}
return false;
}
재시도 대상은 타임아웃, 연결 실패, 502·503·504 같은 일시적 장애로 한정합니다. 400·401·404처럼 요청 자체가 잘못된 4xx는 몇 번을 보내도 결과가 같으므로 재시도하지 않습니다. 예외는 429(Too Many Requests)와 503인데, 서버가 Retry-After 헤더를 보내면 그 시간만큼 기다린 뒤 재시도합니다. 지터가 없으면 장애가 복구되는 순간 모든 클라이언트가 같은 박자로 재시도해 서버를 다시 넘어뜨리는 일이 생깁니다. POST처럼 멱등하지 않은 요청은 앞 절에서 설명한 대로 서버 측 중복 방지 수단이 있을 때만 재시도합니다.
회로 차단기
같은 서버가 계속 실패하는데도 매 요청마다 타임아웃까지 기다리면, 호출하는 쪽 스레드가 모두 묶여 장애가 번집니다. 회로 차단기는 연속 실패가 일정 횟수를 넘으면 한동안 요청을 보내지 않고 바로 실패를 돌려주며(Open), 대기 시간이 지나면 요청 하나만 시험 삼아 보내(Half-Open) 성공하면 정상 상태(Closed)로 돌아갑니다.
stateDiagram-v2 [*] --> Closed Closed --> Open: 연속 실패가 임계값 도달 Open --> HalfOpen: 대기 시간 경과 HalfOpen --> Closed: 시험 요청 성공 HalfOpen --> Open: 시험 요청 실패
#include <mutex>
class CircuitBreaker {
enum class State { Closed, Open, HalfOpen };
State state_ = State::Closed;
int failures_ = 0;
std::chrono::steady_clock::time_point opened_at_;
std::mutex mutex_;
const int threshold_ = 5;
const std::chrono::seconds cooldown_{30};
public:
bool allowRequest() {
std::lock_guard<std::mutex> lock(mutex_);
if (state_ == State::Closed) return true;
if (state_ == State::Open &&
std::chrono::steady_clock::now() - opened_at_ >= cooldown_) {
state_ = State::HalfOpen; // 시험 요청 하나만 통과
return true;
}
return false; // Open이거나, HalfOpen에서 시험 요청 진행 중
}
void recordSuccess() {
std::lock_guard<std::mutex> lock(mutex_);
state_ = State::Closed;
failures_ = 0;
}
void recordFailure() {
std::lock_guard<std::mutex> lock(mutex_);
if (state_ == State::HalfOpen || ++failures_ >= threshold_) {
state_ = State::Open;
opened_at_ = std::chrono::steady_clock::now();
}
}
};
상태와 시각을 여러 스레드가 함께 읽고 쓰므로 mutex 하나로 묶었습니다. 카운터만 std::atomic으로 바꾸고 시각은 일반 변수로 두면, 두 값이 서로 다른 시점의 상태를 보게 되어 데이터 경쟁이 생깁니다. Half-Open에서 시험 요청을 하나만 통과시키는 것도 중요합니다. 대기 시간이 끝나자마자 모든 요청을 풀어 주면 아직 회복되지 않은 서버에 부하가 한꺼번에 몰립니다.
회로 차단기와 재시도를 함께 쓸 때는 재시도 한 번 한 번을 모두 실패로 기록하는지, 최종 결과만 기록하는지를 정해 두어야 임계값의 의미가 분명해집니다. 요청 시간·상태 코드·실패 유형을 메트릭으로 남겨 두면 임계값과 대기 시간을 실제 장애 패턴에 맞게 조정할 수 있습니다.
같이 보면 좋은 글
- C++ 소켓 프로그래밍
- C++ JSON 파싱
- C++ RAII: 생성자에서 획득하고 소멸자에서 해제하는 자원 관리 클래스 만들기
- 이전 글: 디자인 패턴 종합: Singleton·Factory·Observer·Strategy·PIMPL
- 다음 글: 간단한 작업 큐 구현: 스레드 풀과 비동기 실행