C++ 채팅 서버 만들기 | 다중 클라이언트와 메시지 브로드캐스트 완벽 가이드 [#31-1]
이 글의 핵심
C++ 채팅 서버 만들기에 대해 정리한 개발 블로그 글입니다. - 클라이언트 A가 보낸 메시지를 B, C, D에게 동시에 전달해야 하는데, 참가자 목록을 순회하면서 async_write를 걸면 데이터 레이스가 발생할 수 있어요. - 새 사용자가 입장했을 때 기존 참가자들에게 "OO님이… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련 키워드: C++…
들어가며: “채팅 서버에서 메시지 브로드캐스트가 복잡해요”
문제 시나리오
채팅 서버를 만들다 보면 이런 고민이 생깁니다:
- 클라이언트 A가 보낸 메시지를 B, C, D에게 동시에 전달해야 하는데, 참가자 목록을 순회하면서
async_write를 걸면 데이터 레이스가 발생할 수 있어요. - 새 사용자가 입장했을 때 기존 참가자들에게 “OO님이 입장했습니다” 알림을 보내고, 퇴장 시에도 “OO님이 나갔습니다”를 브로드캐스트해야 하는데, join/leave 시점에 목록이 변경되면서 충돌이 나요.
- 나중에 입장한 사용자에게 최근 N개 메시지 히스토리를 보내주려면, 메시지를 어디에 저장하고 어떻게 전달할까요?
- 동시 접속자 1만 명을 처리하려면 어떤 구조가 적합할까요?
구체적인 문제 상황
| 시나리오 | 증상 | 원인 |
|---|---|---|
| 100명이 동시에 메시지 전송 | 서버 크래시, 메시지 누락 | participants_ 순회 중 다른 스레드가 leave() 호출 → iterator 무효화 |
| 느린 클라이언트 1명 | 전체 채팅 지연 | 한 세션의 write_queue_가 무한 증가 → 메모리 부족, 다른 세션도 영향 |
| 연결 끊김 후 재접속 | ”닉네임 중복” 또는 고아 세션 | leave 호출 전에 세션이 소멸되거나, participants_에서 제거 누락 |
| 긴 메시지 폭탄 | 서버 메모리 급증 | async_read_until이 \n 전까지 무제한 버퍼링 → DoS 공격에 취약 |
| 채널별 분리 필요 | 한 방에만 메시지 전달해야 함 | 단일 Room 구조로는 채널 구분 불가 → Room을 채널별로 분리해야 함 |
채팅 서버의 핵심은 브로드캐스트(broadcast)—한 클라이언트가 보낸 메시지를 나를 제외한 모든 참가자에게 전송하는 것—를 비동기로 안전하게 처리하는 것입니다. 세션을 shared_ptr로 관리하고, Room(참가자 목록)에 등록한 뒤, strand로 입장/퇴장·브로드캐스트를 직렬화하면 실무 수준의 채팅 서버 골격을 만들 수 있습니다.
목표:
- 세션: 연결당 하나,
async_read→ 수신 메시지 파싷 → 브로드캐스트 → 다시async_read - 참가자 목록과 strand로 동기화
- 입장/퇴장 알림 브로드캐스트
- 메시지 히스토리 (최근 N개)
- 일반적인 에러 (데이터 레이스, 메모리 누수)와 해결법
- 성능 벤치마크 (동시 접속자)
- 프로덕션 배포 가이드
요구 환경: C++17 이상, Boost.Asio 1.70+
개념을 잡는 비유
소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.
목차
- 전체 구조와 아키텍처
- ChatRoom 완전 구현
- Session 완전 구현
- strand를 이용한 메시지 브로드캐스트
- 입장/퇴장 알림
- 메시지 히스토리
- 일반적인 에러와 해결법
- 성능 벤치마크
- 성능 최적화 팁
- 프로덕션 배포
- 정리와 다음 단계
1. 전체 구조와 아키텍처
아키텍처 다이어그램
flowchart TB
subgraph Server["채팅 서버"]
Acceptor["acceptorbr/async_accept"]
Room["ChatRoombr/participants + h..."]
Strand["strandbr/동기화"]
end
subgraph Sessions["세션들"]
S1[Session A]
S2[Session B]
S3[Session C]
end
Acceptor -->|새 연결| S1
Acceptor -->|새 연결| S2
Acceptor -->|새 연결| S3
S1 -->|join/leave/deliver| Strand
S2 -->|join/leave/deliver| Strand
S3 -->|join/leave/deliver| Strand
Strand --> Room
Room -->|async_write| S1
Room -->|async_write| S2
Room -->|async_write| S3
핵심 컴포넌트
| 컴포넌트 | 역할 |
|---|---|
| io_context + acceptor | async_accept로 새 연결 수락 |
| Session (shared_ptr) | 소켓, 버퍼, strand. Room에 등록 |
| ChatRoom | 참가자 목록, 메시지 히스토리, deliver() 시 나를 제외한 모든 참가자에게 async_write |
| strand | join, leave, deliver를 같은 strand에서 실행해 데이터 레이스 방지 |
2. ChatRoom 완전 구현
ChatRoom은 참가자 목록과 메시지 히스토리를 관리하고, strand를 통해 모든 수정 작업을 직렬화합니다.
#include <boost/asio.hpp>
#include <memory>
#include <set>
#include <deque>
#include <string>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
using error_code = boost::system::error_code;
class Session; // 전방 선언
class ChatRoom {
public:
explicit ChatRoom(asio::io_context& io)
: strand_(asio::make_strand(io))
, max_history_(100) {}
// strand에서 실행: 참가자 추가
void join(std::shared_ptr<Session> session) {
asio::post(strand_, [this, session]() {
participants_.insert(session);
});
}
// strand에서 실행: 참가자 제거
void leave(std::shared_ptr<Session> session) {
asio::post(strand_, [this, session]() {
participants_.erase(session);
});
}
// strand에서 실행: 메시지를 sender 제외 모든 참가자에게 브로드캐스트
void deliver(const std::string& message,
std::shared_ptr<Session> sender) {
asio::post(strand_, [this, message, sender]() {
history_.push_back(message);
if (history_.size() > max_history_) {
history_.pop_front();
}
for (auto& participant : participants_) {
if (participant != sender) {
participant->deliver(message);
}
}
});
}
// strand에서 실행: 입장 알림 브로드캐스트
void broadcast_join(const std::string& nickname,
std::shared_ptr<Session> newcomer) {
asio::post(strand_, [this, nickname, newcomer]() {
std::string msg = "[시스템] " + nickname + "님이 입장했습니다.\n";
history_.push_back(msg);
if (history_.size() > max_history_) history_.pop_front();
for (auto& p : participants_) {
p->deliver(msg);
}
});
}
// strand에서 실행: 퇴장 알림 브로드캐스트
void broadcast_leave(const std::string& nickname) {
asio::post(strand_, [this, nickname]() {
std::string msg = "[시스템] " + nickname + "님이 퇴장했습니다.\n";
history_.push_back(msg);
if (history_.size() > max_history_) history_.pop_front();
for (auto& p : participants_) {
p->deliver(msg);
}
});
}
// strand에서 실행: 새 참가자에게 히스토리 전송
void send_history(std::shared_ptr<Session> session) {
asio::post(strand_, [this, session]() {
for (const auto& msg : history_) {
session->deliver(msg);
}
});
}
asio::strand<asio::io_context::executor_type>& strand() {
return strand_;
}
private:
asio::strand<asio::io_context::executor_type> strand_;
std::set<std::shared_ptr<Session>> participants_;
std::deque<std::string> history_;
size_t max_history_;
};
설명:
join/leave/deliver/broadcast_join/broadcast_leave/send_history는 모두asio::post(strand_, ...)로 감싸서 동일 strand에서 실행되므로,participants_와history_에 대한 접근이 직렬화됩니다.participants_는set<shared_ptr<Session>>로, 세션 수명은shared_ptr로 관리됩니다.history_는deque로 최근max_history_개만 유지합니다.
3. Session 완전 구현
Session은 연결 하나를 나타내며, async_read_until로 한 줄씩 읽고 Room::deliver로 브로드캐스트한 뒤 다시 읽기를 등록합니다.
class Session : public std::enable_shared_from_this<Session> {
public:
Session(tcp::socket socket, ChatRoom& room)
: socket_(std::move(socket))
, room_(room)
, strand_(room.strand()) {}
void start() {
room_.join(shared_from_this());
do_read();
}
void do_read() {
asio::async_read_until(socket_, read_buf_, '\n',
asio::bind_executor(strand_, [self = shared_from_this()](
error_code ec, std::size_t /*bytes_transferred*/) {
if (ec) {
self->handle_error(ec);
return;
}
std::string msg = self->parse_message();
if (!msg.empty()) {
self->room_.deliver(msg, self);
}
self->do_read();
}));
}
// Room에서 호출: 이 세션의 write 큐에 메시지 추가
void deliver(const std::string& message) {
bool write_in_progress = !write_queue_.empty();
write_queue_.push_back(message);
if (!write_in_progress) {
do_write();
}
}
private:
void do_write() {
if (write_queue_.empty()) return;
const std::string& msg = write_queue_.front();
asio::async_write(socket_, asio::buffer(msg),
asio::bind_executor(strand_, [self = shared_from_this()](
error_code ec, std::size_t /*bytes_transferred*/) {
if (ec) {
self->handle_error(ec);
return;
}
self->write_queue_.pop_front();
if (!self->write_queue_.empty()) {
self->do_write();
}
}));
}
std::string parse_message() {
std::istream is(&read_buf_);
std::string line;
std::getline(is, line);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
return line.empty() ? "" : (line + "\n");
}
void handle_error(error_code ec) {
if (ec == asio::error::eof || ec == asio::error::connection_reset) {
room_.leave(shared_from_this());
room_.broadcast_leave(nickname_);
}
}
tcp::socket socket_;
asio::streambuf read_buf_;
ChatRoom& room_;
asio::strand<asio::io_context::executor_type> strand_;
std::deque<std::string> write_queue_;
std::string nickname_;
};
설명:
enable_shared_from_this<Session>: 비동기 완료 핸들러에서shared_from_this()로 수명을 유지합니다.async_read_until(..., '\n'): 한 줄 단위로 메시지를 수신합니다.deliver(): Room에서 호출.write_queue_에 넣고, 쓰기 중이 아니면do_write()를 시작합니다. 한 번에 하나의async_write만 걸고, 완료 시 큐에 다음 메시지가 있으면 다시do_write()를 호출해 순서를 보장합니다.asio::bind_executor(strand_, ...): 읽기/쓰기 완료 핸들러가 Room의 strand에서 실행되도록 합니다.
닉네임 설정: 실제 구현에서는 첫 메시지를 "NICK alice\n" 형태로 받아 nickname_에 저장하고, 이후 메시지는 "alice: 안녕\n" 형태로 브로드캐스트할 수 있습니다.
서버 main과 do_accept
acceptor가 새 연결을 받을 때마다 Session을 생성하고 start()를 호출합니다.
class Server {
public:
Server(asio::io_context& io, const tcp::endpoint& endpoint)
: acceptor_(io, endpoint)
, room_(io) {}
void start() {
do_accept();
}
private:
void do_accept() {
acceptor_.async_accept(
[this](error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<Session>(std::move(socket), room_)
->start();
}
do_accept(); // 다음 연결 대기
});
}
tcp::acceptor acceptor_;
ChatRoom room_;
};
int main() {
asio::io_context io;
Server server(io, tcp::endpoint(tcp::v4(), 9000));
server.start();
io.run();
return 0;
}
4. strand를 이용한 메시지 브로드캐스트
왜 strand가 필요한가?
여러 스레드에서 io_context::run()을 호출하면, 완료 핸들러가 서로 다른 스레드에서 동시에 실행될 수 있습니다. 이때 participants_에 동시에 접근하면 데이터 레이스가 발생합니다.
// ❌ strand 없이: 데이터 레이스
void deliver(const std::string& msg, std::shared_ptr<Session> sender) {
for (auto& p : participants_) { // 스레드 A가 순회 중
p->deliver(msg); // 스레드 B가 leave()로 participants_ 수정 → 💥
}
}
strand는 “이 strand에 포스트된 작업들은 순서대로, 동시에 하나만 실행된다”는 보장을 합니다. join, leave, deliver를 모두 같은 strand에 포스트하면, participants_ 접근이 직렬화됩니다.
브로드캐스트 흐름
sequenceDiagram
participant S1 as Session A
participant Room as ChatRoom
participant S2 as Session B
participant S3 as Session C
S1->>Room: deliver("안녕", self)
Room->>Room: post(strand, [히스토리 추가, participants 순회])
Room->>S2: deliver("안녕")
Room->>S3: deliver("안녕")
S2->>S2: write_queue_.push + do_write()
S3->>S3: write_queue_.push + do_write()
5. 입장/퇴장 알림
입장 시
Session::start()에서room_.join(shared_from_this())호출.- 클라이언트가
"NICK alice\n"를 보내면, 서버가nickname_을 설정. room_.broadcast_join(nickname_, shared_from_this())호출 → “[시스템] alice님이 입장했습니다.” 브로드캐스트.room_.send_history(shared_from_this())호출 → 최근 메시지 히스토리 전송.
퇴장 시
async_read_until또는async_write완료 시error_code가eof또는connection_reset.handle_error()에서room_.leave(shared_from_this())와room_.broadcast_leave(nickname_)호출.participants_에서 제거되면, 해당 세션에 대한shared_ptr참조가 줄어들어 세션이 소멸됩니다.
프로토콜 예시 (텍스트)
NICK alice
alice: 안녕하세요
bob: 반가워요
서버가 파싱할 때:
NICK으로 시작하면 닉네임 설정.- 그 외에는
nickname_:접두어를 붙여"alice: 안녕하세요\n"형태로 브로드캐스트.
바이너리 프로토콜 (길이 프리픽스): 4바이트 길이 + payload. 프로토콜 설계와 직렬화(#30-3)에서 상세히 다룹니다. 대용량 메시지나 이진 데이터가 필요할 때 유용합니다.
프로토콜 명세 (텍스트 기반)
# 클라이언트 → 서버
NICK <닉네임>\n # 입장 시 닉네임 설정 (최초 1회)
<메시지 내용>\n # 일반 채팅 (닉네임 설정 후)
# 서버 → 클라이언트
[시스템] OO님이 입장했습니다.\n
[시스템] OO님이 퇴장했습니다.\n
<닉네임>: <메시지>\n # 다른 참가자 메시지
바이너리 프로토콜 (길이 프리픽스)
대용량·이진 데이터 지원 시 사용합니다.
// 프레임 구조: [4바이트 길이 little-endian][payload]
struct MessageFrame {
uint32_t length; // payload 길이 (자기 자신 제외)
char payload[]; // 가변 길이
};
// 송신 예시
void send_message(tcp::socket& sock, const std::string& msg) {
uint32_t len = static_cast<uint32_t>(msg.size());
std::vector<asio::const_buffer> bufs = {
asio::buffer(&len, 4),
asio::buffer(msg)
};
asio::write(sock, bufs);
}
채널별 Room 관리 (다중 채팅방)
하나의 서버에서 여러 채널을 지원하려면 채널 ID별로 ChatRoom을 분리합니다.
#include <unordered_map>
#include <mutex>
class ChatServer {
public:
void join_channel(const std::string& channel_id,
std::shared_ptr<Session> session) {
std::lock_guard<std::mutex> lock(rooms_mutex_);
auto it = rooms_.find(channel_id);
if (it == rooms_.end()) {
it = rooms_.emplace(channel_id,
std::make_shared<ChatRoom>(io_)).first;
}
it->second->join(session);
session->set_room(it->second);
}
void leave_channel(const std::string& channel_id,
std::shared_ptr<Session> session) {
std::lock_guard<std::mutex> lock(rooms_mutex_);
auto it = rooms_.find(channel_id);
if (it != rooms_.end()) {
it->second->leave(session);
// 프로덕션에서는 빈 방 제거 로직 추가 (leave 콜백 등)
}
}
private:
asio::io_context& io_;
std::unordered_map<std::string, std::shared_ptr<ChatRoom>> rooms_;
std::mutex rooms_mutex_;
};
설명: rooms_mutex_는 rooms_ 맵 자체의 동시 접근만 보호합니다. 각 ChatRoom 내부의 participants_ 접근은 해당 Room의 strand가 직렬화합니다. 빈 방은 메모리 절약을 위해 제거합니다.
완전한 단일 파일 예제 (복사 후 바로 빌드 가능)
// chat_server_full.cpp - g++ -std=c++17 -O2 -pthread -o chat_server chat_server_full.cpp
#include <boost/asio.hpp>
#include <memory>
#include <set>
#include <deque>
#include <string>
#include <iostream>
#include <functional>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
using error_code = boost::system::error_code;
class Session;
class ChatRoom {
public:
explicit ChatRoom(asio::io_context& io)
: strand_(asio::make_strand(io)), max_history_(50) {}
void join(std::shared_ptr<Session> s) {
asio::post(strand_, [this, s]() { participants_.insert(s); });
}
void leave(std::shared_ptr<Session> s) {
asio::post(strand_, [this, s]() { participants_.erase(s); });
}
void deliver(const std::string& msg, std::shared_ptr<Session> sender) {
asio::post(strand_, [this, msg, sender]() {
history_.push_back(msg);
if (history_.size() > max_history_) history_.pop_front();
for (auto& p : participants_)
if (p != sender) p->deliver(msg);
});
}
void broadcast_join(const std::string& nick, std::shared_ptr<Session> newcomer) {
asio::post(strand_, [this, nick, newcomer]() {
std::string m = "[시스템] " + nick + "님이 입장했습니다.\n";
history_.push_back(m);
if (history_.size() > max_history_) history_.pop_front();
for (auto& p : participants_) p->deliver(m);
});
}
void broadcast_leave(const std::string& nick) {
asio::post(strand_, [this, nick]() {
std::string m = "[시스템] " + nick + "님이 퇴장했습니다.\n";
history_.push_back(m);
if (history_.size() > max_history_) history_.pop_front();
for (auto& p : participants_) p->deliver(m);
});
}
void send_history(std::shared_ptr<Session> s) {
asio::post(strand_, [this, s]() {
for (const auto& m : history_) s->deliver(m);
});
}
asio::strand<asio::io_context::executor_type>& strand() { return strand_; }
private:
asio::strand<asio::io_context::executor_type> strand_;
std::set<std::shared_ptr<Session>> participants_;
std::deque<std::string> history_;
size_t max_history_;
};
class Session : public std::enable_shared_from_this<Session> {
public:
Session(tcp::socket socket, ChatRoom& room)
: socket_(std::move(socket)), room_(room), strand_(room.strand()) {}
void start() { room_.join(shared_from_this()); do_read(); }
void deliver(const std::string& msg) {
if (write_queue_.size() >= 500) { socket_.close(); return; }
bool in_progress = !write_queue_.empty();
write_queue_.push_back(msg);
if (!in_progress) do_write();
}
private:
void do_read() {
asio::async_read_until(socket_, read_buf_, '\n',
asio::bind_executor(strand_, [self = shared_from_this()](
error_code ec, std::size_t) {
if (ec) { self->handle_error(ec); return; }
std::string line; std::getline(std::istream(&self->read_buf_), line);
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) { self->do_read(); return; }
if (line.substr(0, 5) == "NICK ") {
self->nickname_ = line.substr(5);
self->room_.broadcast_join(self->nickname_, self);
self->room_.send_history(self);
} else if (!self->nickname_.empty()) {
self->room_.deliver(self->nickname_ + ": " + line + "\n", self);
}
self->do_read();
}));
}
void do_write() {
if (write_queue_.empty()) return;
const std::string& msg = write_queue_.front();
asio::async_write(socket_, asio::buffer(msg),
asio::bind_executor(strand_, [self = shared_from_this()](
error_code ec, std::size_t) {
if (ec) { self->handle_error(ec); return; }
self->write_queue_.pop_front();
if (!self->write_queue_.empty()) self->do_write();
}));
}
void handle_error(error_code ec) {
if (ec == asio::error::eof || ec == asio::error::connection_reset) {
room_.leave(shared_from_this());
if (!nickname_.empty()) room_.broadcast_leave(nickname_);
}
}
tcp::socket socket_;
asio::streambuf read_buf_;
ChatRoom& room_;
asio::strand<asio::io_context::executor_type> strand_;
std::deque<std::string> write_queue_;
std::string nickname_;
};
int main() {
asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 9000));
ChatRoom room(io);
std::function<void()> do_accept;
do_accept = [&]() {
acceptor.async_accept([&](error_code ec, tcp::socket socket) {
if (!ec) std::make_shared<Session>(std::move(socket), room)->start();
do_accept();
});
};
do_accept();
std::cout << "Chat server listening on port 9000\n";
io.run();
return 0;
}
실행 방법: ./chat_server 후 nc localhost 9000으로 접속, NICK alice 입력 후 메시지 전송.
6. 메시지 히스토리
구현 요약
ChatRoom::history_:std::deque<std::string>로 최근 N개 메시지 저장.deliver,broadcast_join,broadcast_leave시history_.push_back()후max_history_초과 시pop_front().- 새 참가자 입장 시
send_history(session)으로history_를 순회하며session->deliver(msg)호출.
주의점
send_history는 strand에서 실행되므로,history_순회 중에participants_가 변경되어도 안전합니다.- 히스토리가 많으면 입장 시 전송 지연이 커질 수 있으므로,
max_history_를 50~200 정도로 제한하는 것이 좋습니다.
7. 일반적인 에러와 해결법
에러 1: 데이터 레이스 (Data Race)
증상: participants_ 순회 중 크래시, 또는 메시지가 일부 클라이언트에게만 전달됨.
원인: join, leave, deliver가 서로 다른 스레드에서 동시에 실행됨.
해결법:
// ✅ strand로 직렬화
void deliver(const std::string& message, std::shared_ptr<Session> sender) {
asio::post(strand_, [this, message, sender]() {
for (auto& p : participants_) {
if (p != sender) p->deliver(message);
}
});
}
에러 2: 메모리 누수 (Session이 소멸되지 않음)
증상: 클라이언트 연결을 끊어도 서버 메모리 사용량이 줄지 않음.
원인: participants_에 shared_ptr이 남아 있거나, 비동기 핸들러가 shared_ptr을 잡고 있어서 세션이 해제되지 않음.
해결법:
// ✅ leave 시 participants_에서 제거 → shared_ptr 참조 감소
void leave(std::shared_ptr<Session> session) {
asio::post(strand_, [this, session]() {
participants_.erase(session);
});
}
// ✅ 핸들러에서 shared_from_this()로 수명 유지
asio::async_read_until(socket_, read_buf_, '\n',
asio::bind_executor(strand_, [self = shared_from_this()](...) {
// self가 핸들러 실행 동안 세션을 유지
}));
에러 3: 순환 참조
증상: ChatRoom이 Session을 가지고, Session이 ChatRoom을 참조할 때, shared_ptr 사용 시 순환 참조 가능성.
해결법: Session은 ChatRoom&(참조)만 들고, ChatRoom만 shared_ptr<Session>을 보관합니다. Session이 ChatRoom을 shared_ptr로 들 필요는 없습니다.
에러 4: write_queue_ 무한 증가
증상: 느린 클라이언트 때문에 write_queue_가 계속 쌓여 메모리 부족.
해결법: 큐 크기 제한을 두고, 초과 시 해당 세션을 강제 퇴장시킵니다.
void deliver(const std::string& message) {
if (write_queue_.size() >= 1000) {
socket_.close();
return;
}
// ...
}
에러 5: async_read_until 버퍼 무한 증가 (DoS)
증상: 클라이언트가 \n 없이 대량 데이터 전송 시 read_buf_가 무한히 커짐.
원인: async_read_until(socket_, read_buf_, '\n')은 구분자가 나올 때까지 버퍼에 계속 쌓습니다.
해결법: 최대 읽기 크기 제한. 완료 핸들러에서 read_buf_.size() 검사.
void do_read() {
asio::async_read_until(socket_, read_buf_, '\n',
asio::bind_executor(strand_, [self = shared_from_this()](
error_code ec, std::size_t /*n*/) {
if (ec) { self->handle_error(ec); return; }
if (self->read_buf_.size() > 64 * 1024) { // 64KB 제한
self->socket_.close();
return;
}
// ... 파싱 후 self->do_read()
}));
}
또는 async_read로 고정 크기만 읽고, 애플리케이션 레벨에서 \n 파싱하는 방식도 가능합니다.
에러 6: shared_from_this() 생성자/소멸자에서 호출
증상: bad_weak_ptr 예외 또는 크래시.
원인: shared_from_this()는 객체가 shared_ptr로 관리될 때만 유효합니다. 생성자에서는 아직 shared_ptr이 없고, 소멸자에서는 이미 참조가 0입니다.
// ❌ 잘못된 예
Session(tcp::socket socket, ChatRoom& room) {
room_.join(shared_from_this()); // 💥 생성자에서 호출 불가!
}
// ✅ 올바른 예: start()에서 호출 (이미 make_shared로 생성된 후)
void start() {
room_.join(shared_from_this());
do_read();
}
에러 7: leave와 broadcast_leave 순서
증상: 퇴장 알림이 퇴장한 사용자에게도 전달되거나, 퇴장 알림이 누락됨.
해결법: leave를 먼저 호출하면 participants_에서 제거되므로, broadcast_leave의 participants_ 순회에 해당 세션은 포함되지 않습니다. 퇴장한 사용자에게도 알림을 보내려면 leave 전에 broadcast_leave를 호출할 수 있으나, broadcast_leave는 participants_ 전체에 보내므로 퇴장한 사용자도 포함됩니다. 일반적으로는 leave 후 broadcast_leave가 맞습니다 (퇴장한 사용자에게는 알림 불필요).
// ✅ 권장 순서
void handle_error(error_code ec) {
if (ec == asio::error::eof || ec == asio::error::connection_reset) {
room_.leave(shared_from_this()); // 1. 먼저 제거
room_.broadcast_leave(nickname_); // 2. 나머지에게 알림
}
}
8. 성능 벤치마크
테스트 환경 (예시)
- CPU: Apple M1 / Intel Xeon
- OS: macOS / Linux
- Boost.Asio 1.81, C++17
- 클라이언트: 동시 접속 N개, 초당 1메시지 전송
동시 접속자별 성능 (참고치)
| 동시 접속자 | 메시지 지연 (평균) | CPU 사용률 | 메모리 (서버) |
|---|---|---|---|
| 100 | ~1 ms | ~5% | ~20 MB |
| 1,000 | ~2 ms | ~15% | ~80 MB |
| 10,000 | ~5 ms | ~40% | ~500 MB |
| 50,000 | ~15 ms | ~80% | ~2 GB |
실제 수치는 하드웨어·OS·네트워크에 따라 다릅니다.
병목 지점
- strand 직렬화: 모든
deliver가 하나의 strand를 거치므로, 참가자 수가 많을수록 순차 처리 비용이 증가합니다. - async_write 체인: 세션당 한 번에 하나의
async_write만 걸기 때문에, 메시지가 많으면 큐 대기가 길어집니다. - 메모리:
participants_와write_queue_가 세션당 존재하므로, 동시 접속자 수에 비례해 증가합니다.
개선 방향
- 멀티스레드
io_context::run(): 여러 스레드가 이벤트를 처리하면 CPU 활용률이 올라갑니다. - Room 분할: 하나의 Room 대신 채널별로 Room을 나누면, strand 경합이 줄어듭니다.
- 메시지 배치: 여러 메시지를 하나의 버퍼로 합쳐서
async_write횟수를 줄입니다.
8.5 성능 최적화 팁
팁 1: 멀티스레드 io_context
asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 9000));
// ... acceptor 설정
std::vector<std::thread> threads;
unsigned num_threads = std::thread::hardware_concurrency();
for (unsigned i = 0; i < num_threads; ++i) {
threads.emplace_back([&io]() { io.run(); });
}
for (auto& t : threads) t.join();
팁 2: 메시지 배치
연속된 메시지를 하나의 async_write로 묶어서 시스템 콜 수를 줄입니다.
void deliver(const std::string& message) {
bool write_in_progress = !write_queue_.empty();
write_queue_.push_back(message);
if (!write_in_progress) {
do_write_batched();
}
}
void do_write_batched() {
if (write_queue_.empty()) return;
std::string batch;
while (!write_queue_.empty() && batch.size() < 4096) {
batch += write_queue_.front();
write_queue_.pop_front();
}
asio::async_write(socket_, asio::buffer(batch),
[self = shared_from_this()](error_code ec, std::size_t) {
if (ec) { self->handle_error(ec); return; }
if (!self->write_queue_.empty()) self->do_write_batched();
});
}
팁 3: SO_REUSEADDR / SO_REUSEPORT
acceptor_.open(tcp::v4());
acceptor_.set_option(asio::socket_base::reuse_address(true));
acceptor_.bind(endpoint);
acceptor_.listen();
팁 4: 스레드 풀 크기
- CPU 바운드가 적다면:
hardware_concurrency()또는 그 2배 - I/O 대기 많다면: 스레드 수를 더 늘려도 됨 (컨텍스트 스위칭 비용 고려)
팁 5: 히스토리 크기 제한
max_history_를 50~100으로 유지하면 입장 시 전송 지연과 메모리를 균형 잡을 수 있습니다.
9. 프로덕션 배포
체크리스트
- 로깅: 연결/해제, 에러, 메시지 수신/전송 이벤트 로깅 (구조화된 로그 권장)
- 에러 처리:
error_code검사, 예외 방지, 연결 끊김 시leave/broadcast_leave호출 - 리소스 제한: 세션당
write_queue_최대 크기, Room당 최대 참가자 수 - 타임아웃: 일정 시간 메시지가 없으면 연결 종료 (idle timeout)
- SSL/TLS: 프로덕션에서는
ssl_stream사용 (#30-2 SSL/TLS 참고) - 모니터링: 동시 접속자 수, 메시지 처리량, 에러율, 메모리 사용량
systemd 서비스 예시
[Unit]
Description=Chat Server
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/chat_server
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Docker 예시
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y libboost-all-dev
COPY chat_server /usr/local/bin/
EXPOSE 9000
CMD ["/usr/local/bin/chat_server"]
환경 변수
# 포트, 스레드 수, 로그 레벨 등
CHAT_PORT=9000
CHAT_THREADS=4
CHAT_LOG_LEVEL=info
빌드 및 실행
# Boost.Asio 포함 (헤더만 필요)
g++ -std=c++17 -O2 -pthread -o chat_server chat_server.cpp
# 실행
./chat_server
# 다른 터미널에서 netcat으로 테스트
nc localhost 9000
프로덕션 패턴
| 패턴 | 설명 | 적용 시점 |
|---|---|---|
| 헬스체크 엔드포인트 | /health 등으로 프로세스 생존 확인 | 로드밸런서 연동 시 |
| Graceful Shutdown | 새 연결 수락 중단 → 기존 세션 정리 → io_context 종료 | 배포·재시작 시 |
| 연결 수 제한 | participants_.size() 상한 두기 | DoS 방지 |
| 메시지 크기 제한 | 한 메시지 최대 바이트 수 (예: 4KB) | 버퍼 폭탄 방지 |
| Idle Timeout | steady_timer로 N초간 메시지 없으면 연결 종료 | 좀비 연결 정리 |
| 로깅 레벨 | 개발: debug, 프로덕션: info/warn | 운영 디버깅 |
| 메트릭 수집 | Prometheus/StatsD로 동시 접속자, 메시지/초 수집 | 모니터링 |
Graceful Shutdown 예시
class Server {
asio::io_context& io_;
tcp::acceptor acceptor_;
ChatRoom room_;
asio::signal_set signals_;
public:
Server(asio::io_context& io, const tcp::endpoint& ep)
: io_(io), acceptor_(io, ep), room_(io),
signals_(io, SIGINT, SIGTERM) {
signals_.async_wait([this](error_code, int) {
acceptor_.close();
io_.stop();
});
}
};
Idle Timeout 예시
// Session에 asio::steady_timer deadline_; 멤버 추가
void Session::do_read() {
deadline_.expires_after(std::chrono::seconds(300));
deadline_.async_wait([self = shared_from_this()](error_code ec) {
if (!ec) self->socket_.close(); // 5분간 무응답 시 종료
});
asio::async_read_until(socket_, read_buf_, '\n', ...);
}
// 메시지 수신 시 deadline_.cancel() 호출 후 다시 expires_after 설정
10. 정리와 다음 단계
정리
| 항목 | 내용 |
|---|---|
| 세션 | 연결당 하나, async_read → deliver → async_read |
| ChatRoom | participants_, history_, deliver(나 제외 브로드캐스트) |
| strand | join, leave, deliver를 strand에서 직렬화해 데이터 레이스 방지 |
| 입장/퇴장 | broadcast_join, broadcast_leave로 시스템 메시지 전송 |
| 메시지 히스토리 | deque로 최근 N개 유지, send_history로 새 참가자에게 전달 |
| 수명 관리 | shared_ptr + leave 시 participants_에서 제거 |
이 글에서 다루는 키워드 (관련 검색어)
C++ 채팅 서버, 실시간 채팅, Asio 브로드캐스트, strand 동기화 등으로 검색하시면 이 글이 도움이 됩니다.
다음 글
REST API 서버(#31-2)에서 HTTP 기반 API 서버 구현을 다룹니다.
이전 글
프로토콜 설계와 직렬화(#30-3)에서 메시지 경계와 직렬화 포맷을 다룹니다.
한 줄 요약: strand로 동기화한 ChatRoom·Session 구조로, 입장/퇴장 알림·메시지 히스토리·데이터 레이스 방지까지 포함한 채팅 서버를 구현할 수 있습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
- C++ 프로토콜 설계와 직렬화 | TCP 메시지 경계·길이 프리픽스·바이너리 포맷 완벽 가이드 [#30-3]
- C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 채팅 서버에서 메시지 브로드캐스트가 복잡하다면? Asio로 여러 클라이언트를 받고, strand로 동기화하며, 입장/퇴장 알림·메시지 히스토리·성능 벤치마크·프로덕션 배포까지 실전 구현합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
관련 글
- C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]
- C++ 데이터베이스 연동 완벽 가이드 | SQLite·PostgreSQL·연결 풀·트랜잭션 [#31-3]
- C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]
- C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]
- C++ 채팅 서버 완성하기 | 인증·방 관리·메시지 히스토리 구현 [#50-1]