C++ WebSocket 구현: 핸드셰이크와 프레임 구조, Beast 클라이언트·서버, Ping/Pong 하트비트
들어가며: “실시간 양방향 통신이 필요해요”
문제 상황: HTTP 폴링의 한계
// ❌ 문제: HTTP 폴링은 비효율적
while (true) {
auto response = httpGet("/api/messages"); // 새 메시지 확인
if (response.has_new_messages()) {
process(response.messages);
}
std::this_thread::sleep_for(std::chrono::seconds(1)); // 1초마다 폴링
}
폴링은 메시지가 없어도 요청을 보내므로 대부분의 요청이 빈 응답으로 끝나고, 메시지는 최대 폴링 간격만큼 늦게 도착합니다. 서버가 받는 요청 수는 동시 접속자 수 × 폴링 빈도라서, 접속자가 N명이고 1초마다 폴링하면 메시지가 없어도 초당 N개의 HTTP 요청을 처리해야 합니다. 요청마다 HTTP 헤더가 오가므로 네트워크 트래픽도 그만큼 늘어납니다.
추가로 고려할 상황
연결 끊김 후 재연결 시 메시지 유실
모바일 앱이 백그라운드로 가면 TCP 연결이 끊깁니다. 재연결 시 “어디서부터 받을지”를 서버가 알려주지 않으면 중간 메시지가 사라집니다. 시퀀스 번호·오프셋 기반 재동기화가 필요합니다.
프록시·방화벽에서 연결 차단
일부 기업 방화벽은 WebSocket Upgrade를 차단합니다. HTTP 폴링 폴백이나 WSS(443 포트)를 사용해 우회할 수 있습니다.
WebSocket으로 해결:
// ✅ WebSocket: 서버가 즉시 푸시
ws.async_read(buffer, [&](beast::error_code ec, std::size_t) {
if (!ec) {
process(buffer); // 메시지 도착 즉시 처리
ws.async_read(buffer, ...); // 다음 메시지 대기
}
});
WebSocket은 연결 하나를 유지하면서 서버가 메시지가 생긴 즉시 보내므로, 빈 요청이 없고 지연은 네트워크 왕복 시간 수준으로 줄어듭니다. 예제의 요구 환경은 Boost.Beast 1.70 이상입니다.
WebSocket 프로토콜 구조
연결 과정
sequenceDiagram
participant C as 클라이언트
participant S as 서버
C->>S: HTTP GET /ws\nUpgrade: websocket\nSec-WebSocket-Key: xxx
S->>C: HTTP 101 Switching Protocols\nSec-WebSocket-Accept: yyy
Note over C,S: WebSocket 연결 수립
C->>S: WebSocket Frame (Text)
S->>C: WebSocket Frame (Text)
C->>S: Ping
S->>C: Pong
C->>S: Close
S->>C: Close
WebSocket 연결 상태
stateDiagram-v2
[*] --> Connecting: TCP 연결
Connecting --> Open: 101 Switching Protocols
Connecting --> [*]: 400/403 등
Open --> Closing: Close 프레임 수신
Open --> [*]: 예기치 않은 끊김
Closing --> [*]: Close 완료
HTTP vs WebSocket
| 특성 | HTTP | WebSocket |
|---|---|---|
| 연결 | Keep-Alive로 재사용하지만 요청·응답 단위 | 한 번 연결해 계속 유지 |
| 방향 | 클라이언트 요청 → 서버 응답 | 양방향, 서버가 먼저 보낼 수 있음 |
| 오버헤드 | 요청마다 HTTP 헤더(보통 수백 바이트) | 프레임 헤더 2~14바이트 |
| 실시간성 | 폴링 필요 (지연) | 즉시 푸시 |
| 서버 부하 | 높음 (폴링) | 낮음 (이벤트 기반) |
핸드셰이크 과정
클라이언트 요청
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
핵심 헤더:
Upgrade: websocket: WebSocket으로 업그레이드 요청Connection: Upgrade: 연결 업그레이드Sec-WebSocket-Key: 랜덤 16바이트 Base64 인코딩Sec-WebSocket-Version: 13: WebSocket 버전
서버 응답
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
핸드셰이크 raw 바이트 예시 (클라이언트가 전송하는 실제 HTTP 요청):
47 45 54 20 2f 63 68 61 74 20 48 54 54 50 2f 31 GET /chat HTTP/1
2e 31 0d 0a 48 6f 73 74 3a 20 65 78 61 6d 70 6c .1..Host: exampl
65 2e 63 6f 6d 0d 0a 55 70 67 72 61 64 65 3a 20 e.com..Upgrade:
77 65 62 73 6f 63 6b 65 74 0d 0a 43 6f 6e 6e 65 websocket..Conne
63 74 69 6f 6e 3a 20 55 70 67 72 61 64 65 0d 0a ction: Upgrade..
Sec-WebSocket-Accept 계산:
#include <openssl/sha.h>
#include <boost/beast/core/detail/base64.hpp>
std::string computeAccept(const std::string& key) {
// RFC 6455: key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
std::string magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
std::string input = key + magic;
// SHA-1 해시
unsigned char hash[SHA_DIGEST_LENGTH];
SHA1(reinterpret_cast<const unsigned char*>(input.c_str()),
input.size(), hash);
// Base64 인코딩
std::string result;
result.resize(boost::beast::detail::base64::encoded_size(SHA_DIGEST_LENGTH));
result.resize(boost::beast::detail::base64::encode(
&result[0], hash, SHA_DIGEST_LENGTH));
return result;
}
핸드셰이크 플로우차트
flowchart TB
Start[TCP 연결] --> ClientReq[클라이언트: HTTP Upgrade 요청]
ClientReq --> ServerCheck{서버: 유효한 요청?}
ServerCheck -->|Yes| ServerResp[서버: 101 Switching Protocols]
ServerCheck -->|No| ServerErr[서버: 400 Bad Request]
ServerResp --> WSOpen[WebSocket 연결 수립]
ServerErr --> Close[연결 종료]
WSOpen --> DataExchange[데이터 교환]
style WSOpen fill:#4caf50
style ServerErr fill:#f44336
프레임 구조
프레임 포맷
graph LR
A["FIN 1bit"] --> B["RSV 3bits"]
B --> C["Opcode 4bits"]
C --> D["Mask 1bit"]
D --> E["Payload Len 7bits"]
E --> F["Extended Payload Len 0/2/8bytes"]
F --> G["Masking Key 0/4bytes"]
G --> H[Payload Data]
style A fill:#ff9800
style C fill:#4caf50
style D fill:#2196f3
Opcode 종류
| Opcode | 값 | 의미 |
|---|---|---|
| Continuation | 0x0 | 이전 프레임 계속 |
| Text | 0x1 | UTF-8 텍스트 |
| Binary | 0x2 | 바이너리 데이터 |
| Close | 0x8 | 연결 종료 |
| Ping | 0x9 | Ping (heartbeat) |
| Pong | 0xA | Pong (응답) |
Close 프레임 (정상 종료)
// Beast: 정상 종료
ws_.close(websocket::close_code::normal);
// Close 코드와 이유 전달
ws_.close(websocket::close_code::normal, "서버 종료 예정");
// Close 수신 시 (do_read 콜백 내)
if (ec == websocket::error::closed) {
auto reason = ws_.reason();
// reason.code (1000=normal, 1001=going_away 등)
// reason.reason (종료 사유 문자열)
}
마스킹
클라이언트에서 서버로 가는 프레임은 반드시 마스킹해야 하고, 서버에서 클라이언트로 가는 프레임은 마스킹하지 않습니다.
// 마스킹 알고리즘
void mask_payload(uint8_t* data, size_t len, const uint8_t mask_key[4]) {
for (size_t i = 0; i < len; ++i) {
data[i] ^= mask_key[i % 4];
}
}
마스킹 키는 프레임마다 무작위로 정해지므로, 브라우저 안의 스크립트가 보내는 바이트를 공격자가 예측할 수 없습니다. 그래서 중간 프록시가 WebSocket 페이로드를 HTTP 요청으로 오인해 캐시를 오염시키는 공격(cache poisoning)을 막을 수 있습니다.
프레임 파싱 예시 (Text “Hi” 전송)
클라이언트가 “Hi” (2바이트)를 Text 프레임으로 보낼 때의 raw 바이트:
바이트 0: 0x81 (FIN=1, RSV=0, Opcode=0x1 Text)
바이트 1: 0x82 (MASK=1, Payload Len=2)
바이트 2-5: 마스킹 키 4바이트 (예: 0x37 0xfa 0x21 0x3d)
바이트 6-7: "Hi"(0x48 0x69) XOR 마스킹 키 앞 2바이트(0x37 0xfa) → 0x7f 0x93
Beast로 프레임 전송 (마스킹 자동 적용):
// Beast는 클라이언트 역할일 때 자동으로 마스킹 적용
ws_.text(true); // Text 프레임
// net::buffer("Hi")는 문자열 리터럴 배열의 '\0'까지 3바이트를 보내므로 string_view로 감싼다
static constexpr std::string_view msg = "Hi";
ws_.async_write(net::buffer(msg),
[](beast::error_code ec, std::size_t) {
if (!ec) {
// 클라이언트: 헤더 2바이트 + 마스킹 키 4바이트 + 페이로드 2바이트 = 8바이트 전송
}
});
Beast WebSocket 클라이언트
기본 클라이언트
#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <iostream>
namespace beast = boost::beast;
namespace websocket = beast::websocket;
namespace net = boost::asio;
using tcp = net::ip::tcp;
class WebSocketClient {
net::io_context& ioc_;
websocket::stream<beast::tcp_stream> ws_;
beast::flat_buffer buffer_;
public:
explicit WebSocketClient(net::io_context& ioc)
: ioc_(ioc), ws_(net::make_strand(ioc)) {}
void connect(const std::string& host, const std::string& port) {
// DNS 조회
tcp::resolver resolver(ioc_);
auto results = resolver.resolve(host, port);
// TCP 연결
net::connect(ws_.next_layer(), results);
// WebSocket 핸드셰이크
ws_.handshake(host, "/");
std::cout << "WebSocket connected to " << host << "\n";
}
void send(const std::string& message) {
ws_.write(net::buffer(message));
}
std::string receive() {
buffer_.clear();
ws_.read(buffer_);
return beast::buffers_to_string(buffer_.data());
}
void close() {
ws_.close(websocket::close_code::normal);
}
};
// 사용 예시
int main() {
net::io_context ioc;
WebSocketClient client(ioc);
client.connect("localhost", "8080"); // 아래 서버 예제를 띄워 두고 테스트
client.send("Hello, WebSocket!");
std::string response = client.receive();
std::cout << "Received: " << response << "\n";
client.close();
}
비동기 클라이언트
class AsyncWebSocketClient : public std::enable_shared_from_this<AsyncWebSocketClient> {
websocket::stream<beast::tcp_stream> ws_;
tcp::resolver resolver_; // 지역 변수로 두면 connect() 반환 시 소멸해 조회가 취소됨
beast::flat_buffer buffer_;
std::deque<std::string> write_queue_; // 보낼 메시지 보관 (버퍼 수명 + 쓰기 직렬화)
public:
explicit AsyncWebSocketClient(net::io_context& ioc)
: ws_(net::make_strand(ioc)), resolver_(ws_.get_executor()) {}
// shared_ptr로 생성한 뒤에만 호출 (shared_from_this 사용)
void connect(const std::string& host, const std::string& port) {
resolver_.async_resolve(host, port,
[self = shared_from_this(), host](
beast::error_code ec,
tcp::resolver::results_type results) {
if (ec) {
std::cerr << "Resolve error: " << ec.message() << "\n";
return;
}
// TCP 연결
beast::get_lowest_layer(self->ws_).async_connect(results,
[self, host](beast::error_code ec, tcp::endpoint) {
if (ec) {
std::cerr << "Connect error: " << ec.message() << "\n";
return;
}
// WebSocket 핸드셰이크
self->ws_.async_handshake(host, "/",
[self](beast::error_code ec) {
if (ec) {
std::cerr << "Handshake error: " << ec.message() << "\n";
return;
}
std::cout << "WebSocket connected\n";
self->do_read();
});
});
});
}
void send(std::string message) {
// 어느 스레드에서 불러도 strand 위에서 큐를 다루도록 post
net::post(ws_.get_executor(),
[self = shared_from_this(), msg = std::move(message)]() mutable {
self->write_queue_.push_back(std::move(msg));
if (self->write_queue_.size() == 1) self->do_write(); // 진행 중인 쓰기가 없을 때만 시작
});
}
private:
void do_write() {
ws_.async_write(net::buffer(write_queue_.front()),
[self = shared_from_this()](beast::error_code ec, std::size_t) {
if (ec) {
std::cerr << "Write error: " << ec.message() << "\n";
return;
}
self->write_queue_.pop_front();
if (!self->write_queue_.empty()) self->do_write();
});
}
void do_read() {
auto self = shared_from_this();
ws_.async_read(buffer_,
[self](beast::error_code ec, std::size_t bytes) {
if (ec) {
if (ec != websocket::error::closed) {
std::cerr << "Read error: " << ec.message() << "\n";
}
return;
}
std::cout << "Received: "
<< beast::buffers_to_string(self->buffer_.data()) << "\n";
self->buffer_.clear();
self->do_read(); // 다음 메시지 대기
});
}
};
async_write에 넘긴 버퍼는 쓰기가 끝날 때까지 살아 있어야 하고, 한 스트림에서 동시에 진행되는 쓰기는 하나여야 합니다. 그래서 메시지를 큐에 보관하고 앞의 쓰기가 끝난 뒤 다음 쓰기를 시작합니다. 읽기 하나와 쓰기 하나는 같은 strand 위라면 동시에 진행해도 됩니다.
Beast WebSocket 서버
완전한 WebSocket 서버
class WebSocketSession : public std::enable_shared_from_this<WebSocketSession> {
websocket::stream<beast::tcp_stream> ws_;
beast::flat_buffer buffer_;
public:
explicit WebSocketSession(tcp::socket socket)
: ws_(std::move(socket)) {}
void run() {
// WebSocket 설정
ws_.set_option(websocket::stream_base::timeout::suggested(
beast::role_type::server));
ws_.set_option(websocket::stream_base::decorator(
[](websocket::response_type& res) {
res.set(beast::http::field::server, "Beast WebSocket Server");
}));
// 핸드셰이크 수락
ws_.async_accept(
[self = shared_from_this()](beast::error_code ec) {
if (ec) {
std::cerr << "Accept error: " << ec.message() << "\n";
return;
}
self->do_read();
});
}
private:
void do_read() {
auto self = shared_from_this();
ws_.async_read(buffer_,
[self](beast::error_code ec, std::size_t) {
if (ec) {
if (ec == websocket::error::closed) {
std::cout << "Connection closed\n";
} else {
std::cerr << "Read error: " << ec.message() << "\n";
}
return;
}
// Echo: 받은 메시지를 그대로 전송
self->ws_.text(self->ws_.got_text());
self->ws_.async_write(self->buffer_.data(),
[self](beast::error_code ec, std::size_t) {
if (ec) {
std::cerr << "Write error: " << ec.message() << "\n";
return;
}
self->buffer_.clear();
self->do_read();
});
});
}
};
class WebSocketServer {
net::io_context& ioc_;
tcp::acceptor acceptor_;
public:
WebSocketServer(net::io_context& ioc, uint16_t port)
: ioc_(ioc),
acceptor_(ioc, tcp::endpoint(tcp::v4(), port)) {}
void run() {
do_accept();
}
private:
void do_accept() {
acceptor_.async_accept(
net::make_strand(ioc_),
[this](beast::error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<WebSocketSession>(std::move(socket))->run();
}
do_accept();
});
}
};
int main() {
net::io_context ioc{1};
WebSocketServer server(ioc, 8080);
server.run();
std::cout << "WebSocket server listening on port 8080\n";
ioc.run();
}
Ping/Pong Heartbeat
Ping/Pong 시퀀스
sequenceDiagram
participant C as 클라이언트
participant S as 서버
Note over C: 30초마다 Ping 전송
C->>S: Ping
S->>C: Pong
Note over C: 연결 유지 확인
C->>S: Ping
Note over S: 응답 없음 (연결 끊김)
Note over C: 타임아웃 → 재연결
Ping 타이머 구현
class WebSocketClientWithPing : public std::enable_shared_from_this<WebSocketClientWithPing> {
websocket::stream<beast::tcp_stream> ws_;
beast::flat_buffer buffer_;
net::steady_timer ping_timer_;
public:
explicit WebSocketClientWithPing(net::io_context& ioc)
: ws_(net::make_strand(ioc)),
ping_timer_(ws_.get_executor()) {}
void start_ping() {
ping_timer_.expires_after(std::chrono::seconds(30));
ping_timer_.async_wait(
[self = shared_from_this()](beast::error_code ec) {
if (ec) return;
// Ping 전송
self->ws_.async_ping({},
[self](beast::error_code ec) {
if (ec) {
std::cerr << "Ping error: " << ec.message() << "\n";
return;
}
self->start_ping(); // 다음 Ping 예약
});
});
}
};
Pong 자동 응답
Beast는 자동으로 Pong을 전송합니다. 수동으로 처리하려면 다음과 같이 합니다.
ws_.control_callback(
[](websocket::frame_type kind, beast::string_view) {
if (kind == websocket::frame_type::ping) {
std::cout << "Received Ping\n";
// Beast가 자동으로 Pong 전송
} else if (kind == websocket::frame_type::pong) {
std::cout << "Received Pong\n";
}
});
완전한 Ping/Pong + Pong 타임아웃 (Beast)
Pong을 받지 못하면 연결을 닫고 재연결하는 예시입니다. 서버라면 직접 만들 필요 없이 timeout::suggested(role_type::server)가 켜 주는 keep-alive Ping으로 같은 효과를 얻을 수 있습니다.
class WebSocketWithHeartbeat : public std::enable_shared_from_this<WebSocketWithHeartbeat> {
websocket::stream<beast::tcp_stream> ws_;
beast::flat_buffer buffer_;
net::steady_timer ping_timer_;
net::steady_timer pong_timer_;
bool pong_received_ = true;
public:
void start_heartbeat() {
pong_received_ = true;
schedule_ping();
}
private:
void schedule_ping() {
ping_timer_.expires_after(std::chrono::seconds(30));
ping_timer_.async_wait(
[self = shared_from_this()](beast::error_code ec) {
if (ec) return;
if (!self->pong_received_) {
std::cerr << "Pong timeout - reconnecting\n";
self->reconnect();
return;
}
self->pong_received_ = false;
self->ws_.async_ping({},
[self](beast::error_code ec) {
if (ec) return;
self->schedule_pong_timeout();
self->schedule_ping();
});
});
}
void schedule_pong_timeout() {
pong_timer_.expires_after(std::chrono::seconds(10));
pong_timer_.async_wait(
[self = shared_from_this()](beast::error_code ec) {
if (ec) return;
if (!self->pong_received_) {
// 핸들러 안에서는 동기 close로 블로킹하지 말고 async_close 사용
self->ws_.async_close(websocket::close_code::going_away,
[self](beast::error_code) { self->reconnect(); });
}
});
}
void setup_control_callback() {
// 콜백은 ws_가 보관하므로 shared_ptr(self)를 캡처하면
// ws_ → 콜백 → self → ws_ 순환이 생겨 객체가 해제되지 않는다
ws_.control_callback(
[this](websocket::frame_type kind, beast::string_view) {
if (kind == websocket::frame_type::pong) {
pong_received_ = true;
}
});
}
void reconnect() { /* 구현 생략 */ }
};
핸드셰이크 실패, Safari WSS 끊김, 동시 read/write: WebSocket 에러
핸드셰이크 실패
원인: 서버가 업그레이드를 거절했거나(경로·인증·Origin 검사 실패 등), 응답 헤더가 RFC 6455와 맞지 않는 경우입니다.
websocket::response_type res;
ws_.async_handshake(res, host, "/",
[&res](beast::error_code ec) {
if (ec == websocket::error::upgrade_declined) {
// 서버가 101이 아닌 응답을 보냄: 상태 코드로 원인 확인
std::cerr << "Upgrade declined: " << res.result_int() << "\n";
} else if (ec == websocket::error::bad_sec_accept) {
std::cerr << "Invalid Sec-WebSocket-Accept\n";
} else if (ec) {
std::cerr << "Handshake error: " << ec.message() << "\n";
}
});
실제 코드에서는 res를 지역 변수가 아니라 멤버로 두어 핸들러가 호출될 때까지 살아 있게 해야 합니다.
프레임 파싱 오류
원인: 마스킹 누락, 잘못된 opcode
| 에러 | 원인 | 해결 |
|---|---|---|
| Mask required | 클라이언트가 마스킹 안 함 | Beast가 자동 처리 |
| Invalid opcode | 잘못된 opcode | 프레임 검증 |
| Payload too large | 메시지 크기 초과 | max_size 설정 |
멀티 스레드에서 간헐적 연결 끊김 (strand 사용)
io_context::run()을 여러 스레드에서 돌리면, 한 연결의 핸들러들이 서로 다른 스레드에서 동시에 실행될 수 있습니다. 이때 두 스레드가 같은 스트림에 쓰기를 시작하면 프레임 바이트가 섞여 상대가 프로토콜 오류로 연결을 끊습니다. 브라우저 종류와 무관하게 생기는 문제이며, 해결책은 스트림 자체를 strand 위에서 만드는 것입니다.
// 스트림의 executor가 strand이면 모든 완료 핸들러가 이 strand에서 실행된다
websocket::stream<beast::tcp_stream> ws_{net::make_strand(ioc)};
// 다른 스레드에서 스트림을 건드릴 때는 net::post(ws_.get_executor(), ...)로 넘긴다
bind_executor로 핸들러마다 별도의 strand를 붙이는 방식은, 스트림 내부의 중간 작업이 다른 executor에서 실행될 수 있어 실수하기 쉽습니다.
Connection timeout
원인: 방화벽·프록시 차단, 잘못된 호스트/포트, 네트워크 불안정
// ❌ 타임아웃 없이 connect → 영원히 대기
beast::get_lowest_layer(ws_).async_connect(results, ...);
// ✅ 타임아웃 설정
beast::get_lowest_layer(ws_).expires_after(std::chrono::seconds(10));
beast::get_lowest_layer(ws_).async_connect(results,
[](beast::error_code ec, tcp::endpoint) {
if (ec == net::error::operation_aborted) {
std::cerr << "Connection timeout\n";
}
});
400 Bad Request / 403 Forbidden
원인: Origin 헤더 불일치, 서브프로토콜 미지원, 인증 실패
Origin은 클라이언트 요청 헤더이므로, 응답을 꾸미는 decorator에서는 확인할 수 없습니다. 서버는 업그레이드 요청을 HTTP로 먼저 읽고, 검사를 통과한 경우에만 그 요청으로 async_accept를 호출합니다.
// beast::http::request<beast::http::string_body> req_; (세션 멤버)
beast::http::async_read(ws_.next_layer(), buffer_, req_,
[self = shared_from_this()](beast::error_code ec, std::size_t) {
if (ec) return;
if (!websocket::is_upgrade(self->req_) ||
self->req_[beast::http::field::origin] != "https://myapp.com") {
// 403 응답을 보내고 종료 (응답 작성 생략)
return;
}
self->ws_.async_accept(self->req_,
[self](beast::error_code ec) { if (!ec) self->do_read(); });
});
Payload too large (메모리 고갈)
원인: 악의적 클라이언트가 수 GB 프레임 전송 시도
// Beast: max_message_size 설정 (기본 16MB)
ws_.read_message_max(1024 * 1024); // 1MB 제한
// 초과 시 websocket::error::message_too_big
ws_.async_read(buffer_, [self = shared_from_this()](beast::error_code ec, std::size_t) {
if (ec == websocket::error::message_too_big) {
// 이 경우 Beast가 1009(too_big) 코드로 Close를 이미 보내므로 별도 close는 필요 없다
return;
}
});
동시 쓰기와 버퍼 공유 (데이터 레이스)
Beast는 한 스트림에서 읽기 하나와 쓰기 하나가 동시에 진행되는 것은 허용하지만, 쓰기 두 개나 읽기 두 개를 동시에 시작하는 것은 허용하지 않습니다. 또 쓰기에 넘긴 버퍼를 같은 시점에 읽기가 채우면 보낼 데이터가 바뀝니다.
// ❌ 잘못된 패턴: 읽기 버퍼를 그대로 쓰기에 넘기고 곧바로 다음 읽기를 시작
void do_read() {
ws_.async_read(buffer_, [this](beast::error_code, std::size_t) {
ws_.async_write(buffer_.data(), [](auto, auto) {});
do_read(); // 쓰기가 끝나기 전에 같은 buffer_를 다시 채움
});
}
// ✅ 앞의 서버 예제처럼 쓰기 완료 핸들러에서 버퍼를 비운 뒤 다음 읽기를 시작하거나,
// 보낼 데이터를 별도 큐로 복사하고 쓰기는 큐로 직렬화한다
세션이 해제되지 않음 (shared_ptr 수명 연장)
원인: 반복 예약되는 타이머 핸들러가 shared_from_this()를 캡처하면, 연결이 끊긴 뒤에도 타이머가 취소되기 전까지 세션이 살아 있습니다.
// ❌ 타이머가 self를 잡고 계속 재예약되어 세션 해제 안 됨
ping_timer_.async_wait([self = shared_from_this()](...) {
self->ws_.async_ping(...); // ws_가 이미 닫혀도 호출
});
// ✅ weak_ptr로 순환 끊기
auto weak = std::weak_ptr<Session>(shared_from_this());
ping_timer_.async_wait([weak](beast::error_code ec) {
auto self = weak.lock();
if (!self || ec) return;
// ...
});
strand 직렬화, 메시지 크기 제한, 지수 백오프 재연결
Strand 사용
모든 WebSocket 작업을 단일 strand에서 실행하세요. 동시 read/write로 인한 크래시를 방지합니다.
// websocket::stream 생성 시 strand 전달
websocket::stream<beast::tcp_stream> ws_{net::make_strand(ioc)};
타임아웃 설정
ws_.set_option(websocket::stream_base::timeout::suggested(beast::role_type::server));
// 서버: handshake 30초, idle 300초, keep-alive Ping 켜짐
// 클라이언트: handshake 30초, idle 제한 없음, keep-alive Ping 꺼짐
keep_alive_pings가 켜져 있으면 idle 시간의 절반 동안 아무 데이터도 오지 않을 때 Beast가 Ping을 보내고, 나머지 절반 안에도 응답이 없으면 연결을 닫습니다.
메시지 크기 제한
ws_.read_message_max(1024 * 1024); // 1MB
재연결 로직 (지수 백오프)
// base_delay_는 std::chrono::milliseconds, 연결 성공 시 retry_count_ = 0으로 초기화
void reconnect_with_backoff() {
auto delay = std::min<std::chrono::milliseconds>(
base_delay_ * (1 << retry_count_),
std::chrono::seconds(60)
);
retry_timer_.expires_after(delay);
retry_timer_.async_wait([this](beast::error_code ec) {
if (!ec) {
connect();
retry_count_ = std::min(retry_count_ + 1, 10);
}
});
}
에러 로깅 (구조화)
struct WsError {
beast::error_code ec;
std::string context;
std::chrono::system_clock::time_point when;
};
// JSON으로 로그 전송 → 모니터링 대시보드
WebSocket과 HTTP 폴링의 비용 비교
동시 접속자 N명, 폴링 간격 T초라면 폴링 서버는 메시지가 없어도 분당 N × 60/T개의 요청을 처리합니다. 예를 들어 1만 명이 1초마다 폴링하면 분당 60만 건이고, 각 요청에 HTTP 헤더가 수백 바이트씩 붙습니다. 롱폴링은 메시지가 생길 때까지 응답을 미뤄 요청 수를 줄이지만, 응답마다 다시 요청을 보내야 하고 연결을 붙잡고 있어야 합니다. WebSocket은 메시지가 있을 때만 수 바이트 헤더의 프레임을 보내므로, 요청 수와 헤더 오버헤드가 모두 실제 메시지 수에 비례합니다.
대신 WebSocket 서버는 연결 수만큼 소켓과 세션 상태(버퍼, 타이머)를 유지해야 하므로, 메모리 사용량은 동시 접속자 수에 비례해 늘어납니다. 연결당 메모리는 버퍼 크기와 세션 구조에 따라 크게 달라지므로, 목표 접속자 수로 부하 테스트를 해서 확인해야 합니다.
채팅 서버, 실시간 대시보드, Redis Pub/Sub 수평 확장
채팅 서버
class ChatServer {
std::set<std::shared_ptr<WebSocketSession>> sessions_;
std::mutex mutex_;
public:
void join(std::shared_ptr<WebSocketSession> session) {
std::lock_guard<std::mutex> lock(mutex_);
sessions_.insert(session);
}
void leave(std::shared_ptr<WebSocketSession> session) {
std::lock_guard<std::mutex> lock(mutex_);
sessions_.erase(session);
}
void broadcast(const std::string& message) {
std::lock_guard<std::mutex> lock(mutex_);
for (auto& session : sessions_) {
session->send(message);
}
}
};
실시간 대시보드
class DashboardServer {
// 토픽 하나에 여러 구독자: map<토픽, 세션>이면 새 구독자가 기존 구독자를 덮어쓴다
std::multimap<std::string, std::shared_ptr<WebSocketSession>> subscribers_;
public:
void subscribe(const std::string& topic, std::shared_ptr<WebSocketSession> session) {
subscribers_.emplace(topic, std::move(session));
}
void publish(const std::string& topic, const nlohmann::json& data) {
auto payload = data.dump();
auto [first, last] = subscribers_.equal_range(topic);
for (auto it = first; it != last; ++it) {
it->second->send(payload);
}
}
// 메트릭 푸시 (1초마다)
void pushMetrics() {
nlohmann::json metrics = {
{"cpu", getCpuUsage()},
{"memory", getMemoryUsage()},
{"requests", getRequestCount()}
};
publish("metrics", metrics);
}
};
로드 밸런서와 프록시 설정
WebSocket 연결 하나는 하나의 TCP 연결이므로, 일단 연결되면 끊길 때까지 같은 백엔드에 붙어 있습니다. sticky session이 필요한 경우는 재연결할 때 이전 서버의 메모리 상태를 이어 받아야 하거나, Socket.IO처럼 HTTP 폴링 폴백을 함께 쓰는 경우입니다. 상태를 Redis 같은 외부 저장소에 두면 sticky 없이도 어느 서버로든 재연결할 수 있습니다. 프록시에서는 Upgrade 헤더 전달과 긴 읽기 타임아웃 설정이 필수입니다.
# Nginx 예시
upstream websocket_backend {
ip_hash; # (선택) 재연결 시 같은 서버로 보내야 할 때만
server 10.0.1.1:8080;
server 10.0.1.2:8080;
}
location /ws {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_read_timeout 3600s; # 기본 60초: Ping 간격보다 길어야 유휴 연결이 끊기지 않음
proxy_send_timeout 3600s;
}
수평 확장 (Redis Pub/Sub)
여러 서버 인스턴스 간 메시지 브로드캐스트:
// 서버 A에서 메시지 수신 → Redis publish
redis.publish("chat:room1", message);
// 서버 B, C는 Redis subscribe → 해당 방 구독자에게만 전송
redis.subscribe("chat:room1", [this](const std::string& msg) {
for (auto& session : room1_sessions_) {
session->send(msg);
}
});
Health Check (Ping 기반)
// Kubernetes/ECS에서 사용할 liveness probe
// WebSocket 서버가 Ping에 Pong 응답하는지 확인
// /health 엔드포인트: HTTP 200 + "ok" 반환 (WebSocket 아님)
참고 자료
같이 보면 좋은 글
- C++에서 HTTP 제대로 파싱하기
- C++ 멀티스레드 네트워크 서버
- C++ TLS 통신
- Boost.Asio 채팅 서버 만들기
- C++ WebSocket 심화 가이드 | 핸드셰이크·프레임·Ping/Pong·에러·프로덕션 패턴
자주 묻는 질문 (FAQ)
Q. WebSocket과 SSE(Server-Sent Events) 중 무엇을 써야 하나요?
A. 서버에서 클라이언트로만 데이터를 밀어 주면 되는 알림·대시보드라면 SSE가 일반 HTTP 위에서 동작하고 재연결도 브라우저가 처리해 주므로 더 단순합니다. 클라이언트도 자주 메시지를 보내야 하는 채팅·게임·협업 편집처럼 양방향이 필요하거나 바이너리 프레임이 필요하면 WebSocket이 맞습니다.
다음 글: [C++ 실전 가이드 #30-2] SSL/TLS 보안 통신: OpenSSL과 Asio 연동 이전 글: [C++ 실전 가이드 #29-3] 멀티스레드 네트워크 서버: io_context 풀과 strand