C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩
이 글의 핵심
HTTP 요청/응답 파싱이 어려운 문제를 해결합니다. 완전한 HTTP 파서, Beast 기반 클라이언트/서버, Keep-Alive 연결 풀, 청크 인코딩, 타임아웃, 에러 처리, 베스트 프랙티스, 프로덕션 패턴까지 실전 코드로 구현합니다.
들어가며: “HTTP 요청/응답 파싱이 어려워요”
실제 겪는 문제 시나리오
// ❌ 문제: recv로 받은 데이터가 한 번에 오지 않음
char buffer[4096];
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
// buffer에 "GET /path HTTP/1.1\r\nHost: ex" 까지만 들어올 수 있음!
// \r\n\r\n이 없어서 헤더 끝을 알 수 없음
// Content-Length도 아직 못 읽음
실제 프로덕션에서 겪는 문제들:
- 불완전한 응답: recv가 한 번에 전체를 반환하지 않아 파싱 실패
- 타임아웃: 느린 서버에서 무한 대기
- 연결 낭비: 요청마다 새 TCP 연결 → 3-way handshake 반복
- 청크 인코딩: Content-Length 없이
Transfer-Encoding: chunked로 오는 응답 처리 못 함 - 헤더 대소문자:
Content-Lengthvscontent-length혼동
문제 시나리오 시각화
sequenceDiagram
participant C as 클라이언트
participant S as 서버
C->>S: recv() 호출
Note over S: 패킷 1: "GET / HTTP/1.1\r\nHo"
S-->>C: 20바이트 반환
Note over C: \r\n\r\n 없음 → 헤더 끝 모름
C->>S: recv() 재호출
Note over S: 패킷 2: "st: example.com\r\n\r\n"
S-->>C: 28바이트 반환
Note over C: 이제 파싱 가능
추가 문제 시나리오
- Keep-Alive 경계 혼동: 한 연결에 여러 요청이 오면 버퍼에 잔여 데이터가 섞임.
req_ = {},buffer_.consume()으로 초기화 필요. - Content-Length 불일치: 서버가 1000바이트라고 했는데 500바이트만 보내고 끊으면 무한 대기. 타임아웃 필수.
- 청크 파싱 오류:
1a\r\n(26바이트)을 10진수로 파싱하면 잘못됨. 16진수(stoull(..., 16)) 사용. - 동시 연결 폭주: 요청마다 새 스레드 생성 시 스레드 수 폭증. 스레드 풀 + 비동기 I/O 권장.
해결책:
- HTTP 파서: 버퍼에 데이터 누적,
\r\n\r\n찾기, Content-Length 기반 본문 읽기 - 연결 풀: Keep-Alive로 연결 재사용
- 청크 파서:
크기\r\n데이터\r\n반복 파싱 - 타임아웃:
SO_RCVTIMEO또는select - Boost.Beast: RFC 준수 파서, 에러 처리 내장
목표:
- HTTP 파서 구현 (요청/응답)
- Beast 기반 완전한 클라이언트/서버
- Keep-Alive 연결 풀
- 청크 인코딩 파싱
- 타임아웃과 에러 처리
- 베스트 프랙티스와 프로덕션 패턴
요구 환경: POSIX 소켓, Boost.Asio, Boost.Beast 1.70+
이 글을 읽으면:
- HTTP 프로토콜의 동작 원리를 이해할 수 있습니다.
- 완전한 HTTP 클라이언트/서버를 구현할 수 있습니다.
- Beast로 프로덕션 수준의 HTTP 통신을 구현할 수 있습니다.
개념을 잡는 비유
소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.
목차
- HTTP 프로토콜 구조
- 문제 시나리오 상세
- HTTP 파서 구현
- Beast HTTP 클라이언트
- Beast HTTP 서버
- HTTP 클라이언트 (Keep-Alive, 소켓)
- HTTP 서버 (라우팅)
- 청크 인코딩 처리
- 타임아웃 처리
- 자주 발생하는 에러
- 베스트 프랙티스
- 프로덕션 패턴
- 성능 비교
1. HTTP 프로토콜 구조
HTTP 요청 형식
GET /api/users HTTP/1.1\r\n
Host: example.com\r\n
User-Agent: MyClient/1.0\r\n
Connection: keep-alive\r\n
\r\n
구조:
- 요청 라인:
메서드 경로 버전\r\n - 헤더:
이름: 값\r\n(여러 줄) - 빈 줄:
\r\n - 본문: (POST 등에서 사용)
HTTP 응답 형식
HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 27\r\n
Connection: keep-alive\r\n
\r\n
{"message":"Hello, World!"}
구조:
- 상태 라인:
버전 코드 메시지\r\n - 헤더:
이름: 값\r\n - 빈 줄:
\r\n - 본문: Content-Length 바이트만큼
요청/응답 구조체
#include <string>
#include <map>
struct HttpRequest {
std::string method;
std::string path;
std::string version;
std::map<std::string, std::string> headers;
std::string body;
};
struct HttpResponse {
std::string version;
int status_code;
std::string status_message;
std::map<std::string, std::string> headers;
std::string body;
};
2. 문제 시나리오 상세
시나리오 1: 불완전한 수신
flowchart LR
subgraph 문제["recv 한 번 호출"]
A[버퍼] --> B["GET /path HTTP/1.1\r\nHo"]
B --> C["\r\n\r\n 없음"]
end
subgraph 해결["버퍼 누적"]
D[버퍼] --> E["데이터 추가 수신"]
E --> F["\r\n\r\n 발견"]
F --> G[파싱 가능]
end
원인: TCP는 스트림 프로토콜이라 메시지 경계가 없습니다. 한 번의 recv로 전체 HTTP 메시지가 올 보장이 없습니다.
해결: \r\n\r\n이 나올 때까지 버퍼에 데이터를 누적합니다.
시나리오 2: Keep-Alive 연결 풀 효과
flowchart TB
subgraph 새연결["요청마다 새 연결"]
N1[요청1] --> H1[3-way handshake]
H1 --> R1[응답1]
N2[요청2] --> H2[3-way handshake]
H2 --> R2[응답2]
end
subgraph 풀["Keep-Alive 풀"]
P1[요청1] --> C[연결 재사용]
C --> P2[요청2]
C --> P3[요청3]
end
비용: 새 연결 시 RTT(왕복 지연) + TLS 핸드셰이크(HTTPS) 추가. Keep-Alive로 5~10배 성능 향상 가능합니다.
차이: Content-Length는 본문 크기를 미리 알 때 사용. 스트리밍 응답(실시간, 대용량)은 청크 인코딩을 씁니다.
3. HTTP 파서 구현
완전한 HTTP 파서 클래스
#include <string>
#include <sstream>
#include <algorithm>
class HttpParser {
public:
// 버퍼에서 \r\n\r\n까지 읽기
static bool readUntilHeaders(int fd, std::string& buffer) {
char temp[1024];
while (buffer.find("\r\n\r\n") == std::string::npos) {
ssize_t n = recv(fd, temp, sizeof(temp), 0);
if (n <= 0) return false;
buffer.append(temp, n);
// 헤더가 너무 크면 에러
if (buffer.size() > 8192) {
throw std::runtime_error("Headers too large");
}
}
return true;
}
// 요청 파싱
static HttpRequest parseRequest(const std::string& data) {
HttpRequest req;
size_t header_end = data.find("\r\n\r\n");
if (header_end == std::string::npos) {
throw std::runtime_error("Invalid HTTP request");
}
std::string header_part = data.substr(0, header_end);
req.body = data.substr(header_end + 4);
std::istringstream stream(header_part);
std::string line;
// 첫 줄: 요청 라인
std::getline(stream, line);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
std::istringstream request_line(line);
request_line >> req.method >> req.path >> req.version;
// 헤더 파싱
while (std::getline(stream, line)) {
if (!line.empty() && 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);
req.headers[name] = value;
}
}
return req;
}
// 응답 파싱
static HttpResponse parseResponse(const std::string& data) {
HttpResponse res;
size_t header_end = data.find("\r\n\r\n");
if (header_end == std::string::npos) {
throw std::runtime_error("Invalid HTTP response");
}
std::string header_part = data.substr(0, header_end);
res.body = data.substr(header_end + 4);
std::istringstream stream(header_part);
std::string line;
// 첫 줄: 상태 라인
std::getline(stream, line);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
std::istringstream status_line(line);
status_line >> res.version >> res.status_code;
std::getline(status_line, res.status_message);
// 앞 공백 제거
if (!res.status_message.empty() && res.status_message[0] == ' ') {
res.status_message = res.status_message.substr(1);
}
// 헤더 파싱
while (std::getline(stream, line)) {
if (!line.empty() && 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;
}
}
return res;
}
// Content-Length 기반 본문 읽기
static void readBody(int fd, HttpResponse& res, std::string& buffer) {
auto it = res.headers.find("content-length");
if (it == res.headers.end()) return;
size_t content_length = std::stoull(it->second);
// 이미 버퍼에 있는 본문
size_t already_read = res.body.size();
// 더 읽어야 할 바이트
while (res.body.size() < content_length) {
char temp[4096];
size_t to_read = std::min(sizeof(temp), content_length - res.body.size());
ssize_t n = recv(fd, temp, to_read, 0);
if (n <= 0) {
throw std::runtime_error("Connection closed while reading body");
}
res.body.append(temp, n);
}
}
// 청크 인코딩 본문 읽기
static void readChunkedBody(int fd, std::string& body) {
std::string buffer;
while (true) {
// 청크 크기 읽기 (hex\r\n)
while (buffer.find("\r\n") == std::string::npos) {
char temp[256];
ssize_t n = recv(fd, temp, sizeof(temp), 0);
if (n <= 0) throw std::runtime_error("Connection closed");
buffer.append(temp, n);
}
size_t line_end = buffer.find("\r\n");
std::string size_line = buffer.substr(0, line_end);
buffer = buffer.substr(line_end + 2);
// 16진수 크기 파싱
size_t chunk_size = std::stoull(size_line, nullptr, 16);
if (chunk_size == 0) {
// 마지막 청크
break;
}
// 청크 데이터 읽기
while (buffer.size() < chunk_size + 2) { // +2 for \r\n
char temp[4096];
ssize_t n = recv(fd, temp, sizeof(temp), 0);
if (n <= 0) throw std::runtime_error("Connection closed");
buffer.append(temp, n);
}
body.append(buffer.substr(0, chunk_size));
buffer = buffer.substr(chunk_size + 2); // Skip \r\n
}
}
};
4. Beast HTTP 클라이언트
완전한 Beast HTTP 클라이언트 (동기)
#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <iostream>
#include <string>
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
using tcp = net::ip::tcp;
class BeastHttpClient {
public:
BeastHttpClient(net::io_context& ioc) : resolver_(ioc), stream_(ioc) {}
HttpResponse get(const std::string& host, const std::string& port,
const std::string& path) {
// DNS 조회
auto const results = resolver_.resolve(host, port);
// 연결
beast::get_lowest_layer(stream_).connect(results);
// HTTP GET 요청
http::request<http::string_body> req{http::verb::get, path, 11};
req.set(http::field::host, host);
req.set(http::field::user_agent, "BeastHttpClient/1.0");
req.keep_alive(false);
// 전송
http::write(stream_, req);
// 응답 수신
beast::flat_buffer buffer;
http::response<http::string_body> res;
http::read(stream_, buffer, res);
// 연결 종료
beast::error_code ec;
stream_.socket().shutdown(tcp::socket::shutdown_both, ec);
// HttpResponse로 변환
HttpResponse result;
result.version = "HTTP/1.1";
result.status_code = res.result_int();
result.status_message = std::string(res.reason());
result.body = res.body();
for (const auto& field : res) {
result.headers[std::string(field.name_string())] =
std::string(field.value());
}
return result;
}
HttpResponse post(const std::string& host, const std::string& port,
const std::string& path, const std::string& body,
const std::string& content_type = "application/json") {
auto const results = resolver_.resolve(host, port);
beast::get_lowest_layer(stream_).connect(results);
http::request<http::string_body> req{http::verb::post, path, 11};
req.set(http::field::host, host);
req.set(http::field::content_type, content_type);
req.body() = body;
req.prepare_payload();
http::write(stream_, req);
beast::flat_buffer buffer;
http::response<http::string_body> res;
http::read(stream_, buffer, res);
beast::error_code ec;
stream_.socket().shutdown(tcp::socket::shutdown_both, ec);
HttpResponse result;
result.status_code = res.result_int();
result.body = res.body();
return result;
}
private:
tcp::resolver resolver_;
beast::tcp_stream stream_;
};
// 사용 예
int main() {
net::io_context ioc;
BeastHttpClient client(ioc);
auto res = client.get("example.com", "80", "/");
std::cout << "Status: " << res.status_code << "\n";
std::cout << "Body: " << res.body.substr(0, 200) << "...\n";
}
참고: Beast의 http::read는 Transfer-Encoding: chunked를 자동 디코딩합니다.
5. Beast HTTP 서버
완전한 Beast HTTP 서버 (비동기)
#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <iostream>
#include <memory>
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
using tcp = net::ip::tcp;
class HttpSession : public std::enable_shared_from_this<HttpSession> {
beast::tcp_stream stream_;
beast::flat_buffer buffer_;
http::request<http::string_body> req_;
public:
explicit HttpSession(tcp::socket socket)
: stream_(std::move(socket)) {}
void start() { do_read(); }
private:
void do_read() {
req_ = {};
buffer_.consume(buffer_.size());
auto self = shared_from_this();
http::async_read(stream_, buffer_, req_,
[self, this](beast::error_code ec, std::size_t) {
if (ec) {
if (ec != http::error::end_of_stream)
std::cerr << "read: " << ec.message() << "\n";
return;
}
handle_request();
});
}
void handle_request() {
http::response<http::string_body> res{http::status::ok, req_.version()};
res.set(http::field::server, "Beast-HTTP-Server");
res.set(http::field::content_type, "text/plain");
if (req_.method() == http::verb::get && req_.target() == "/") {
res.body() = "Hello, World!";
} else if (req_.method() == http::verb::get &&
req_.target().starts_with("/api/")) {
res.set(http::field::content_type, "application/json");
res.body() = "{\"message\":\"API response\"}";
} else if (req_.method() == http::verb::get &&
req_.target() == "/health") {
res.set(http::field::content_type, "application/json");
res.body() = "{\"status\":\"ok\"}";
} else {
res.result(http::status::not_found);
res.body() = "Not Found";
}
res.prepare_payload();
auto self = shared_from_this();
http::async_write(stream_, res,
[self, this](beast::error_code ec, std::size_t) {
if (!ec) {
if (req_.keep_alive()) {
do_read(); // Keep-Alive: 다음 요청
}
}
});
}
};
int main() {
net::io_context ioc;
tcp::acceptor acceptor(ioc, {tcp::v4(), 8080});
auto do_accept = [&]() {
acceptor.async_accept(ioc,
[&](beast::error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<HttpSession>(std::move(socket))->start();
}
do_accept();
});
};
do_accept();
std::cout << "HTTP server on :8080\n";
ioc.run();
}
6. HTTP 클라이언트 (Keep-Alive, 소켓)
연결 풀 구현
#include <unordered_map>
#include <queue>
#include <mutex>
class HttpClient {
struct Connection {
int fd;
std::string host;
uint16_t port;
std::chrono::steady_clock::time_point last_used;
};
std::unordered_map<std::string, std::queue<Connection>> pool_;
std::mutex mutex_;
const size_t max_connections_per_host_ = 4;
const std::chrono::seconds idle_timeout_{30};
public:
int getConnection(const std::string& host, uint16_t port) {
std::lock_guard<std::mutex> lock(mutex_);
std::string key = host + ":" + std::to_string(port);
auto& queue = pool_[key];
while (!queue.empty()) {
auto conn = queue.front();
queue.pop();
if (std::chrono::steady_clock::now() - conn.last_used < idle_timeout_)
return conn.fd;
close(conn.fd);
}
return connectNew(host, port);
}
void returnConnection(int fd, const std::string& host, uint16_t port) {
std::lock_guard<std::mutex> lock(mutex_);
std::string key = host + ":" + std::to_string(port);
if (pool_[key].size() < max_connections_per_host_)
pool_[key].push({fd, host, port, std::chrono::steady_clock::now()});
else
close(fd);
}
HttpResponse get(const std::string& host, uint16_t port, const std::string& path) {
int fd = getConnection(host, port);
try {
std::string request = "GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nConnection: keep-alive\r\n\r\n";
send(fd, request.c_str(), request.size(), 0);
std::string buffer;
HttpParser::readUntilHeaders(fd, buffer);
HttpResponse res = HttpParser::parseResponse(buffer);
auto it = res.headers.find("transfer-encoding");
if (it != res.headers.end() && it->second.find("chunked") != std::string::npos)
HttpParser::readChunkedBody(fd, res.body);
else
HttpParser::readBody(fd, res, buffer);
returnConnection(fd, host, port);
return res;
} catch (...) {
close(fd);
throw;
}
}
private:
int connectNew(const std::string& host, uint16_t port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) throw std::runtime_error("socket() failed");
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, "93.184.216.34", &addr.sin_addr);
if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
close(fd);
throw std::runtime_error("connect() failed");
}
return fd;
}
};
7. HTTP 서버 (라우팅)
간단한 HTTP 서버
#include <functional>
#include <unordered_map>
class HttpServer {
public:
using Handler = std::function<void(const HttpRequest&, HttpResponse&)>;
private:
int listen_fd_;
std::unordered_map<std::string, Handler> routes_;
public:
HttpServer(uint16_t port) {
listen_fd_ = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd_ < 0) {
throw std::runtime_error("socket() failed");
}
int opt = 1;
setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if (bind(listen_fd_, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
throw std::runtime_error("bind() failed");
}
if (listen(listen_fd_, 128) < 0) {
throw std::runtime_error("listen() failed");
}
}
~HttpServer() {
close(listen_fd_);
}
void route(const std::string& method_path, Handler handler) {
routes_[method_path] = handler;
}
void run() {
std::cout << "Server listening on port 8080\n";
while (true) {
int client_fd = accept(listen_fd_, nullptr, nullptr);
if (client_fd < 0) continue;
std::thread([this, client_fd]() {
handleClient(client_fd);
}).detach();
}
}
private:
void handleClient(int fd) {
try {
while (true) {
std::string buffer;
if (!HttpParser::readUntilHeaders(fd, buffer)) break;
HttpRequest req = HttpParser::parseRequest(buffer);
auto it = req.headers.find("content-length");
if (it != req.headers.end()) {
size_t content_length = std::stoull(it->second);
while (req.body.size() < content_length) {
char temp[4096];
size_t to_read = std::min(sizeof(temp), content_length - req.body.size());
ssize_t n = recv(fd, temp, to_read, 0);
if (n <= 0) break;
req.body.append(temp, n);
}
}
HttpResponse res;
res.version = "HTTP/1.1";
std::string key = req.method + " " + req.path;
auto route_it = routes_.find(key);
if (route_it != routes_.end())
route_it->second(req, res);
else {
res.status_code = 404;
res.status_message = "Not Found";
res.body = "404 Not Found";
}
sendResponse(fd, res);
auto conn_it = req.headers.find("connection");
if (conn_it != req.headers.end() && conn_it->second == "close") break;
}
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << "\n";
}
close(fd);
}
void sendResponse(int fd, const HttpResponse& res) {
std::ostringstream oss;
oss << res.version << " " << res.status_code << " " << res.status_message << "\r\n";
oss << "Content-Length: " << res.body.size() << "\r\n";
oss << "Connection: keep-alive\r\n";
oss << "\r\n";
oss << res.body;
std::string response = oss.str();
send(fd, response.c_str(), response.size(), 0);
}
};
// 사용 예시
int main() {
HttpServer server(8080);
server.route("GET /", {
res.status_code = 200;
res.status_message = "OK";
res.body = "Hello, World!";
});
server.route("GET /api/users", {
res.status_code = 200;
res.status_message = "OK";
res.body = R"([{"id":1,"name":"Alice"}])";
});
server.run();
}
8. 청크 인코딩 처리
청크 인코딩 형식
5\r\n
Hello\r\n
6\r\n
World\r\n
0\r\n
\r\n
구조:
- 청크 크기 (16진수) +
\r\n - 청크 데이터 +
\r\n - 반복
- 마지막 청크:
0\r\n\r\n
청크 파싱 예시
앞서 HttpParser::readChunkedBody 참조. Beast 사용 시 http::read가 자동으로 청크를 디코딩합니다.
9. 타임아웃 처리
SO_RCVTIMEO 사용
void setTimeout(int fd, int seconds) {
struct 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));
}
Beast 타임아웃
stream_.expires_after(std::chrono::seconds(30));
http::async_read(stream_, buffer_, req_, handler);
10. 자주 발생하는 에러
에러 요약 표
| 에러 | 원인 | 해결 |
|---|---|---|
| 불완전 응답 | recv가 한 번에 전체를 반환 안 함 | 버퍼에 누적, \r\n\r\n 확인 |
| 타임아웃 | 서버 응답 느림 | SO_RCVTIMEO 설정 |
| Content-Length 불일치 | 본문이 더 짧거나 김 | 정확히 N바이트만 읽기 |
| 청크 파싱 실패 | 크기 라인 잘못 파싱 | 16진수 변환 확인 |
| Connection reset | 서버가 연결 끊음 | 재시도 로직 |
| body limit exceeded | 요청 본문 초과 | Beast: parser.body_limit() |
| end_of_stream | 클라이언트 연결 끊음 | 정상 종료로 처리 |
| CRLF 인젝션 | 사용자 입력을 헤더에 직접 삽입 | \r\n 제거 |
문제 1: “Connection reset by peer”
원인: 클라이언트가 요청 도중 연결을 끊거나, keep-alive 타임아웃.
해결법:
http::async_read(stream_, buffer_, req_,
[self, this](beast::error_code ec, std::size_t) {
if (ec) {
if (ec == http::error::end_of_stream ||
ec == net::error::connection_reset) {
return; // 정상적인 끊김
}
std::cerr << "read error: " << ec.message() << "\n";
return;
}
handle_request();
});
문제 2: “body limit exceeded”
원인: 요청 본문이 body_limit을 초과함 (DoS 방지용).
해결법:
http::request_parser<http::string_body> parser;
parser.body_limit(10 * 1024 * 1024); // 10MB
http::async_read(stream_, buffer_, parser, ...);
문제 3: Keep-Alive에서 다음 요청 파싱 실패
원인: 한 연결에 여러 요청이 올 때, 이전 요청의 버퍼를 비우지 않음.
해결법:
void do_read() {
req_ = {};
buffer_.consume(buffer_.size());
http::async_read(stream_, buffer_, req_, ...);
}
문제 4: CRLF 인젝션 (헤더 주입)
원인: 사용자 입력을 헤더에 그대로 넣으면 \r\n으로 새 헤더 주입 가능. 해결: \r, \n 문자 제거 후 헤더에 삽입.
문제 5: std::getline과 CRLF
원인: HTTP는 \r\n(CRLF)를 줄 구분자로 사용. std::getline은 \n만 제거.
해결법:
std::getline(stream, line);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
11. 베스트 프랙티스
1. Beast 사용 권장
// ❌ 수동 파싱: 엣지 케이스 버그
std::string path = extract_path(raw_request);
// ✅ Beast: RFC 준수, 검증됨
http::request<http::string_body> req;
http::read(socket, buffer, req);
std::string path = std::string(req.target());
2. body_limit 설정
http::request_parser<http::string_body> parser;
parser.body_limit(1024 * 1024); // 1MB 제한
3. prepare_payload() 호출
res.body() = "Hello";
res.prepare_payload(); // Content-Length 자동 설정
4. Keep-Alive 처리
if (req.keep_alive()) {
res.keep_alive(true);
do_read(); // 다음 요청 대기
} else {
res.keep_alive(false);
stream_.socket().shutdown(tcp::socket::shutdown_send);
}
5. 에러 응답 일관성
JSON 형식 {"error":"메시지"}로 에러를 반환하고, escape_json으로 따옴표·개행 이스케이프합니다.
12. 프로덕션 패턴
패턴 1: Graceful Shutdown
std::atomic<bool> shutdown_requested로 SIGINT/SIGTERM 시 acceptor_.close() 호출. do_accept에서 shutdown_requested 확인 후 새 연결 수락 중단.
패턴 2: 헬스 체크 엔드포인트
/health 경로에서 {"status":"ok"} JSON을 반환합니다. Beast 서버 예제에 이미 포함되어 있습니다.
패턴 3: 요청 크기 제한
parser.header_limit(8*1024), parser.body_limit(10*1024*1024)로 헤더 8KB, 본문 10MB 제한을 둡니다.
패턴 4: REST API 클라이언트
BeastHttpClient를 래핑해 getUsers(), createUser() 등 도메인 API를 노출합니다. JSON 파싱은 nlohmann/json 등과 연동합니다.
프로덕션 체크리스트
- body_limit, header_limit 설정
- 타임아웃 설정 (stream_.expires_after)
- Graceful shutdown (SIGINT/SIGTERM)
- 헬스 체크 엔드포인트 (/health)
- CRLF 인젝션 방지
- Keep-Alive 처리
- 에러 로깅
13. 성능 비교
Keep-Alive vs 새 연결
테스트: 100회 GET 요청
| 방식 | 시간 | 연결 수 | 오버헤드 |
|---|---|---|---|
| 새 연결 | 2.8초 | 100개 | 3-way handshake × 100 |
| Keep-Alive | 0.5초 | 1개 | 재사용 |
결론: Keep-Alive는 약 5.6배 빠름
Beast vs 수동 파싱
| 항목 | 수동 파싱 | Beast |
|---|---|---|
| RFC 준수 | 부분 | 완전 |
| 청크 인코딩 | 직접 구현 | 자동 |
| 유지보수 | 높음 | 낮음 |
참고 자료
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]
- C++ 네트워크 에러 완벽 가이드 | errno·타임아웃·재시도·서킷브레이커 [#28-3]
- C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]
이 글에서 다루는 키워드 (관련 검색어)
C++ HTTP 클라이언트, HTTP 파싱, Boost.Beast, Keep-Alive, 청크 인코딩 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| 파서 | \r\n\r\n 찾기, 헤더 파싱, Content-Length 기반 본문 읽기 |
| Beast | RFC 준수, 청크 자동 처리, 권장 |
| Keep-Alive | 연결 재사용으로 5.6배 빠름 |
| 청크 인코딩 | 크기(hex)\r\n데이터\r\n 반복 파싱 |
| 타임아웃 | SO_RCVTIMEO 또는 stream_.expires_after |
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. REST API 클라이언트, 마이크로서비스 통신, 파일 다운로드, 웹 크롤러 등에 활용합니다. Beast나 libcurl을 쓰더라도 프로토콜 이해가 디버깅에 필수입니다.
Q. Beast나 libcurl을 쓰는 게 낫지 않나요?
A. 네, 프로덕션에서는 검증된 라이브러리를 사용하는 것이 좋습니다. Beast는 HTTP/1.1, HTTP/2, WebSocket을 표준에 맞게 구현합니다. 프로토콜을 이해하면 디버깅과 최적화에 큰 도움이 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. RFC 7230-7235, Boost.Beast 문서, libcurl 문서를 참고하세요.
한 줄 요약: HTTP 파서·Beast·Keep-Alive·청크 인코딩으로 완전한 HTTP 클라이언트/서버를 구현할 수 있습니다.
이전 글: C++ 실전 가이드 #28-1: TCP 소켓 기초
다음 글: [C++ 실전 가이드 #28-3] 네트워크 에러 처리: errno·타임아웃·재시도
관련 글
- C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴