C++ WebSocket 심화 | Boost.Beast·Ping/Pong·재연결·운영

들어가며: “WebSocket 연결이 자꾸 끊겨요”

문제 시나리오 1: 연결이 일정 시간마다 끊김

// ❌ 문제: NAT/방화벽이 유휴 연결을 끊음
// 일정 시간 메시지가 오가지 않으면
// 중간 라우터/방화벽/로드 밸런서가 TCP 연결을 정리
ws_.async_read(buffer_, [](beast::error_code, std::size_t) {
    // ec == connection_reset 또는 connection_aborted
});

NAT 테이블과 방화벽, 로드 밸런서는 유휴(idle) TCP 연결을 일정 시간 후 정리합니다. WebSocket은 한 번 연결하면 오랫동안 데이터를 주고받지 않을 수 있어, 중간 장비가 “사용하지 않는 연결”로 판단해 끊어버립니다. 그 유휴 타임아웃보다 짧은 간격으로 Ping/Pong을 주고받아 연결에 트래픽이 흐르게 하면 됩니다. 타임아웃 값은 장비마다 다르므로(로드 밸런서는 60초 기본값이 흔함) 경로에 있는 장비의 설정을 확인합니다.

문제 시나리오 2: 핸드셰이크 400 Bad Request

// ❌ 문제: 서버가 400 Bad Request 반환
ws_.async_handshake(host, "/chat",
    [](beast::error_code ec) {
        // ec == bad_request
        // 서버 로그: "Missing Sec-WebSocket-Key"
    });

Sec-WebSocket-Key 누락, 잘못된 Upgrade 헤더, 버전 불일치처럼 RFC 6455를 따르지 않는 요청은 핸드셰이크가 실패합니다. Beast 클라이언트는 이런 헤더를 자동으로 만들므로, Beast를 쓰는데도 실패한다면 중간 프록시가 헤더를 바꾸거나 빠뜨리는지 먼저 의심합니다.

문제 시나리오 3: 대용량 메시지로 메모리 폭발

// ❌ 문제: 100MB 메시지 수신 시 OOM
ws_.async_read(buffer_, [](beast::error_code, std::size_t bytes) {
    // bytes == 100 * 1024 * 1024
    // buffer가 100MB 할당 → 서버 크래시
});

read_message_max로 최대 메시지 크기를 제한합니다. Beast 기본값은 16MB이므로 서비스가 실제로 주고받는 크기에 맞춰 낮춥니다.

추가 문제 시나리오

io_context를 여러 스레드에서 돌리면서 한 WebSocket 스트림의 작업이 서로 다른 스레드에서 동시에 실행되면 데이터 레이스가 생기고, 프레임이 섞여 연결이 불규칙하게 끊깁니다. 브라우저 문제처럼 보이지만 서버 쪽 동시성 버그이며, 스트림의 작업을 strand로 직렬화하면 해결됩니다. 연결이 실패하자마자 재연결을 반복하면 서버가 복구되는 순간 부하가 몰리므로 지수 백오프가 필요하고, 수천 개 세션에 한꺼번에 쓰기를 걸면 메모리와 큐가 폭주하므로 세션별 쓰기 큐로 백프레셔를 적용합니다.

예제는 Boost.Beast 1.70 이상과 C++17을 기준으로 합니다.


Upgrade 요청과 Sec-WebSocket-Accept: 핸드셰이크 분석

HTTP 업그레이드 요청 (클라이언트 → 서버)

WebSocket 연결은 HTTP Upgrade 요청으로 시작합니다. RFC 6455를 따르는 정확한 요청 예시입니다.

GET /chat HTTP/1.1
Host: example.com:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com

핵심 헤더 설명:

헤더필수설명
Upgrade: websocket✅WebSocket 프로토콜로 업그레이드 요청
Connection: Upgrade✅HTTP 연결 업그레이드
Sec-WebSocket-Key✅랜덤 16바이트 Base64 (서버가 WebSocket 핸드셰이크를 이해했는지 확인하는 용도)
Sec-WebSocket-Version: 13✅WebSocket 버전 (13만 지원)
Origin권장CORS 검증용 (브라우저)
Sec-WebSocket-Protocol선택서브프로토콜 (예: chat, json)

Sec-WebSocket-Key 생성

#include <random>
#include <boost/beast/core/detail/base64.hpp>  // Beast 내부(detail) API라 버전에 따라 바뀔 수 있음
// RFC 6455: 16바이트 랜덤 → Base64
std::string generate_websocket_key() {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dis(0, 255);
    
    unsigned char key[16];
    for (int i = 0; i < 16; ++i) {
        key[i] = static_cast<unsigned char>(dis(gen));
    }
    
    std::string result;
    result.resize(boost::beast::detail::base64::encoded_size(16));
    result.resize(boost::beast::detail::base64::encode(
        &result[0], key, 16));
    
    return result;
}

서버 응답 (101 Switching Protocols)

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sec-WebSocket-Accept 계산

#include <openssl/sha.h>
#include <boost/beast/core/detail/base64.hpp>
std::string compute_accept(const std::string& key) {
    const std::string magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
    std::string input = key + magic;
    
    unsigned char hash[SHA_DIGEST_LENGTH];
    SHA1(reinterpret_cast<const unsigned char*>(input.data()),
         input.size(), hash);
    
    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;
}

알고리즘: SHA1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") → Base64

핸드셰이크 시퀀스 다이어그램

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버
    
    C->>S: TCP 연결
    C->>S: HTTP GET + Upgrade + Sec-WebSocket-Key
    S->>S: Key 검증, Accept 계산
    S->>C: HTTP 101 + Sec-WebSocket-Accept
    Note over C,S: WebSocket 연결 수립
    C->>S: WebSocket Frame (데이터)
    S->>C: WebSocket Frame (데이터)

핸드셰이크 실패 케이스

응답원인
400 Bad RequestSec-WebSocket-Key 누락, Upgrade 헤더 오류
403 ForbiddenOrigin 검증 실패
426 Upgrade RequiredSec-WebSocket-Version 불일치
503 Service Unavailable서버 과부하, 연결 수 제한

RFC 6455 프레임 레이아웃과 opcode별 예시

프레임 레이아웃 (RFC 6455)

graph LR
    subgraph Header
        A[FIN 1bit] --> B[RSV 3bit]
        B --> C[Opcode 4bit]
        C --> D[Mask 1bit]
        D --> E[Payload Len 7bit]
    end
    E --> F[Extended 0/2/8 byte]
    F --> G[Mask Key 0/4 byte]
    G --> H[Payload Data]

Opcode 완전 목록

Opcode값의미방향
Continuation0x0이전 프레임의 연속양방향
Text0x1UTF-8 텍스트양방향
Binary0x2바이너리 데이터양방향
Close0x8연결 종료양방향
Ping0x9Heartbeat 요청양방향
Pong0xAHeartbeat 응답양방향

Text 프레임 예시 (마스킹 O)

클라이언트 → 서버: “Hello” (5바이트)

바이트 0: 0x81 (FIN=1, Opcode=0x1 Text)
바이트 1: 0x85 (Mask=1, Payload Len=5)
바이트 2-5: Masking Key (4바이트 랜덤)
바이트 6-10: "Hello" XOR Masking Key
// 마스킹 알고리즘 (클라이언트 → 서버 필수)
void mask_payload(uint8_t* data, size_t len, const uint8_t key[4]) {
    for (size_t i = 0; i < len; ++i) {
        data[i] ^= key[i % 4];
    }
}

마스킹 이유: 프록시 캐시 poisoning 공격 방지. 오래된 프록시가 WebSocket 트래픽을 HTTP로 오인해 캐시할 수 있어, 마스킹으로 “캐시 불가” 형태로 만듭니다.

Ping 프레임 예시

바이트 0: 0x89 (FIN=1, Opcode=0x9 Ping)
바이트 1: 0x00 (Mask=0 서버→클라이언트, Payload Len=0)

Payload가 있으면 Pong에 그대로 반환합니다.

Pong 프레임 예시

바이트 0: 0x8A (FIN=1, Opcode=0xA Pong)
바이트 1: 0x00 (Payload Len=0)

Close 프레임 예시

바이트 0: 0x88 (FIN=1, Opcode=0x8 Close)
바이트 1: 0x02 (Payload Len=2)
바이트 2-3: Close Code (예: 1000=정상, 1001=이동, 1002=프로토콜 에러)
바이트 4-: UTF-8 이유 문자열 (선택)

주요 Close Code:

코드의미
1000Normal Closure
1001Going Away (서버 종료 등)
1002Protocol Error
1003Unsupported Data
1006Abnormal Closure (Close 프레임 없이 끊김)
1007Invalid payload (인코딩 오류)
1011Internal Error

Beast로 프레임 타입 처리

ws_.control_callback(
    [](websocket::frame_type kind, beast::string_view) {
        switch (kind) {
            case websocket::frame_type::ping:
                // Beast가 자동으로 Pong 전송
                break;
            case websocket::frame_type::pong:
                // heartbeat 응답 수신
                break;
            case websocket::frame_type::close:
                // 연결 종료 요청
                break;
        }
    });

Ping/Pong heartbeat 구현

Ping/Pong 시퀀스

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버
    
    loop 30초마다
        C->>S: Ping
        S->>C: Pong (자동)
    end
    
    Note over C: Pong 미수신 10초
    C->>C: 연결 끊김 판단 → 재연결

클라이언트: Ping 전송 + Pong 타임아웃

Beast의 stream_base::timeout에서 keep_alive_pings를 켜면 아래 동작을 Beast가 대신해 주므로, 직접 구현은 Pong 지연을 측정하거나 재연결 정책을 세밀하게 제어해야 할 때 씁니다.

class WebSocketClientWithHeartbeat
    : public std::enable_shared_from_this<WebSocketClientWithHeartbeat> {
    websocket::stream<beast::tcp_stream> ws_;
    beast::flat_buffer buffer_;
    net::steady_timer ping_timer_;
    net::steady_timer pong_timeout_;
    bool pong_received_ = false;
    
public:
    explicit WebSocketClientWithHeartbeat(net::io_context& ioc)
        : ws_(net::make_strand(ioc)),
          ping_timer_(ws_.get_executor()),
          pong_timeout_(ws_.get_executor()) {}
    
    void start_heartbeat() {
        // Pong 수신을 기록할 콜백 등록 (순환 참조를 피하려고 this 캡처)
        ws_.control_callback(
            [this](websocket::frame_type kind, beast::string_view) {
                if (kind == websocket::frame_type::pong) on_pong();
            });
        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;
                self->send_ping();
            });
    }
    
    void send_ping() {
        pong_received_ = false;
        pong_timeout_.expires_after(std::chrono::seconds(10));
        pong_timeout_.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;
                }
            });
        
        ws_.async_ping({},
            [self = shared_from_this()](beast::error_code ec) {
                if (ec) {
                    std::cerr << "Ping failed: " << ec.message() << "\n";
                    return;
                }
                self->schedule_ping();
            });
    }
    
    void on_pong() {
        pong_received_ = true;
        pong_timeout_.cancel();
    }
    
    void reconnect() {
        // 재연결 로직 (지수 백오프 권장)
    }
};

서버: Ping 수신 시 Pong 자동 응답

Beast는 Ping을 받으면 자동으로 Pong을 보냅니다. control_callback은 Pong 수신을 기록하는 용도로 씁니다. 콜백은 스트림이 읽기를 진행하는 동안에만 호출되므로 async_read가 항상 걸려 있어야 합니다. 콜백에 shared_from_this()를 캡처하면 스트림(멤버)이 세션을 가리키는 순환 참조가 생겨 세션이 영원히 해제되지 않으므로 this나 weak_ptr를 캡처합니다.

ws_.control_callback(
    [this](websocket::frame_type kind, beast::string_view) {
        if (kind == websocket::frame_type::pong) {
            // 서버가 보낸 Ping에 대한 클라이언트의 Pong
            on_pong_received();
        }
        // ping: Beast가 자동으로 Pong 전송
    });

서버 → 클라이언트 Ping (선택)

서버가 클라이언트 연결 상태를 확인하려면 서버에서 Ping을 보낼 수 있습니다.

void server_send_ping() {
    ws_.async_ping("heartbeat",
        [self = shared_from_this()](beast::error_code ec) {
            if (ec) {
                // 전송 실패 = 연결 끊김
                self->close_session();
            }
        });
}

Beast 비동기 클라이언트와 에코 서버

비동기 클라이언트 (핸드셰이크 + 읽기 + Ping)

#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 CompleteWebSocketClient
    : public std::enable_shared_from_this<CompleteWebSocketClient> {
    tcp::resolver resolver_;  // 비동기 조회가 끝날 때까지 살아 있어야 하므로 멤버
    websocket::stream<beast::tcp_stream> ws_;
    beast::flat_buffer buffer_;
    net::steady_timer ping_timer_;
    std::string host_;
    std::string path_;
    
public:
    explicit CompleteWebSocketClient(net::io_context& ioc)
        : resolver_(net::make_strand(ioc)),
          ws_(net::make_strand(ioc)),
          ping_timer_(ws_.get_executor()) {}
    
    void connect(const std::string& host, const std::string& port,
                 const std::string& path = "/") {
        host_ = host;
        path_ = path;
        
        resolver_.async_resolve(host, port,
            beast::bind_front_handler(&CompleteWebSocketClient::on_resolve,
                                     shared_from_this()));
    }
    
private:
    void on_resolve(beast::error_code ec,
                    tcp::resolver::results_type results) {
        if (ec) {
            std::cerr << "Resolve: " << ec.message() << "\n";
            return;
        }
        
        beast::get_lowest_layer(ws_).async_connect(results,
            beast::bind_front_handler(&CompleteWebSocketClient::on_connect,
                                     shared_from_this()));
    }
    
    void on_connect(beast::error_code ec,
                    tcp::resolver::results_type::endpoint_type ep) {
        if (ec) {
            std::cerr << "Connect: " << ec.message() << "\n";
            return;
        }
        
        // Host 헤더에는 기본 포트가 아니면 포트도 포함해야 함 (예: host_ + ":" + std::to_string(ep.port()))
        ws_.async_handshake(host_, path_,
            beast::bind_front_handler(&CompleteWebSocketClient::on_handshake,
                                     shared_from_this()));
    }
    
    void on_handshake(beast::error_code ec) {
        if (ec) {
            std::cerr << "Handshake: " << ec.message() << "\n";
            return;
        }
        
        std::cout << "WebSocket connected\n";
        do_read();
        start_ping();
    }
    
    void do_read() {
        ws_.async_read(buffer_,
            beast::bind_front_handler(&CompleteWebSocketClient::on_read,
                                     shared_from_this()));
    }
    
    void on_read(beast::error_code ec, std::size_t bytes) {
        if (ec) {
            if (ec != websocket::error::closed) {
                std::cerr << "Read: " << ec.message() << "\n";
            }
            return;
        }
        
        std::cout << "Received: "
                  << beast::buffers_to_string(buffer_.data()) << "\n";
        buffer_.consume(buffer_.size());
        do_read();
    }
    
    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;
                self->ws_.async_ping({},
                    [self](beast::error_code ec) {
                        if (!ec) self->start_ping();
                    });
            });
    }
};

비동기 서버 (Echo + Ping/Pong)

class CompleteWebSocketSession
    : public std::enable_shared_from_this<CompleteWebSocketSession> {
    websocket::stream<beast::tcp_stream> ws_;
    beast::flat_buffer buffer_;
    
public:
    explicit CompleteWebSocketSession(tcp::socket socket)
        : ws_(std::move(socket)) {}
    
    void run() {
        ws_.set_option(websocket::stream_base::timeout::suggested(
            beast::role_type::server));
        ws_.read_message_max(64 * 1024);  // 64KB 제한
        
        ws_.async_accept(
            beast::bind_front_handler(&CompleteWebSocketSession::on_accept,
                                     shared_from_this()));
    }
    
private:
    void on_accept(beast::error_code ec) {
        if (ec) {
            std::cerr << "Accept: " << ec.message() << "\n";
            return;
        }
        
        do_read();
    }
    
    void do_read() {
        ws_.async_read(buffer_,
            beast::bind_front_handler(&CompleteWebSocketSession::on_read,
                                     shared_from_this()));
    }
    
    void on_read(beast::error_code ec, std::size_t) {
        if (ec) {
            if (ec == websocket::error::closed) {
                std::cout << "Connection closed normally\n";
            } else {
                std::cerr << "Read: " << ec.message() << "\n";
            }
            return;
        }
        
        ws_.text(ws_.got_text());
        ws_.async_write(buffer_.data(),
            beast::bind_front_handler(&CompleteWebSocketSession::on_write,
                                     shared_from_this()));
    }
    
    void on_write(beast::error_code ec, std::size_t) {
        if (ec) {
            std::cerr << "Write: " << ec.message() << "\n";
            return;
        }
        
        buffer_.consume(buffer_.size());
        do_read();
    }
};

400 핸드셰이크, message_too_big, 동시 async_read 같은 에러

에러 1: Handshake 400 Bad Request

서버가 101이 아닌 응답(400 등)을 돌려주면 Beast 클라이언트의 핸드셰이크는 websocket::error::upgrade_declined 에러로 끝납니다. 서버가 왜 거절했는지 보려면 응답 객체를 받는 오버로드로 상태 코드와 본문을 확인합니다. 원인은 대개 Sec-WebSocket-Key 누락이나 형식 오류, Upgrade·Connection 헤더 누락이며, Beast를 쓴다면 중간 프록시가 이 헤더를 전달하지 않는 경우가 많습니다.

websocket::response_type res_;  // 멤버로 두어 핸들러까지 살아 있게 함
ws_.async_handshake(res_, host, path,
    [this](beast::error_code ec) {
        if (ec == websocket::error::upgrade_declined) {
            std::cerr << "Upgrade declined: " << res_.result_int() << " " << res_.body() << "\n";
        }
    });

에러 2: bad_version (426 Upgrade Required)

서버가 426으로 응답했다면 Sec-WebSocket-Version이 13이 아닌 경우입니다. Beast는 항상 13을 보내므로, 직접 구현한 클라이언트에서 주로 생깁니다.

에러 3: connection_reset / connection_aborted

읽기·쓰기 중 연결이 끊기는 경우로, NAT·방화벽 유휴 타임아웃, 서버 재시작, 네트워크 불안정이 원인입니다. heartbeat로 유휴 끊김을 막고, 그래도 끊기면 재연결합니다.

ws_.async_read(buffer_,
    [self = shared_from_this()](beast::error_code ec, std::size_t) {
        if (ec) {
            if (ec == net::error::connection_reset ||
                ec == net::error::connection_aborted) {
                self->schedule_reconnect();
            }
            return;
        }
        // ...
    });

에러 4: frame too big / payload too large

websocket::error::message_too_big은 받은 메시지가 read_message_max를 넘었다는 뜻입니다. 상대가 정상적으로 큰 메시지를 보내는 서비스라면 한도를 올리고, 아니라면 공격이나 버그로 보고 연결을 끊습니다.

// 서버: 최대 메시지 크기 제한
ws_.read_message_max(1024 * 1024);  // 1MB
// 클라이언트도 동일하게 설정
ws_.read_message_max(1024 * 1024);

에러 5: 멀티스레드 서버에서 연결이 불규칙하게 끊김

io_context::run()을 여러 스레드에서 호출하는 서버에서 한 스트림의 핸들러들이 동시에 실행되면 데이터 레이스로 프레임이 깨집니다. 클라이언트 브라우저와는 관계없는 서버 쪽 버그입니다. 스트림을 strand 실행기로 만들면 그 스트림의 완료 핸들러가 모두 strand에서 순서대로 실행됩니다. 다른 스레드에서 이 스트림에 쓰기를 시작해야 한다면 net::post(ws_.get_executor(), ...)로 strand에 넘깁니다.

// strand 실행기로 만든 WebSocket: 핸들러가 이 strand에서 직렬화됨
websocket::stream<beast::tcp_stream> ws_(net::make_strand(ioc));
ws_.async_read(buffer_, [](beast::error_code, std::size_t) { /* ... */ });

에러 6: Mask required (클라이언트 → 서버)

RFC 6455에 따라 클라이언트 → 서버 프레임은 반드시 마스킹해야 하며, 마스킹되지 않은 프레임을 받은 서버는 연결을 끊습니다. Beast 클라이언트는 자동으로 마스킹하므로 직접 구현한 클라이언트에서 주로 생깁니다.

에러 7: Invalid UTF-8 (Text 프레임)

Text 프레임은 유효한 UTF-8이어야 하며, Beast는 이를 검사해 위반하면 에러로 연결을 닫습니다. 임의 바이트를 보낸다면 Binary 프레임을 쓰거나, 보내기 전에 UTF-8을 검증합니다.

// Binary로 전송하거나, UTF-8 검증 후 전송
ws_.binary(true);
ws_.async_write(net::buffer(data), ...);

에러 8: Double read (동시 async_read)

async_read가 끝나기 전에 또 async_read를 호출하면 미정의 동작이며 크래시로 이어집니다. 쓰기도 마찬가지로 한 번에 하나만 진행되어야 합니다. 다음 읽기는 읽기 완료 핸들러에서만 시작합니다.

void on_read(beast::error_code ec, std::size_t) {
    if (ec) return;
    // 처리 ...
    do_read();  // 여기서만 다음 읽기 시작
}

메시지 크기 제한, 백오프 재연결, graceful close, 브로드캐스트 백프레셔

메시지 크기 제한

// 서비스가 실제로 주고받는 최대 메시지보다 약간 크게
// 예: 짧은 채팅 메시지만 오가는 서비스
ws_.read_message_max(64 * 1024);

재연결: 지수 백오프

// attempt_는 멤버 변수, 핸드셰이크 성공(on_handshake)에서 0으로 리셋
void schedule_reconnect() {
    // duration에는 << 연산자가 없으므로 정수로 계산
    auto base = std::chrono::seconds(std::min<long long>(1LL << std::min(attempt_, 6), 60));
    ++attempt_;
    // ±20% jitter로 재시작 직후 동시 접속 몰림 완화
    std::uniform_real_distribution<double> jitter(0.8, 1.2);
    auto delay = std::chrono::duration_cast<std::chrono::milliseconds>(base * jitter(rng_));
    reconnect_timer_.expires_after(delay);
    reconnect_timer_.async_wait(
        [self = shared_from_this()](beast::error_code ec) {
            if (!ec) self->connect(self->host_, self->port_, self->path_);
        });
}

Graceful Close

void close() {
    ws_.async_close(websocket::close_code::normal,
        [self = shared_from_this()](beast::error_code ec) {
            if (ec) {
                beast::get_lowest_layer(self->ws_).close();
            }
        });
}

브로드캐스트 백프레셔

// ❌ 나쁜 예: 동시에 수천 개 write
for (auto& session : sessions_) {
    session->ws_.async_write(...);  // 큐 폭주
}
// ✅ 좋은 예: 세션별 큐로 한 번에 하나씩 전송
// 메시지는 shared_ptr<const std::string>으로 공유해 세션마다 복사하지 않음
void broadcast(std::shared_ptr<const std::string> msg) {
    for (auto& session : sessions_) {
        session->enqueue(msg);
    }
}
void enqueue(std::shared_ptr<const std::string> msg) {
    // 다른 스레드에서 호출될 수 있으므로 세션의 strand로 넘김
    net::post(ws_.get_executor(), [self = shared_from_this(), msg] {
        if (self->write_queue_.size() >= kMaxQueue) return;  // 느린 클라이언트: 버리거나 연결 종료
        bool was_empty = self->write_queue_.empty();
        self->write_queue_.push(msg);
        if (was_empty) self->do_write();
    });
}
void do_write() {
    ws_.async_write(net::buffer(*write_queue_.front()),
        [self = shared_from_this()](beast::error_code ec, std::size_t) {
            if (ec) return;
            self->write_queue_.pop();
            if (!self->write_queue_.empty()) self->do_write();
        });
}

타임아웃 설정

websocket::stream_base::timeout opt{
    std::chrono::seconds(30),  // handshake timeout
    std::chrono::seconds(60),  // idle timeout
    true                       // keep_alive_pings: idle 절반(30초) 동안 조용하면 Ping 전송
};
ws_.set_option(opt);
// keep_alive_pings를 false로 두면 메시지가 없는 정상 연결도 idle timeout에 끊김

로깅

ws_.async_handshake(host, path,
    [host, path](beast::error_code ec) {
        if (ec) {
            spdlog::error("WebSocket handshake failed: {} {} {}",
                host, path, ec.message());
        }
    });

연결 수 제한, 메트릭, 서브프로토콜 협상, WSS

패턴 1: 연결 수 제한

class WebSocketServer {
    std::atomic<int> connection_count_{0};
    static constexpr int max_connections_ = 10000;
    
    void do_accept() {
        acceptor_.async_accept(
            [this](beast::error_code ec, tcp::socket socket) {
                if (ec) return;
                
                if (connection_count_.load() >= max_connections_) {
                    socket.close();
                    spdlog::warn("Connection limit reached");
                } else {
                    connection_count_++;
                    std::make_shared<Session>(std::move(socket),
                        [this]() { connection_count_--; })->run();
                }
                do_accept();
            });
    }
};

패턴 2: Graceful Shutdown

void shutdown() {
    acceptor_.close();  // 새 연결 수락 중단
    
    for (auto& session : sessions_) {
        session->ws_.async_close(websocket::close_code::going_away,
            [](beast::error_code) {});
    }
    
    // ioc_.stop()을 바로 부르면 위 close가 끝나기 전에 루프가 멈춤
    // work guard만 해제하면 남은 작업이 끝난 뒤 run()이 자연스럽게 반환됨
    work_guard_.reset();
}

패턴 3: 메트릭 수집

struct WebSocketMetrics {
    std::atomic<uint64_t> connections_total{0};
    std::atomic<uint64_t> connections_active{0};
    std::atomic<uint64_t> messages_received{0};
    std::atomic<uint64_t> messages_sent{0};
    std::atomic<uint64_t> errors_handshake{0};
    std::atomic<uint64_t> errors_read{0};
};
// Prometheus/Grafana 등으로 노출
void on_handshake(beast::error_code ec) {
    if (ec) {
        metrics_.errors_handshake++;
        return;
    }
    metrics_.connections_active++;
}

패턴 4: Subprotocol 협상

// 클라이언트
ws_.set_option(websocket::stream_base::decorator(
    [](websocket::request_type& req) {
        req.set(beast::http::field::sec_websocket_protocol,
               "chat, json");
    }));
// 서버: 클라이언트가 제안한 목록에 있을 때만 응답에 넣어야 함
// (요청을 먼저 http::read로 읽고 req[sec_websocket_protocol]을 확인한 뒤 async_accept(req, ...))
ws_.set_option(websocket::stream_base::decorator(
    [](websocket::response_type& res) {
        res.set(beast::http::field::sec_websocket_protocol, "chat");
    }));

패턴 5: WSS (TLS) 연동

using ssl_stream = boost::asio::ssl::stream<beast::tcp_stream>;
websocket::stream<ssl_stream> wss_(net::make_strand(ioc), ssl_ctx);
// SNI 설정 (많은 서버가 요구)
SSL_set_tlsext_host_name(wss_.next_layer().native_handle(), host_.c_str());
// TCP 연결 후 TLS 핸드셰이크, 그다음 WebSocket 핸드셰이크
wss_.next_layer().async_handshake(ssl::stream_base::client,
    [this](beast::error_code ec) {
        if (!ec) {
            wss_.async_handshake(host_, path_, ...);
        }
    });

참고 자료


다음 글: [C++ 실전 가이드 #30-3] 프로토콜과 직렬화 이전 글: C++ WebSocket 구현


같이 보면 좋은 글