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

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

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

이 글의 핵심

- 클라이언트 A가 보낸 메시지를 B, C, D에게 동시에 전달해야 하는데, 참가자 목록을 순회하면서 async_write를 걸면 데이터 레이스가 발생할 수 있어요. - 새 사용자가 입장했을 때 기존 참가자들에게 OO님이… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다.

들어가며: “채팅 서버에서 메시지 브로드캐스트가 복잡해요”

문제 시나리오

채팅 서버를 만들다 보면 이런 고민이 생깁니다:

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

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

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 구조로, 입장/퇴장 알림·메시지 히스토리·데이터 레이스 방지까지 포함한 채팅 서버를 구현할 수 있습니다.

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 채팅 서버에서 메시지 브로드캐스트가 복잡하다면? Asio로 여러 클라이언트를 받고, strand로 동기화하며, 입장/퇴장 알림·메시지 히스토리·성능 벤치마크·프로덕션 배포까지 실전 구현합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ 채팅 서버 만들기 | 다중 클라이언트와 메시지 브로드캐스트 완벽 가이드 [#31-1]」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 채팅 서버 만들기 | 다중 클라이언트와 메시지 브로드캐스트 완벽 가이드 [#31-1]」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.