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가 한 번에 전체를 반환하지 않음
해결책:
- 완전한 HTTP 클라이언트: 연결 → 요청 전송 → 응답 수신 → 파싱까지 한 흐름으로
- 연결 풀(Connection Pool): Keep-Alive로 연결 재사용, handshake 비용 절감
- 타임아웃 처리:
SO_RCVTIMEO,SO_SNDTIMEO또는select로 무한 대기 방지 - 에러 분류: 연결 실패, 타임아웃, 파싱 에러 구분해 재시도/폴백 결정
목표:
- 완전한 HTTP 클라이언트 구현 (GET, POST, 헤더 파싱)
- Keep-Alive 연결 풀로 성능 최적화
- 타임아웃과 에러 처리 패턴
- 자주 발생하는 에러와 해결법
- 성능 벤치마크 (풀 vs 새 연결)
- 프로덕션 배포 체크리스트
요구 환경: POSIX 소켓 (Linux, macOS). Windows에서는 WSL 사용 또는 Winsock으로 별도 구현 필요.
이 글을 읽으면:
- TCP 소켓으로 HTTP 요청을 보내고 응답을 받는 전체 흐름을 이해할 수 있습니다.
- 연결 풀을 적용해 성능을 2~3배 개선할 수 있습니다.
- 타임아웃, 재시도, 에러 분류를 실전에 적용할 수 있습니다.
목차
- 문제 시나리오와 해결 방향
- HTTP 프로토콜 기초
- TCP 소켓 연결
- 완전한 HTTP 클라이언트 구현
- 연결 풀 (Connection Pool)
- 타임아웃 처리
- 자주 발생하는 에러와 해결법
- 성능 벤치마크
- 프로덕션 패턴
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;
}
위 코드 설명: getaddrinfo는 host:port를 sockaddr 구조체로 변환합니다. hints에 AF_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을 반환합니다. errno는 EAGAIN 또는 EWOULDBLOCK입니다.
6.2 connect 타임아웃 (select 사용)
connect는 SO_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 타임아웃 값 권장
| 상황 | 연결 타임아웃 | 읽기/쓰기 타임아웃 |
|---|---|---|
| 로컬 LAN | 2~5초 | 5~10초 |
| 인터넷 | 5~10초 | 10~30초 |
| 느린 API | 10~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 인코딩: 쿼리 파라미터 특수문자(
&,=, 공백)는 퍼센트 인코딩 - 타임아웃: 연결 5
10초, 읽기 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
참고 자료
- cppreference - POSIX 소켓
- RFC 7230 - HTTP/1.1: Message Syntax and Routing
- Boost.Beast - HTTP and WebSocket
- libcurl - curl_easy_setopt
관련 글
- C++ JSON 파싱 완벽 가이드 | nlohmann·RapidJSON·커스텀 타입·에러 처리·프로덕션 패턴
- C++ REST API 클라이언트 완벽 가이드 | CRUD·인증·에러 처리·프로덕션 패턴 [#21-3]
- C++ 작업 큐 완벽 가이드 | 스레드 풀·워크 스틸링·성능 벤치마크 [#21-2]
- C++ 디자인 패턴 | Observer·Strategy
- C++ RAII 완벽 가이드 |