C++ 채팅 서버 만들기 | 다중 클라이언트와 메시지 브로드캐스트 완벽 가이드 [#31-1]

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는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.


목차

  1. 전체 구조와 아키텍처
  2. ChatRoom 완전 구현
  3. Session 완전 구현
  4. strand를 이용한 메시지 브로드캐스트
  5. 입장/퇴장 알림
  6. 메시지 히스토리
  7. 일반적인 에러와 해결법
  8. 성능 벤치마크
  9. 성능 최적화 팁
  10. 프로덕션 배포
  11. 정리와 다음 단계

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 + acceptorasync_accept로 새 연결 수락
Session (shared_ptr)소켓, 버퍼, strand. Room에 등록
ChatRoom참가자 목록, 메시지 히스토리, deliver() 시 나를 제외한 모든 참가자에게 async_write
strandjoin, 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. 입장/퇴장 알림

입장 시

  1. Session::start()에서 room_.join(shared_from_this()) 호출.
  2. 클라이언트가 "NICK alice\n"를 보내면, 서버가 nickname_을 설정.
  3. room_.broadcast_join(nickname_, shared_from_this()) 호출 → “[시스템] alice님이 입장했습니다.” 브로드캐스트.
  4. room_.send_history(shared_from_this()) 호출 → 최근 메시지 히스토리 전송.

퇴장 시

  1. async_read_until 또는 async_write 완료 시 error_codeeof 또는 connection_reset.
  2. handle_error()에서 room_.leave(shared_from_this())room_.broadcast_leave(nickname_) 호출.
  3. 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_servernc localhost 9000으로 접속, NICK alice 입력 후 메시지 전송.


6. 메시지 히스토리

구현 요약

  • ChatRoom::history_: std::deque<std::string>로 최근 N개 메시지 저장.
  • deliver, broadcast_join, broadcast_leavehistory_.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: 순환 참조

증상: ChatRoomSession을 가지고, SessionChatRoom을 참조할 때, shared_ptr 사용 시 순환 참조 가능성.

해결법: SessionChatRoom&(참조)만 들고, ChatRoomshared_ptr<Session>을 보관합니다. SessionChatRoomshared_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_leaveparticipants_ 순회에 해당 세션은 포함되지 않습니다. 퇴장한 사용자에게도 알림을 보내려면 leave 전에 broadcast_leave를 호출할 수 있으나, broadcast_leaveparticipants_ 전체에 보내므로 퇴장한 사용자도 포함됩니다. 일반적으로는 leavebroadcast_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·네트워크에 따라 다릅니다.

병목 지점

  1. strand 직렬화: 모든 deliver가 하나의 strand를 거치므로, 참가자 수가 많을수록 순차 처리 비용이 증가합니다.
  2. async_write 체인: 세션당 한 번에 하나의 async_write만 걸기 때문에, 메시지가 많으면 큐 대기가 길어집니다.
  3. 메모리: 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 Timeoutsteady_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_readdeliverasync_read
ChatRoomparticipants_, history_, deliver(나 제외 브로드캐스트)
strandjoin, leave, deliver를 strand에서 직렬화해 데이터 레이스 방지
입장/퇴장broadcast_join, broadcast_leave로 시스템 메시지 전송
메시지 히스토리deque로 최근 N개 유지, send_history로 새 참가자에게 전달
수명 관리shared_ptr + leaveparticipants_에서 제거

이 글에서 다루는 키워드 (관련 검색어)

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]