C++ 채팅 서버 아키텍처: Acceptor-Worker 구조, 방 관리, 메시지 라우팅, 커넥션 풀

들어가며: “채팅 서버 아키텍처를 어떻게 설계해야 할까요?”

설계할 때 먼저 부딪히는 질문

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

  • 동시 접속 1만 명을 처리하려면 단일 io_context로 충분할까요? 아니면 Acceptor-Worker 구조가 필요한가요?

  • 채널이 100개일 때 방별 참가자 목록을 어떻게 관리하며, 메시지를 어떤 경로로 전달할까요?

  • 느린 클라이언트 1명이 전체 채팅을 지연시키지 않으려면 어떻게 해야 할까요?

  • 연결 폭주 시 서버가 다운되지 않으려면 커넥션 풀이나 Rate Limiter가 필요한가요? 채팅 서버 아키텍처의 핵심은 Acceptor-Worker 패턴(연결 수락과 처리 분리), 방 관리(채널별 참가자·히스토리), 메시지 라우팅(발신자 제외 브로드캐스트), 커넥션 풀(연결 수 제한·재사용)을 올바르게 조합하는 것입니다. 목표:

  • Acceptor-Worker 아키텍처로 연결 수락과 비즈니스 로직 분리

  • 방 관리 시스템 (채널 생성/삭제, 참가자 입퇴장)

  • 메시지 라우팅 (방별 브로드캐스트, DM 지원)

  • 커넥션 풀 (연결 수 제한, 세션 재사용)

  • 일반적인 에러와 해결법

  • 프로덕션 패턴 (모니터링, 스케일 아웃) 요구 환경: C++17 이상, Boost.Asio 1.70+


동시 접속 폭주·데이터 레이스·느린 클라이언트

동시 접속 폭주로 서버 다운

증상: 새 사용자가 대량으로 접속할 때 서버가 응답하지 않거나 크래시합니다. 원인: 단일 스레드에서 async_accept와 모든 I/O를 처리하면, 연결 수락 자체가 병목이 됩니다. 또한 io_context::run()이 블로킹되면 새 연결 수락이 지연됩니다. 해결: Acceptor를 전용 스레드에서 돌리고, Worker 스레드 풀에서 세션 I/O를 처리합니다.

100명이 동시에 메시지 전송 시 데이터 레이스

증상: 서버 크래시, 메시지 누락, iterator 무효화 에러. 원인: participants_를 순회하는 동안 다른 스레드가 leave()를 호출해 컨테이너가 수정됩니다. 해결: strand로 join, leave, deliver를 직렬화하거나, shared_ptr 기반 세션 수명 관리.

느린 클라이언트 1명이 전체 채팅 지연

증상: 한 사용자의 네트워크가 느리면 다른 사용자들도 메시지 수신이 늦어집니다. 원인: write_queue_가 무한 증가하며, 브로드캐스트 시 모든 세션에 순차적으로 async_write를 걸어 한 세션의 지연이 전체에 영향을 줍니다. 해결: write_queue_ 크기 제한, 느린 클라이언트 강제 퇴장, 비블로킹 전송.

채널별 메시지가 섞임

증상: #general과 #random 메시지가 한곳에 섞여 전달됩니다. 원인: 단일 Room 구조로 모든 참가자를 한 목록에 관리합니다. 해결: 채널 ID별로 Room을 분리하며, 세션이 현재 속한 방만 대상으로 메시지 라우팅.

연결 수 제한 없이 메모리 폭발

증상: DDoS나 스파이크 트래픽 시 연결이 무한 증가해 OOM이 발생합니다. 원인: async_accept가 제한 없이 새 연결을 수락합니다. 해결: 커넥션 풀로 동시 연결 수를 제한하며, 초과 시 대기 또는 거부합니다.

시나리오 요약표

시나리오증상원인해결
동시 접속 폭주서버 다운단일 스레드 병목Acceptor-Worker
메시지 전송 시 크래시데이터 레이스participants_ 동시 수정strand 직렬화
느린 클라이언트전체 지연write_queue 무한 증가큐 크기 제한, 강제 퇴장
채널 메시지 혼합잘못된 방에 전달단일 Room채널별 Room 분리
연결 폭주OOM무제한 accept커넥션 풀, Rate Limiter

Acceptor-Worker 아키텍처

아키텍처 다이어그램

flowchart TB
    subgraph Clients[클라이언트]
        C1[클라이언트 1]
        C2[클라이언트 2]
        C3[클라이언트 N]
    end
    subgraph Acceptor[Acceptor 스레드]
        A[async_accept]
        A -->|새 소켓| Q[소켓 큐]
    end
    subgraph Workers[Worker 스레드 풀]
        W1[Worker 1]
        W2[Worker 2]
        W3[Worker 3]
        W4[Worker 4]
    end
    subgraph Sessions[세션들]
        S1[Session A]
        S2[Session B]
        S3[Session C]
    end
    C1 --> A
    C2 --> A
    C3 --> A
    Q --> W1
    Q --> W2
    Q --> W3
    Q --> W4
    W1 --> S1
    W2 --> S2
    W3 --> S3

Acceptor-Worker 패턴 개요

Acceptor: 새 TCP 연결만 수락하며, 소켓을 Worker 풀에 전달합니다. 연결 수락 자체는 가벼운 작업이므로 단일 스레드로 충분합니다. Worker: 전달받은 소켓으로 Session을 생성하며, async_read/async_write를 처리합니다. CPU 코어 수만큼 Worker 스레드를 두어 병렬 처리를 극대화합니다.

Acceptor 구현

#include <boost/asio.hpp>
#include <queue>
#include <mutex>
#include <condition_variable>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
class Acceptor {
public:
    Acceptor(asio::io_context& io, const tcp::endpoint& endpoint)
        : acceptor_(io, endpoint)
        , io_(io) {}
    void start() {
        do_accept();
    }
    // Worker가 호출: 새 소켓 대기 (블로킹)
    tcp::socket wait_for_socket() {
        std::unique_lock lock(mutex_);
        cv_.wait(lock, [this] { return !socket_queue_.empty(); });
        tcp::socket sock = std::move(socket_queue_.front());
        socket_queue_.pop();
        return sock;
    }
private:
    void do_accept() {
        acceptor_.async_accept(
            [this](boost::system::error_code ec, tcp::socket socket) {
                if (!ec) {
                    {
                        std::lock_guard lock(mutex_);
                        socket_queue_.push(std::move(socket));
                    }
                    cv_.notify_one();
                }
                do_accept();
            });
    }
    tcp::acceptor acceptor_;
    asio::io_context& io_;
    std::queue<tcp::socket> socket_queue_;
    std::mutex mutex_;
    std::condition_variable cv_;
};

Worker 풀 구현

class WorkerPool {
public:
    WorkerPool(Acceptor& acceptor, size_t num_workers)
        : acceptor_(acceptor)
        , num_workers_(num_workers) {}
    void start() {
        for (size_t i = 0; i < num_workers_; ++i) {
            workers_.emplace_back([this] { worker_loop(); });
        }
    }
    void join() {
        for (auto& w : workers_) {
            if (w.joinable()) w.join();
        }
    }
private:
    void worker_loop() {
        while (true) {
            tcp::socket socket = acceptor_.wait_for_socket();
            // 각 Worker는 자신만의 io_context를 가짐
            asio::io_context io;
            auto session = std::make_shared<Session>(std::move(socket), io);
            session->start();
            io.run();  // 이 연결의 I/O가 끝날 때까지 실행
        }
    }
    Acceptor& acceptor_;
    size_t num_workers_;
    std::vector<std::thread> workers_;
};

io_context 풀 방식 (대안)

Acceptor와 Worker가 같은 io_context 풀을 공유하는 방식입니다. 연결 수락 후 소켓을 풀의 아무 io_context에 포스트합니다.

class AcceptorWithIOPool {
public:
    AcceptorWithIOPool(const tcp::endpoint& endpoint, size_t pool_size)
        : acceptor_(*io_contexts_[0], endpoint)
        , pool_size_(pool_size)
        , next_io_(0) {
        for (size_t i = 0; i < pool_size; ++i) {
            io_contexts_.push_back(std::make_shared<asio::io_context>());
        }
    }
    void run() {
        std::vector<std::thread> threads;
        for (auto& io : io_contexts_) {
            threads.emplace_back([&io] { io->run(); });
        }
        do_accept();
        for (auto& t : threads) {
            t.join();
        }
    }
private:
    void do_accept() {
        acceptor_.async_accept(
            [this](boost::system::error_code ec, tcp::socket socket) {
                if (!ec) {
                    auto& io = *io_contexts_[next_io_ % pool_size_];
                    next_io_++;
                    asio::post(io, [socket = std::move(socket), &io, this]() mutable {
                        auto session = std::make_shared<Session>(std::move(socket), room_);
                        session->start();
                    });
                }
                do_accept();
            });
    }
    tcp::acceptor acceptor_;
    std::vector<std::shared_ptr<asio::io_context>> io_contexts_;
    size_t pool_size_;
    size_t next_io_;
    ChatRoom& room_;
};

RoomManager로 채널별 방 관리

방 관리 아키텍처

flowchart TB
    subgraph RoomManager[RoomManager]
        RM[rooms_ map]
    end
    subgraph Rooms[채널별 Room]
        R1[Room #general]
        R2[Room #random]
        R3[Room #dev]
    end
    subgraph Participants[참가자]
        P1[Session A]
        P2[Session B]
        P3[Session C]
    end
    RM --> R1
    RM --> R2
    RM --> R3
    R1 --> P1
    R1 --> P2
    R2 --> P3

RoomManager 구현

#include <unordered_map>
#include <shared_mutex>
class RoomManager {
public:
    RoomManager(asio::io_context& io)
        : io_(io) {}
    std::shared_ptr<ChatRoom> get_or_create(const std::string& channel_id) {
        std::unique_lock lock(mutex_);
        auto it = rooms_.find(channel_id);
        if (it == rooms_.end()) {
            it = rooms_.emplace(
                channel_id,
                std::make_shared<ChatRoom>(io_, channel_id)
            ).first;
        }
        return it->second;
    }
    void remove_if_empty(const std::string& channel_id) {
        std::unique_lock lock(mutex_);
        auto it = rooms_.find(channel_id);
        if (it != rooms_.end() && it->second->participant_count() == 0) {
            rooms_.erase(it);
        }
    }
    std::vector<std::string> list_channels() const {
        std::shared_lock lock(mutex_);
        std::vector<std::string> result;
        for (const auto& [id, room] : rooms_) {
            if (room->participant_count() > 0) {
                result.push_back(id);
            }
        }
        return result;
    }
private:
    asio::io_context& io_;
    std::unordered_map<std::string, std::shared_ptr<ChatRoom>> rooms_;
    mutable std::shared_mutex mutex_;
};

ChatRoom 확장 (채널 ID 지원)

class ChatRoom {
public:
    ChatRoom(asio::io_context& io, const std::string& channel_id)
        : strand_(asio::make_strand(io))
        , channel_id_(channel_id)
        , max_history_(100) {}
    void join(std::shared_ptr<Session> session) {
        asio::post(strand_, [this, session]() {
            participants_.insert(session);
            session->set_current_room(channel_id_);
        });
    }
    void leave(std::shared_ptr<Session> session) {
        asio::post(strand_, [this, session]() {
            participants_.erase(session);
            session->clear_current_room();
        });
    }
    size_t participant_count() const {
        return participants_.size();
    }
    // deliver, broadcast_join, broadcast_leave, send_history 등
    // (기존 ChatRoom과 동일)
private:
    asio::strand<asio::io_context::executor_type> strand_;
    std::string channel_id_;
    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:
    void join_room(const std::string& channel_id) {
        auto room = room_manager_.get_or_create(channel_id);
        room->join(shared_from_this());
        current_rooms_.insert(channel_id);
    }
    void leave_room(const std::string& channel_id) {
        auto room = room_manager_.get_or_create(channel_id);
        room->leave(shared_from_this());
        current_rooms_.erase(channel_id);
        room_manager_.remove_if_empty(channel_id);
    }
    void leave_all_rooms() {
        for (const auto& ch : current_rooms_) {
            auto room = room_manager_.get_or_create(ch);
            room->leave(shared_from_this());
            room_manager_.remove_if_empty(ch);
        }
        current_rooms_.clear();
    }
private:
    RoomManager& room_manager_;
    std::set<std::string> current_rooms_;
};

방 브로드캐스트와 DM 라우팅

메시지 라우팅 흐름

sequenceDiagram
    participant S1 as Session A
    participant Router as MessageRouter
    participant RM as RoomManager
    participant R1 as Room #general
    participant S2 as Session B
    participant S3 as Session C
    S1->>Router: route("general", "안녕", sender=A)
    Router->>RM: get_or_create("general")
    RM->>R1: Room 반환
    Router->>R1: deliver("안녕", sender=A)
    R1->>S2: deliver("안녕")
    R1->>S3: deliver("안녕")

MessageRouter 구현

class MessageRouter {
public:
    MessageRouter(RoomManager& room_mgr)
        : room_mgr_(room_mgr) {}
    // 방에 메시지 브로드캐스트 (발신자 제외)
    void broadcast_to_room(
        const std::string& channel_id,
        const std::string& message,
        std::shared_ptr<Session> sender
    ) {
        auto room = room_mgr_.get_or_create(channel_id);
        room->deliver(message, sender);
    }
    // DM (Direct Message): 특정 사용자에게만 전송
    void send_dm(
        const std::string& target_user_id,
        const std::string& message,
        std::shared_ptr<Session> sender
    ) {
        auto session = session_registry_.get(target_user_id);
        if (session) {
            session->deliver(message);
        }
    }
    // 시스템 메시지: 특정 방의 모든 참가자에게
    void broadcast_system(
        const std::string& channel_id,
        const std::string& system_message
    ) {
        auto room = room_mgr_.get_or_create(channel_id);
        room->broadcast_system(system_message);
    }
private:
    RoomManager& room_mgr_;
    SessionRegistry& session_registry_;
};

프로토콜 기반 라우팅

클라이언트 메시지 형식에 따라 라우팅 대상을 결정합니다.

// 프로토콜: JOIN <channel>, LEAVE <channel>, MSG <channel> <content>, DM <user_id> <content>
void Session::handle_message(const std::string& raw) {
    std::istringstream iss(raw);
    std::string cmd;
    iss >> cmd;
    if (cmd == "JOIN") {
        std::string channel;
        iss >> channel;
        join_room(channel);
    } else if (cmd == "LEAVE") {
        std::string channel;
        iss >> channel;
        leave_room(channel);
    } else if (cmd == "MSG") {
        std::string channel, content;
        iss >> channel;
        std::getline(iss, content);
        if (!content.empty()) content = content.substr(1);  // 앞 공백 제거
        router_.broadcast_to_room(channel, nickname_ + ": " + content + "\n", shared_from_this());
    } else if (cmd == "DM") {
        std::string target_id, content;
        iss >> target_id;
        std::getline(iss, content);
        if (!content.empty()) content = content.substr(1);
        router_.send_dm(target_id, "[DM] " + nickname_ + ": " + content + "\n", shared_from_this());
    }
}

ConnectionLimiter로 연결 수 제한

커넥션 풀의 목적

  • 동시 연결 수 제한: 서버 리소스 보호
  • 대기 큐: 제한 초과 시 연결을 거부하거나 대기시킴
  • 모니터링: 현재 연결 수, 대기 수 추적

ConnectionLimiter 구현

class ConnectionLimiter {
public:
    explicit ConnectionLimiter(size_t max_connections)
        : max_connections_(max_connections)
        , current_connections_(0) {}
    bool try_acquire() {
        std::lock_guard lock(mutex_);
        if (current_connections_ >= max_connections_) {
            return false;
        }
        ++current_connections_;
        return true;
    }
    void release() {
        std::lock_guard lock(mutex_);
        if (current_connections_ > 0) {
            --current_connections_;
        }
    }
    size_t current_count() const {
        std::lock_guard lock(mutex_);
        return current_connections_;
    }
private:
    size_t max_connections_;
    size_t current_connections_;
    mutable std::mutex mutex_;
};

Acceptor에 연결 제한 적용

void do_accept() {
    acceptor_.async_accept(
        [this](boost::system::error_code ec, tcp::socket socket) {
            if (!ec) {
                if (!limiter_.try_acquire()) {
                    // 연결 거부: "서버 만료" 응답 후 소켓 닫기
                    std::string msg = "ERROR: Server at capacity\n";
                    asio::write(socket, asio::buffer(msg));
                    socket.close();
                } else {
                    auto session = std::make_shared<Session>(
                        std::move(socket), room_,
                        [this]() { limiter_.release(); }  // 퇴장 시 release
                    );
                    session->start();
                }
            }
            do_accept();
        });
}

Session에 release 콜백 추가

class Session : public std::enable_shared_from_this<Session> {
public:
    using ReleaseCallback = std::function<void()>;
    Session(tcp::socket socket, ChatRoom& room, ReleaseCallback on_release)
        : socket_(std::move(socket))
        , room_(room)
        , on_release_(std::move(on_release)) {}
    void handle_error(boost::system::error_code ec) {
        if (ec == asio::error::eof || ec == asio::error::connection_reset) {
            room_.leave(shared_from_this());
            room_.broadcast_leave(nickname_);
            if (on_release_) {
                on_release_();
            }
        }
    }
private:
    ReleaseCallback on_release_;
};

대기 큐 방식 (선택)

연결 수 초과 시 즉시 거부 대신 대기 큐에 넣어 짧은 시간 후 재시도할 수 있게 합니다. try_acquire가 Ok, Rejected, Queued 중 하나를 반환하며, Queued일 때는 별도 스레드가 연결 해제 시 큐에서 꺼내 처리합니다.


Acceptor·RoomManager·Router를 묶은 통합 서버

전체 구조 다이어그램

flowchart TB
    subgraph Clients[클라이언트]
        C1[C1]
        C2[C2]
        C3[C3]
    end
    subgraph Server[채팅 서버]
        subgraph Acceptor[Acceptor]
            A[async_accept]
            LIM[ConnectionLimiter]
        end
        subgraph IOPool[io_context 풀]
            IO1[io_context 1]
            IO2[io_context 2]
            IO3[io_context 3]
            IO4[io_context 4]
        end
        subgraph Core[핵심 컴포넌트]
            RM[RoomManager]
            MR[MessageRouter]
        end
        subgraph Rooms[Room 인스턴스]
            R1[#general]
            R2[#random]
        end
    end
    C1 --> A
    C2 --> A
    C3 --> A
    A --> LIM
    LIM --> IOPool
    IOPool --> Core
    RM --> R1
    RM --> R2
    MR --> R1
    MR --> R2

통합 서버 클래스

class ChatServerArchitecture {
public:
    ChatServerArchitecture(
        const tcp::endpoint& endpoint,
        size_t io_pool_size = 4,
        size_t max_connections = 10000
    )
        : limiter_(max_connections)
        , room_mgr_(main_io_)
        , router_(room_mgr_)
        , pool_size_(io_pool_size)
        , next_io_(0) {
        for (size_t i = 0; i < io_pool_size; ++i) {
            io_pools_.push_back(std::make_shared<asio::io_context>());
        }
        acceptor_ = std::make_unique<tcp::acceptor>(*io_pools_[0], endpoint);
    }
    void run() {
        std::vector<std::thread> threads;
        for (auto& io : io_pools_) {
            threads.emplace_back([&io] { io->run(); });
        }
        do_accept();
        for (auto& t : threads) {
            t.join();
        }
    }
private:
    void do_accept() {
        acceptor_->async_accept(
            [this](boost::system::error_code ec, tcp::socket socket) {
                if (!ec) {
                    if (!limiter_.try_acquire()) {
                        socket.close();
                    } else {
                        auto& io = *io_pools_[next_io_ % pool_size_];
                        next_io_++;
                        auto room = room_mgr_.get_or_create("general");
                        auto session = std::make_shared<Session>(
                            std::move(socket), *room, room_mgr_, router_,
                            [this]() { limiter_.release(); }
                        );
                        asio::post(io, [session]() mutable {
                            session->start();
                        });
                    }
                }
                do_accept();
            });
    }
    ConnectionLimiter limiter_;
    asio::io_context main_io_;
    std::unique_ptr<tcp::acceptor> acceptor_;
    std::vector<std::shared_ptr<asio::io_context>> io_pools_;
    size_t pool_size_;
    size_t next_io_;
    RoomManager room_mgr_;
    MessageRouter router_;
};

빌드 및 실행

# 빌드 (Boost.Asio 필요)
g++ -std=c++17 -O2 -pthread -o chat_server_arch \
    chat_server_architecture.cpp \
    -lboost_system
# 실행
./chat_server_arch
# 테스트 (다른 터미널에서)
nc localhost 9000
NICK alice
JOIN general
MSG general 안녕하세요

이터레이터 무효화, 메시지 순서, Room 누수: 에러 해결

”iterator invalidation” / “vector subscript out of range”

원인: participants_를 순회하는 동안 leave()가 호출되어 컨테이너가 수정됩니다. 해결:

// ❌ 잘못된 코드
for (auto& p : participants_) {
    p->deliver(msg);  // deliver 내부에서 leave 호출 가능 → iterator 무효화
}
// ✅ 올바른 코드: 복사본 순회
auto copy = participants_;
for (auto& p : copy) {
    if (participants_.count(p)) {  // 아직 남아있는지 확인
        p->deliver(msg);
    }
}

또는 strand로 모든 수정을 직렬화합니다.

”Bad file descriptor” / “Connection reset by peer”

원인: 세션이 소멸된 후에도 async_write 완료 핸들러가 실행되거나, 이미 닫힌 소켓에 쓰기를 시도합니다. 해결:

// ✅ shared_from_this()로 수명 유지
void do_write() {
    asio::async_write(socket_, asio::buffer(msg),
        [self = shared_from_this()](error_code ec, size_t) {
            if (ec) {
                self->handle_error(ec);
                return;
            }
            // self가 살아있는 동안만 접근
        });
}

“Address already in use”

원인: 이전 서버 프로세스가 종료되지 않았거나, TIME_WAIT 상태의 소켓이 포트를 점유합니다. 해결:

// ✅ SO_REUSEADDR 설정
acceptor_.open(endpoint.protocol());
acceptor_.set_option(asio::socket_base::reuse_address(true));
acceptor_.bind(endpoint);
acceptor_.listen();

“메시지 순서 뒤바뀜”

원인: 여러 스레드에서 같은 세션의 async_write를 동시에 걸어 순서가 보장되지 않습니다. 해결: write_queue를 두며, 한 번에 하나의 async_write만 실행합니다.

void deliver(const std::string& msg) {
    bool in_progress = !write_queue_.empty();
    write_queue_.push_back(msg);
    if (!in_progress) {
        do_write();
    }
}
void do_write() {
    if (write_queue_.empty()) return;
    const auto& msg = write_queue_.front();
    asio::async_write(socket_, asio::buffer(msg),
        [self = shared_from_this()](error_code ec, size_t) {
            self->write_queue_.pop_front();
            if (!self->write_queue_.empty()) {
                self->do_write();
            }
        });
}

”메모리 누수 - Room이 삭제되지 않음”

원인: 참가자가 0명이 되어도 rooms_에서 제거하지 않습니다. 해결: leave() 시 remove_if_empty() 호출.

void leave(std::shared_ptr<Session> session) {
    asio::post(strand_, [this, session]() {
        participants_.erase(session);
        if (participants_.empty()) {
            room_manager_.remove_if_empty(channel_id_);
        }
    });
}

”async_read_until으로 DoS 공격”

원인: async_read_until(socket, buf, '\n')은 \n이 올 때까지 무제한 버퍼링합니다. 악의적 클라이언트가 \n 없이 대량 데이터를 보내면 메모리 폭발합니다. 해결: 최대 메시지 크기 제한.

asio::async_read_until(socket_, read_buf_, '\n',
    asio::transfer_at_least(1),
    [self = shared_from_this()](error_code ec, size_t n) {
        if (self->read_buf_.size() > 64 * 1024) {  // 64KB 제한
            self->socket_.close();
            return;
        }
        // ...
    });

strand·shared_ptr·큐 크기 제한 원칙

strand로 공유 상태 보호

participants_, history_ 등 공유 컨테이너는 반드시 같은 strand에서만 접근합니다.

void deliver(const std::string& msg, std::shared_ptr<Session> sender) {
    asio::post(strand_, [this, msg, sender]() {
        history_.push_back(msg);
        for (auto& p : participants_) {
            if (p != sender) p->deliver(msg);
        }
    });
}

shared_ptr로 세션 수명 관리

비동기 연산 완료 시점에 세션이 살아있어야 하므로 shared_from_this()를 캡처합니다.

asio::async_read_until(socket_, read_buf_, '\n',
    asio::bind_executor(strand_, [self = shared_from_this()](error_code ec, size_t) {
        if (ec) { self->handle_error(ec); return; }
        self->process_message();
        self->do_read();
    }));

write_queue 크기 제한

느린 클라이언트가 전체를 지연시키지 않도록 큐 크기를 제한합니다.

void deliver(const std::string& msg) {
    if (write_queue_.size() >= 500) {
        socket_.close();  // 강제 퇴장
        return;
    }
    // ...
}

메시지 크기 제한

단일 메시지가 너무 크면 DoS에 취약합니다. 4KB~64KB 정도로 제한합니다.

로깅 및 메트릭

연결 수, 방 수, 메시지 처리량을 주기적으로 로깅합니다.

void log_metrics() {
    spdlog::info("Connections: {}, Rooms: {}, Messages/sec: {}",
        limiter_.current_count(),
        room_mgr_.room_count(),
        messages_per_sec_);
}

헬스 체크·Graceful Shutdown·백프레셔

헬스 체크 엔드포인트

로드 밸런서나 오케스트레이터가 서버 상태를 확인할 수 있도록 합니다.

// 별도 포트(예: 9001)에서 HTTP 헬스 체크 수신
void run_health_check_server(uint16_t port) {
    asio::io_context io;
    tcp::acceptor acc(io, tcp::endpoint(tcp::v4(), port));
    tcp::socket sock(io);
    acc.async_accept(sock, [&](error_code ec) {
        if (!ec) {
            std::string response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK";
            asio::write(sock, asio::buffer(response));
        }
    });
    io.run();
}

Graceful Shutdown

새 연결 수락을 중단하며, 기존 연결이 정상 종료될 때까지 대기합니다.

void shutdown() {
    acceptor_.close();
    for (auto& session : all_sessions_) {
        session->close();
    }
    io_.stop();
}

수평 확장 (멀티 서버)

여러 채팅 서버 인스턴스 앞에 메시지 브로커(Redis Pub/Sub, RabbitMQ)를 두어, 서버 간 메시지를 동기화합니다.

[Client A] ---> [Server 1] ---> [Redis Pub/Sub] ---> [Server 2] ---> [Client B]

백프레셔 (Backpressure)

클라이언트가 메시지를 너무 빠르게 보낼 때, 서버가 “천천히 보내라”는 신호를 보냅니다.

if (messages_received_per_second_ > 100) {
    send_to_client("RATE_LIMIT: slow down");
    return;
}

모니터링 대시보드

Prometheus + Grafana로 연결 수, 방 수, 지연 시간, 에러율을 시각화합니다.

// Prometheus 메트릭 노출
// chat_connections_total, chat_rooms_total, chat_messages_total

채팅 서버 아키텍처 요약

컴포넌트별 역할

컴포넌트역할
Acceptor-Worker연결 수락과 I/O 처리 분리, 스레드 풀로 병렬화
RoomManager채널별 Room 생성/삭제, 참가자 관리
MessageRouter방별 브로드캐스트, DM 라우팅
ConnectionLimiter동시 연결 수 제한, 리소스 보호
strand공유 상태 접근 직렬화, 데이터 레이스 방지

아키텍처 구현 점검 항목

  • Acceptor-Worker 또는 io_context 풀 적용
  • RoomManager로 채널별 Room 분리
  • MessageRouter로 메시지 라우팅 (방/DM)
  • ConnectionLimiter로 연결 수 제한
  • strand로 participants_ 접근 직렬화
  • write_queue 크기 제한 (느린 클라이언트 대응)
  • 메시지 크기 제한 (DoS 방지)
  • Graceful Shutdown 구현
  • 헬스 체크 엔드포인트
  • 로깅 및 메트릭

다음 글


참고 자료:


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 느린 클라이언트 한 명 때문에 방 전체 채팅이 늦어지는 이유는 무엇인가요?

A. 브로드캐스트 루프가 각 세션에 동기적으로 쓰거나 한 세션의 송신 완료를 기다린 뒤 다음 세션으로 넘어가면, 가장 느린 클라이언트의 속도가 방 전체의 전송 속도가 됩니다. 브로드캐스트는 메시지를 세션별 송신 큐에 넣기만 하고, 실제 전송은 세션마다 독립적인 비동기 쓰기로 처리해야 합니다. 이때 송신 큐에 상한을 두고 계속 넘치는 클라이언트는 연결을 끊어야 메모리가 무한히 늘어나지 않습니다.