C++로 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도 아직 못 읽음
직접 HTTP를 구현하면 비슷한 문제를 차례로 만납니다. recv가 메시지 전체를 한 번에 돌려주지 않아 파싱이 실패하고, 타임아웃이 없으면 느린 상대 앞에서 무한히 기다립니다. 요청마다 새 TCP 연결을 맺으면 핸드셰이크 비용이 반복되고, Content-Length 없이 Transfer-Encoding: chunked로 오는 응답은 별도 파싱이 필요합니다. 헤더 이름은 대소문자를 구분하지 않으므로 Content-Length와 content-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에서는 버퍼에 다음 요청의 일부가 이미 들어와 있을 수 있어, 버퍼를 통째로 비우면 그 데이터가 사라집니다. 서버가 Content-Length: 1000이라고 해 놓고 500바이트만 보낸 채 멈추면 타임아웃 없이는 영원히 기다리게 됩니다. 청크 크기 1a는 16진수(26바이트)이므로 10진수로 파싱하면 틀립니다. 연결마다 스레드를 만드는 서버는 동시 연결이 늘면 스레드 수가 폭증하므로 비동기 I/O나 스레드 풀이 필요합니다.
이 글에서는 버퍼 누적·\r\n\r\n 탐색·Content-Length 기반 본문 읽기를 하는 파서를 직접 만들고, Keep-Alive 연결 풀, 청크 파서, 타임아웃을 붙인 뒤, 같은 일을 Boost.Beast로 하는 방법을 봅니다. 예제는 POSIX 소켓과 Boost 1.70 이상의 Asio·Beast를 기준으로 합니다.
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;
};
불완전한 수신과 Keep-Alive: 직접 구현할 때 부딪히는 문제
시나리오 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
새 연결은 TCP 핸드셰이크에 RTT 하나, HTTPS라면 TLS 핸드셰이크에 RTT가 더 듭니다. 얼마나 차이가 나는지는 RTT에 따라 달라지며, 뒤의 비교 절에서 다시 정리합니다. 본문 길이를 미리 알면 Content-Length를 쓰고, 생성하면서 보내는 스트리밍 응답은 청크 인코딩을 씁니다.
HTTP 파서 구현
완전한 HTTP 파서 클래스
#include <string>
#include <sstream>
#include <algorithm>
#include <stdexcept>
#include <sys/socket.h>
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 기반 본문 읽기
// res.body에는 헤더와 함께 이미 받은 본문 앞부분이 들어 있음
static void readBody(int fd, HttpResponse& res) {
auto it = res.headers.find("content-length");
if (it == res.headers.end()) return;
size_t content_length = std::stoull(it->second);
// 다음 응답의 일부까지 함께 받았다면 잘라 냄 (실제로는 다음 파싱용으로 보관해야 함)
if (res.body.size() > content_length) res.body.resize(content_length);
// 더 읽어야 할 바이트
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);
}
}
// 청크 인코딩 본문 읽기
// buffer: 헤더 뒤에 이미 받아 둔 바이트 (버리면 첫 청크가 사라짐)
static void readChunkedBody(int fd, std::string buffer, std::string& body) {
body.clear();
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) {
// 마지막 청크. 뒤따르는 트레일러와 빈 줄(\r\n)은 이 예제에서 생략
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
}
}
};
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를 자동 디코딩합니다.
Beast HTTP 서버
완전한 Beast HTTP 서버 (비동기)
#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <chrono>
#include <functional>
#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_;
http::response<http::string_body> res_; // async_write가 끝날 때까지 살아 있어야 하므로 멤버로 둠
public:
explicit HttpSession(tcp::socket socket)
: stream_(std::move(socket)) {}
void start() { do_read(); }
private:
void do_read() {
req_ = {}; // 요청 객체만 초기화, buffer_는 비우지 않음
stream_.expires_after(std::chrono::seconds(30));
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() {
res_ = {};
auto& res = res_;
res.version(req_.version());
res.result(http::status::ok);
res.keep_alive(req_.keep_alive());
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) return;
if (res_.need_eof()) { // Connection: close
beast::error_code ignored;
stream_.socket().shutdown(tcp::socket::shutdown_send, ignored);
return;
}
do_read(); // Keep-Alive: 다음 요청
});
}
};
int main() {
net::io_context ioc;
tcp::acceptor acceptor(ioc, {tcp::v4(), 8080});
// 람다가 자기 자신을 다시 호출하므로 auto 대신 std::function으로 선언
std::function<void()> do_accept;
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();
}
소켓으로 직접 만드는 Keep-Alive 클라이언트
연결 풀 구현
#include <chrono>
#include <cstring>
#include <mutex>
#include <netdb.h>
#include <queue>
#include <unistd.h>
#include <unordered_map>
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;
if (!HttpParser::readUntilHeaders(fd, buffer))
throw std::runtime_error("connection closed"); // 풀에서 꺼낸 연결이 이미 끊긴 경우
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, std::move(res.body), res.body);
else
HttpParser::readBody(fd, res);
auto conn = res.headers.find("connection");
if (conn != res.headers.end() && conn->second == "close")
close(fd); // 서버가 닫겠다고 했으면 풀에 넣지 않음
else
returnConnection(fd, host, port);
return res;
} catch (...) {
close(fd);
throw;
}
}
private:
int connectNew(const std::string& host, uint16_t port) {
// host 이름을 DNS로 조회해 IPv4/IPv6 주소를 차례로 시도
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
addrinfo* res = nullptr;
std::string port_str = std::to_string(port);
if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0)
throw std::runtime_error("getaddrinfo() failed");
int fd = -1;
for (addrinfo* p = res; p; p = p->ai_next) {
fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
if (fd < 0) continue;
if (connect(fd, p->ai_addr, p->ai_addrlen) == 0) break;
close(fd);
fd = -1;
}
freeaddrinfo(res);
if (fd < 0) throw std::runtime_error("connect() failed");
return fd;
}
};
라우팅 테이블을 가진 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);
if (content_length > 10 * 1024 * 1024) break; // 본문 크기 제한 (DoS 방지)
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 /", [](const HttpRequest& req, HttpResponse& res) {
res.status_code = 200;
res.status_message = "OK";
res.body = "Hello, World!";
});
server.route("GET /api/users", [](const HttpRequest& req, HttpResponse& res) {
res.status_code = 200;
res.status_message = "OK";
res.body = R"([{"id":1,"name":"Alice"}])";
});
server.run();
}
청크 인코딩 처리
청크 인코딩 형식
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가 자동으로 청크를 디코딩합니다.
타임아웃 처리
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);
Connection reset, body limit, CRLF 인젝션: HTTP 구현 에러
에러 요약 표
| 에러 | 원인 | 해결 |
|---|---|---|
| 불완전 응답 | 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을 초과하면 발생합니다. Beast의 요청 파서는 기본 본문 제한이 1MB이고 헤더 제한은 8KB입니다. 더 큰 업로드를 받아야 한다면 파서를 직접 만들어 제한을 올립니다.
http::request_parser<http::string_body> parser;
parser.body_limit(10 * 1024 * 1024); // 10MB
http::async_read(stream_, buffer_, parser, ...);
문제 3: Keep-Alive에서 다음 요청 파싱 실패
흔히 “이전 요청의 버퍼를 비우지 않아서”라고 설명하며 buffer_.consume(buffer_.size())를 권하지만, 이는 오히려 버그를 만듭니다. http::async_read는 메시지를 읽고 나면 사용한 바이트만 버퍼에서 소비하므로, 버퍼에 남은 것은 다음 요청의 앞부분입니다. 이를 지우면 클라이언트가 이어 보낸 요청이 깨집니다. 매 요청마다 초기화할 것은 요청 객체뿐입니다.
void do_read() {
req_ = {}; // 요청 객체만 초기화, buffer_는 그대로 재사용
http::async_read(stream_, buffer_, req_, ...);
}
문제 4: CRLF 인젝션 (헤더 주입)
사용자 입력을 헤더 값에 그대로 넣으면 \r\n을 포함한 입력으로 새 헤더나 응답 본문을 끼워 넣을 수 있습니다. 헤더에 넣기 전에 \r, \n이 포함된 값은 거부하거나 제거합니다. Beast는 set()에 CR·LF가 든 값을 넘기면 예외를 던집니다.
문제 5: std::getline과 CRLF
HTTP는 \r\n(CRLF)을 줄 구분자로 쓰는데 std::getline은 \n만 제거하므로, 각 줄 끝에 남은 \r을 직접 지워야 합니다.
std::getline(stream, line);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
body_limit·prepare_payload·Keep-Alive 처리 원칙
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());
body_limit 설정
http::request_parser<http::string_body> parser;
parser.body_limit(1024 * 1024); // 1MB 제한
prepare_payload() 호출
res.body() = "Hello";
res.prepare_payload(); // Content-Length 자동 설정
Keep-Alive 처리
응답의 keep-alive 여부는 요청에 맞추고, 다음 읽기나 연결 종료는 응답 쓰기가 끝난 뒤(완료 핸들러 안)에서 결정합니다.
res.keep_alive(req.keep_alive());
// ... async_write 완료 핸들러 안에서
if (res.need_eof()) {
stream_.socket().shutdown(tcp::socket::shutdown_send, ec);
} else {
do_read(); // 다음 요청 대기
}
에러 응답 일관성
JSON 형식 {"error":"메시지"}로 에러를 반환하며, escape_json으로 따옴표·개행 이스케이프합니다.
graceful shutdown, 헬스 체크, 요청 크기 제한
패턴 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 등과 연동합니다.
Keep-Alive vs 새 연결, Beast vs 수동 파싱
Keep-Alive vs 새 연결
같은 서버에 GET을 100번 보낸다고 하면, 요청마다 새 연결을 맺는 쪽은 매번 TCP 3-way handshake에 왕복 시간(RTT) 1번이 추가로 들고, HTTPS라면 TLS 핸드셰이크로 1~2 RTT가 더 붙습니다. Keep-Alive로 연결 하나를 재사용하면 이 비용을 처음 한 번만 냅니다.
| 방식 | 연결 수 | 요청마다 추가되는 지연 |
|---|---|---|
| 새 연결 | 100개 | TCP 1 RTT (+ TLS 1~2 RTT) |
| Keep-Alive | 1개 | 없음 (첫 요청만 연결 비용) |
그래서 차이의 크기는 RTT에 비례합니다. 같은 머신(localhost)에서는 RTT가 매우 짧아 차이가 작게 보이고, 다른 리전의 서버처럼 RTT가 수십 ms인 환경에서는 요청 수만큼 그 시간이 쌓여 차이가 크게 벌어집니다. 새 연결을 계속 만들면 클라이언트 쪽에 TIME_WAIT 소켓이 쌓여 임시 포트가 고갈되는 문제도 생기므로, 같은 호스트로 반복 요청하는 클라이언트는 연결을 재사용하는 것이 기본입니다.
Beast vs 수동 파싱
| 항목 | 수동 파싱 | Beast |
|---|---|---|
| RFC 준수 | 직접 다룬 경우만 | 파서가 RFC 7230 규칙을 검사 |
| 청크 인코딩 | 직접 구현 | 자동 |
| 유지보수 부담 | 큼 | 작음 |
| HTTP/2 | 직접 구현 | 미지원(HTTP/1.1과 WebSocket만) |
참고 자료
같이 보면 좋은 글
이전 글: C++ 실전 가이드 #28-1: TCP 소켓 기초 다음 글: [C++ 실전 가이드 #28-3] 네트워크 에러 처리: errno·타임아웃·재시도