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

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

1. 전체 구조와 아키텍처

아키텍처 다이어그램

다음은 mermaid를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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를 통해 모든 수정 작업을 직렬화합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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로 브로드캐스트한 뒤 다시 읽기를 등록합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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()를 호출합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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_에 동시에 접근하면 데이터 레이스가 발생합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 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_ 접근이 직렬화됩니다.

브로드캐스트 흐름

아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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)에서 상세히 다룹니다. 대용량 메시지나 이진 데이터가 필요할 때 유용합니다.

프로토콜 명세 (텍스트 기반)

아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

# 클라이언트 → 서버
NICK <닉네임>\n          # 입장 시 닉네임 설정 (최초 1회)
<메시지 내용>\n          # 일반 채팅 (닉네임 설정 후)
# 서버 → 클라이언트
[시스템] OO님이 입장했습니다.\n
[시스템] OO님이 퇴장했습니다.\n
<닉네임>: <메시지>\n     # 다른 참가자 메시지

바이너리 프로토콜 (길이 프리픽스)

대용량·이진 데이터 지원 시 사용합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 프레임 구조: [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을 분리합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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가 직렬화합니다. 빈 방은 메모리 절약을 위해 제거합니다.

완전한 단일 파일 예제 (복사 후 바로 빌드 가능)

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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가 서로 다른 스레드에서 동시에 실행됨. 해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 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을 잡고 있어서 세션이 해제되지 않음. 해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ✅ 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_가 계속 쌓여 메모리 부족. 해결법: 큐 크기 제한을 두고, 초과 시 해당 세션을 강제 퇴장시킵니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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() 검사. 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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입니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 잘못된 예
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가 맞습니다 (퇴장한 사용자에게는 알림 불필요). 아래 코드는 cpp를 사용한 구현 예제입니다. 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 권장 순서
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

아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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로 묶어서 시스템 콜 수를 줄입니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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

다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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 서비스 예시

아래 코드는 ini를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

[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 예시

아래 코드는 dockerfile를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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]

환경 변수

다음은 간단한 bash 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

# 포트, 스레드 수, 로그 레벨 등
CHAT_PORT=9000
CHAT_THREADS=4
CHAT_LOG_LEVEL=info

빌드 및 실행

아래 코드는 bash를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

# 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 예시

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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 예시

아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 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와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3