C++ 채팅 서버 아키텍처: Acceptor-Worker 구조, 방 관리, 메시지 라우팅, 커넥션 풀
들어가며: 채팅 서버를 키울 때 생기는 문제들
기본적인 채팅 서버는 io_context 하나와 방 하나로 충분히 동작합니다. 하지만 접속자가 늘고 기능이 붙기 시작하면 설계 문제가 하나씩 드러납니다.
- 스레드 하나로 모든 I/O와 메시지 처리를 하면, CPU 코어 하나가 가득 차는 순간 전체 서버가 그 속도로 묶입니다.
- 여러 스레드를 붙이면 참가자 목록을 순회하는 중에 다른 스레드가 참가자를 지우는 데이터 레이스가 생깁니다.
- 네트워크가 느린 클라이언트 하나의 송신 큐가 끝없이 쌓이며 메모리를 잡아먹습니다.
- 채널이 여러 개가 되면 메시지를 어느 방으로 보낼지 라우팅이 필요해집니다.
- 접속 수에 상한이 없으면 트래픽이 몰릴 때 파일 디스크립터와 메모리가 바닥납니다.
이 글은 Boost.Asio(C++17, Boost 1.70 이상)로 이 문제들을 풀어 갑니다. 스레드마다 io_context를 하나씩 두는 풀 구조, 방별 strand로 공유 상태를 보호하는 RoomManager, 방 브로드캐스트와 DM을 나누는 메시지 라우터, 동시 접속 수 제한, 느린 클라이언트와 큰 메시지에 대한 방어를 차례로 구현합니다.
연결 수락과 I/O 처리 분리: io_context 풀
스레드 하나로는 왜 부족한가
io_context 하나를 스레드 하나에서 run()하면 모든 완료 핸들러가 그 스레드에서 차례로 실행됩니다. 락이 필요 없어 단순하지만, 메시지 파싱·브로드캐스트·직렬화가 CPU를 쓰는 만큼 처리량이 코어 하나에 묶입니다. 연결 수락(accept) 자체는 가벼운 작업이라 병목이 되는 경우는 드물고, 실제 한계는 연결 수가 늘었을 때 I/O 핸들러를 처리하는 CPU입니다.
멀티코어를 쓰는 대표적인 방법은 두 가지입니다.
io_context하나를 여러 스레드가 함께run(): 핸들러가 아무 스레드에서나 실행되므로, 세션 상태를 strand로 보호해야 합니다.- 스레드마다
io_context를 하나씩(io_context 풀): 연결을 받을 때 풀에서io_context하나를 골라 그 연결의 모든 I/O를 맡깁니다. 한 세션의 핸들러는 항상 같은 스레드에서 실행되므로 세션 내부 상태에 락이 필요 없습니다.
이 글은 2번을 씁니다. 여기서 Acceptor는 “연결을 받아 어느 Worker(io_context)에 맡길지 정하는 역할”, Worker는 “맡은 연결의 I/O를 처리하는 스레드”입니다.
flowchart TB
subgraph Clients[클라이언트]
C1[클라이언트 1]
C2[클라이언트 2]
C3[클라이언트 N]
end
subgraph Acceptor[Acceptor]
A[async_accept]
end
subgraph Workers[io_context 풀: 스레드당 하나]
W1[io_context 1]
W2[io_context 2]
W3[io_context 3]
end
C1 --> A
C2 --> A
C3 --> A
A -->|새 소켓을 W1에 바인딩| W1
A -->|다음 소켓은 W2| W2
A -->|라운드 로빈| W3
IoContextPool
#include <boost/asio.hpp>
#include <atomic>
#include <memory>
#include <thread>
#include <vector>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
using error_code = boost::system::error_code;
class IoContextPool {
public:
explicit IoContextPool(size_t size) {
for (size_t i = 0; i < size; ++i) {
auto io = std::make_unique<asio::io_context>(1); // 동시성 힌트: 스레드 하나
guards_.emplace_back(asio::make_work_guard(*io));
contexts_.push_back(std::move(io));
}
}
void run() {
for (auto& io : contexts_) {
threads_.emplace_back([&ctx = *io] { ctx.run(); });
}
}
void stop() {
for (auto& g : guards_) g.reset(); // 남은 작업이 끝나면 run()이 반환되게 함
for (auto& io : contexts_) io->stop();
}
void join() {
for (auto& t : threads_) if (t.joinable()) t.join();
}
asio::io_context& next() {
return *contexts_[next_.fetch_add(1, std::memory_order_relaxed) % contexts_.size()];
}
private:
std::vector<std::unique_ptr<asio::io_context>> contexts_;
std::vector<asio::executor_work_guard<asio::io_context::executor_type>> guards_;
std::vector<std::thread> threads_;
std::atomic<size_t> next_{0};
};
executor_work_guard가 중요합니다. io_context::run()은 처리할 작업이 하나도 없으면 즉시 반환합니다. 서버 시작 직후에는 아직 연결이 없으므로, 작업 보증(work guard) 없이 스레드를 띄우면 대부분의 스레드가 바로 끝나 버리고 나중에 배정한 연결은 영영 처리되지 않습니다. 이 버그는 예외도 로그도 없이 “일부 클라이언트만 응답이 없는” 증상으로 나타나 찾기 어렵습니다.
소켓을 처음부터 원하는 io_context에 바인딩하기
void Server::do_accept() {
acceptor_.async_accept(
pool_.next(), // 새 소켓이 이 io_context를 쓰도록 지정
[this](error_code ec, tcp::socket socket) {
if (!ec) {
on_accept(std::move(socket));
}
if (acceptor_.is_open()) do_accept();
});
}
async_accept에 io_context를 넘기면, 수락된 소켓이 처음부터 그 io_context의 실행기(executor)에 묶입니다. 흔한 실수는 acceptor의 io_context로 소켓을 받은 뒤 asio::post(다른_io, ...)로 세션을 넘기는 것입니다. 이렇게 해도 소켓의 비동기 연산 완료는 여전히 acceptor 쪽 io_context에서 실행되므로, 부하가 한 스레드에 몰리고 세션 상태에 대한 스레드 가정도 깨집니다.
세션: 송신 큐와 메시지 크기 제한
방 브로드캐스트는 여러 스레드에서 세션의 deliver를 호출합니다. 세션의 송신 큐는 세션 자신의 io_context 스레드에서만 건드리도록, deliver는 작업을 세션 실행기로 넘기기만 합니다.
#include <deque>
#include <set>
#include <string>
class RoomManager;
class MessageRouter;
class Session : public std::enable_shared_from_this<Session> {
public:
using ReleaseFn = std::function<void()>;
Session(tcp::socket socket, RoomManager& rooms, MessageRouter& router, ReleaseFn on_release)
: socket_(std::move(socket))
, read_buf_(kMaxLine) // 최대 크기를 넘으면 read_until이 실패
, rooms_(rooms)
, router_(router)
, on_release_(std::move(on_release)) {}
~Session() {
if (on_release_) on_release_(); // 어떤 경로로 끝나든 연결 슬롯 반환
}
void start() { do_read(); }
// 다른 스레드(방 strand)에서 호출 가능
void deliver(std::string msg) {
asio::post(socket_.get_executor(),
[self = shared_from_this(), msg = std::move(msg)]() mutable {
self->enqueue(std::move(msg));
});
}
const std::string& nickname() const { return nickname_; }
private:
static constexpr size_t kMaxLine = 4 * 1024;
static constexpr size_t kMaxQueue = 500;
void do_read() {
asio::async_read_until(socket_, read_buf_, '\n',
[self = shared_from_this()](error_code ec, size_t n) {
if (ec) { // eof, 리셋, 또는 kMaxLine 초과(not_found)
self->close();
return;
}
std::string line(asio::buffers_begin(self->read_buf_.data()),
asio::buffers_begin(self->read_buf_.data()) + n - 1);
self->read_buf_.consume(n);
self->handle_message(line);
self->do_read();
});
}
void enqueue(std::string msg) {
if (write_queue_.size() >= kMaxQueue) {
close(); // 따라오지 못하는 클라이언트는 끊음
return;
}
bool idle = write_queue_.empty();
write_queue_.push_back(std::move(msg));
if (idle) do_write();
}
void do_write() {
asio::async_write(socket_, asio::buffer(write_queue_.front()),
[self = shared_from_this()](error_code ec, size_t) {
if (ec) {
self->close();
return;
}
self->write_queue_.pop_front();
if (!self->write_queue_.empty()) self->do_write();
});
}
void close(); // 방에서 나가고 소켓 닫기
void handle_message(const std::string& line);
void join_room(const std::string& channel);
void leave_room(const std::string& channel);
tcp::socket socket_;
asio::streambuf read_buf_;
std::deque<std::string> write_queue_;
std::set<std::string> current_rooms_;
std::string nickname_ = "anonymous";
RoomManager& rooms_;
MessageRouter& router_;
ReleaseFn on_release_;
};
세 가지가 핵심입니다.
첫째, 한 세션에는 동시에 하나의 async_write만 걸려 있어야 합니다. 이전 쓰기가 끝나기 전에 같은 소켓에 다시 async_write를 걸면 두 메시지의 바이트가 섞여 나갈 수 있습니다. 그래서 큐가 비어 있을 때만 do_write를 시작하고, 완료 핸들러에서 다음 메시지를 이어서 보냅니다.
둘째, 송신 큐에 상한을 둡니다. 브로드캐스트는 각 세션의 큐에 메시지를 넣기만 하므로 느린 클라이언트가 다른 사람의 수신을 막지는 않지만, 그 클라이언트의 큐는 계속 커집니다. 상한(여기서는 500개)을 넘으면 연결을 끊어 메모리를 보호합니다. 끊는 대신 오래된 메시지를 버리는 정책도 가능하지만, 채팅에서는 중간 메시지가 빠진 채 대화가 이어지는 것보다 재접속 후 최근 기록을 다시 받는 편이 덜 혼란스럽습니다.
셋째, 한 줄의 최대 길이를 제한합니다. async_read_until은 구분자가 올 때까지 계속 버퍼에 쌓으므로, 줄바꿈 없이 데이터를 계속 보내는 클라이언트 하나가 메모리를 소진시킬 수 있습니다. asio::streambuf를 최대 크기와 함께 만들면, 그 크기 안에서 구분자를 찾지 못했을 때 읽기가 asio::error::not_found로 실패하므로 연결을 끊으면 됩니다.
연결 슬롯 반환을 소멸자에 둔 것도 의도한 설계입니다. 에러 종류별로 반환 코드를 넣으면 eof와 리셋만 처리하고 타임아웃이나 다른 에러에서 슬롯이 새는 일이 생기는데, 세션 객체는 마지막 shared_ptr가 사라질 때 반드시 소멸하므로 어떤 경로로 끝나든 한 번 반환됩니다.
생성자와 start()를 나눈 데도 이유가 있습니다. shared_from_this()는 객체가 이미 shared_ptr로 관리되고 있을 때만 쓸 수 있어서, 생성자 안에서 방에 자신을 등록하거나 비동기 읽기를 걸려고 호출하면 C++17 기준으로 std::bad_weak_ptr 예외가 납니다. std::make_shared로 세션을 만든 뒤 start()에서 첫 async_read_until을 거는 순서가 그래서 필요합니다.
RoomManager로 채널별 방 관리
flowchart TB
subgraph RoomManager[RoomManager]
RM[rooms_ map + mutex]
end
subgraph Rooms[채널별 Room: 각자 strand 보유]
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
ChatRoom
방의 참가자 목록과 대화 기록은 여러 세션(즉 여러 스레드)이 동시에 건드립니다. 방마다 strand를 하나 두고, 참가자 목록을 읽고 쓰는 작업을 모두 그 strand로 보내 순서대로 실행되게 합니다.
class ChatRoom : public std::enable_shared_from_this<ChatRoom> {
public:
ChatRoom(asio::io_context& io, std::string channel_id, RoomManager& manager)
: strand_(asio::make_strand(io))
, channel_id_(std::move(channel_id))
, manager_(manager) {}
void join(std::shared_ptr<Session> session) {
asio::post(strand_, [self = shared_from_this(), session] {
self->participants_.insert(session);
self->count_.store(self->participants_.size());
for (const auto& line : self->history_) session->deliver(line);
});
}
void leave(std::shared_ptr<Session> session); // 아래 "빈 방 정리"에서 구현
void deliver(std::string msg, std::shared_ptr<Session> sender) {
asio::post(strand_, [self = shared_from_this(), msg = std::move(msg), sender] {
self->history_.push_back(msg);
if (self->history_.size() > kMaxHistory) self->history_.pop_front();
for (const auto& p : self->participants_) {
if (p != sender) p->deliver(msg); // 세션 큐에 넣기만 함
}
});
}
size_t participant_count() const { return count_.load(); }
const std::string& id() const { return channel_id_; }
private:
static constexpr size_t kMaxHistory = 100;
asio::strand<asio::io_context::executor_type> strand_;
std::string channel_id_;
RoomManager& manager_;
std::set<std::shared_ptr<Session>> participants_;
std::deque<std::string> history_;
std::atomic<size_t> count_{0};
};
participant_count()는 strand 밖에서 호출되므로 participants_.size()를 직접 읽으면 데이터 레이스입니다. strand 안에서 갱신하는 원자 카운터를 따로 두었습니다. 브로드캐스트 루프는 strand 안에서만 돌고 Session::deliver는 세션 실행기로 작업을 넘기기만 하므로, 순회 중에 participants_가 바뀌어 이터레이터가 무효화되는 일이 없습니다. 참가자 집합을 복사해서 순회하는 방법도 있지만, 모든 수정이 strand를 거친다면 복사는 필요 없습니다.
RoomManager
#include <shared_mutex>
#include <unordered_map>
class RoomManager {
public:
explicit RoomManager(IoContextPool& pool) : pool_(pool) {}
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>(pool_.next(), channel_id, *this)).first;
}
return it->second;
}
std::shared_ptr<ChatRoom> find(const std::string& channel_id) const {
std::shared_lock lock(mutex_);
auto it = rooms_.find(channel_id);
return it == rooms_.end() ? nullptr : 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);
}
}
size_t room_count() const {
std::shared_lock lock(mutex_);
return rooms_.size();
}
private:
IoContextPool& pool_;
std::unordered_map<std::string, std::shared_ptr<ChatRoom>> rooms_;
mutable std::shared_mutex mutex_;
};
방 목록은 읽기가 훨씬 많으므로 std::shared_mutex로 읽기는 동시에, 생성·삭제는 단독으로 하게 했습니다. 방의 strand는 풀의 io_context 중 하나에 묶어, 방 처리도 여러 스레드에 분산됩니다.
빈 방 정리
참가자가 0명이 된 방을 지우지 않으면, 사용자가 임의의 채널 이름으로 들어갔다 나갈 때마다 방 객체가 쌓입니다. 정리는 참가자 수가 실제로 줄어든 뒤에 해야 하므로 strand 안에서 호출합니다.
void ChatRoom::leave(std::shared_ptr<Session> session) {
asio::post(strand_, [self = shared_from_this(), session] {
self->participants_.erase(session);
self->count_.store(self->participants_.size());
if (self->participants_.empty()) {
self->manager_.remove_if_empty(self->channel_id_);
}
});
}
leave를 post한 직후 호출하는 쪽에서 바로 remove_if_empty를 부르면, 아직 strand 작업이 실행되기 전이라 카운트가 줄지 않은 상태를 보고 방을 지우지 않습니다. 정리 호출 위치가 strand 안이어야 하는 이유입니다.
이 구현에는 경계 조건이 하나 남아 있습니다. 세션 A가 get_or_create로 방 포인터를 받은 직후, 마지막 참가자가 나가 방이 맵에서 지워지면, A는 맵에 없는 방에 들어가게 됩니다. 이후 같은 채널에 들어오는 사람은 새 방을 받으므로 둘이 대화를 못 합니다. 이를 막으려면 빈 방을 즉시 지우지 않고 일정 시간 뒤에 다시 확인해 지우거나, join을 매니저 락 안에서 방 생성과 함께 처리합니다. 접속자가 많은 공개 채널에서는 드물지만, 사람이 적은 채널에서는 실제로 재현되는 문제입니다.
세션의 방 입퇴장
세션 한 개가 여러 방에 동시에 들어갈 수 있게 하려면, 세션이 현재 들어가 있는 채널 목록을 들고 있어야 연결이 끊길 때 모든 방에서 정리할 수 있습니다. 이 목록은 세션 자신의 핸들러에서만 바뀌므로 락이 필요 없습니다.
void Session::join_room(const std::string& channel) {
if (current_rooms_.insert(channel).second) {
rooms_.get_or_create(channel)->join(shared_from_this());
}
}
void Session::leave_room(const std::string& channel) {
if (current_rooms_.erase(channel)) {
if (auto room = rooms_.find(channel)) room->leave(shared_from_this());
}
}
void Session::close() {
for (const auto& ch : current_rooms_) {
if (auto room = rooms_.find(ch)) room->leave(shared_from_this());
}
current_rooms_.clear();
error_code ignored;
socket_.shutdown(tcp::socket::shutdown_both, ignored);
socket_.close(ignored);
}
나갈 때는 get_or_create 대신 find를 씁니다. 나가는 동작이 방을 새로 만들면 안 되기 때문입니다.
방 브로드캐스트와 DM 라우팅
sequenceDiagram
participant S1 as Session A
participant Router as MessageRouter
participant R1 as Room general
participant S2 as Session B
participant S3 as Session C
S1->>Router: to_room("general", "안녕", sender=A)
Router->>R1: deliver("안녕", sender=A) (strand로 post)
R1->>S2: deliver("안녕") (B의 실행기로 post)
R1->>S3: deliver("안녕") (C의 실행기로 post)
class SessionRegistry {
public:
void add(const std::string& user_id, std::weak_ptr<Session> s) {
std::lock_guard lock(mutex_);
sessions_[user_id] = std::move(s);
}
void remove(const std::string& user_id) {
std::lock_guard lock(mutex_);
sessions_.erase(user_id);
}
std::shared_ptr<Session> get(const std::string& user_id) {
std::lock_guard lock(mutex_);
auto it = sessions_.find(user_id);
return it == sessions_.end() ? nullptr : it->second.lock();
}
private:
std::mutex mutex_;
std::unordered_map<std::string, std::weak_ptr<Session>> sessions_;
};
class MessageRouter {
public:
MessageRouter(RoomManager& rooms, SessionRegistry& registry)
: rooms_(rooms), registry_(registry) {}
// 방에 브로드캐스트 (발신자 제외). 존재하지 않는 방에는 보내지 않음
bool to_room(const std::string& channel, std::string msg, std::shared_ptr<Session> sender) {
auto room = rooms_.find(channel);
if (!room) return false;
room->deliver(std::move(msg), std::move(sender));
return true;
}
// DM: 대상 사용자 한 명에게만
bool to_user(const std::string& user_id, std::string msg) {
auto target = registry_.get(user_id);
if (!target) return false;
target->deliver(std::move(msg));
return true;
}
private:
RoomManager& rooms_;
SessionRegistry& registry_;
};
세션 레지스트리는 weak_ptr를 들고 있습니다. shared_ptr로 들고 있으면 레지스트리가 세션 수명을 붙잡아, 연결이 끊긴 세션이 소멸하지 않고 연결 슬롯도 반환되지 않습니다. 방 브로드캐스트에서는 find를 써서 존재하지 않는 채널에 메시지를 보내면 방이 새로 생기지 않게 했습니다. 실제 서비스라면 발신자가 그 방의 참가자인지도 확인해야, 들어가지 않은 방에 메시지를 흘려보내는 것을 막을 수 있습니다.
텍스트 프로토콜 처리
// 프로토콜: NICK <name> / JOIN <channel> / LEAVE <channel> / MSG <channel> <text> / DM <user> <text>
#include <sstream>
void Session::handle_message(const std::string& raw) {
std::istringstream iss(raw);
std::string cmd, target, content;
iss >> cmd >> target;
std::getline(iss >> std::ws, content);
if (cmd == "JOIN") {
join_room(target);
} else if (cmd == "LEAVE") {
leave_room(target);
} else if (cmd == "MSG") {
if (current_rooms_.count(target) == 0) {
deliver("ERROR not in channel " + target + "\n");
return;
}
router_.to_room(target, nickname_ + ": " + content + "\n", shared_from_this());
} else if (cmd == "DM") {
if (!router_.to_user(target, "[DM] " + nickname_ + ": " + content + "\n")) {
deliver("ERROR no such user " + target + "\n");
}
}
}
NICK 처리와 레지스트리 등록, 닉네임 중복 검사는 생략했습니다. 줄 단위 텍스트 프로토콜은 디버깅이 쉽지만, 메시지에 줄바꿈이 들어갈 수 없고 바이너리 데이터를 실을 수 없습니다. 기능이 늘어나면 길이 접두사가 붙은 프레임 형식이나 JSON 메시지로 옮기게 됩니다(TCP 위에 프로토콜 설계하기 참고).
동시 접속 수 제한
class ConnectionLimiter {
public:
explicit ConnectionLimiter(size_t max_connections) : max_(max_connections) {}
bool try_acquire() {
size_t cur = current_.load();
while (cur < max_) {
if (current_.compare_exchange_weak(cur, cur + 1)) return true;
}
return false;
}
void release() { current_.fetch_sub(1); }
size_t current() const { return current_.load(); }
private:
const size_t max_;
std::atomic<size_t> current_{0};
};
이름은 흔히 “커넥션 풀”이라고 부르지만, 서버 쪽에서 하는 일은 연결을 재사용하는 풀이 아니라 동시 연결 수의 상한을 지키는 것입니다. 상한은 프로세스의 파일 디스크립터 한도(ulimit -n)와 세션당 메모리(송신 큐 상한 × 메시지 크기 포함)로 계산합니다.
void Server::on_accept(tcp::socket socket) {
if (!limiter_.try_acquire()) {
// 거절 메시지는 비동기로 보내고 닫음 (동기 write는 acceptor를 막을 수 있음)
auto sock = std::make_shared<tcp::socket>(std::move(socket));
auto msg = std::make_shared<std::string>("ERROR server at capacity\n");
asio::async_write(*sock, asio::buffer(*msg),
[sock, msg](error_code, size_t) {
error_code ignored;
sock->close(ignored);
});
return;
}
auto session = std::make_shared<Session>(std::move(socket), rooms_, router_,
[this] { limiter_.release(); });
session->start();
}
상한에 걸린 연결을 거절할 때 asio::write 같은 동기 쓰기를 쓰면, 송신 버퍼가 가득 찬 클라이언트 하나 때문에 accept 루프 전체가 멈출 수 있습니다. 거절 메시지 없이 바로 닫아도 되지만, 클라이언트가 재시도 간격을 조절할 수 있도록 이유를 알려 주는 편이 낫습니다.
서버 조립
class Server {
public:
Server(const tcp::endpoint& endpoint, size_t threads, size_t max_connections)
: pool_(threads)
, acceptor_(accept_io_)
, limiter_(max_connections)
, rooms_(pool_)
, router_(rooms_, registry_) {
acceptor_.open(endpoint.protocol());
acceptor_.set_option(asio::socket_base::reuse_address(true));
acceptor_.bind(endpoint);
acceptor_.listen();
}
void run() {
pool_.run();
do_accept();
accept_io_.run(); // accept는 이 스레드에서
pool_.join();
}
void shutdown(); // 아래 Graceful Shutdown 참고
private:
void do_accept();
void on_accept(tcp::socket socket);
IoContextPool pool_; // 멤버 선언 순서 = 초기화 순서
asio::io_context accept_io_;
tcp::acceptor acceptor_;
ConnectionLimiter limiter_;
SessionRegistry registry_;
RoomManager rooms_;
MessageRouter router_;
};
int main() {
Server server(tcp::endpoint(tcp::v4(), 9000),
std::max(1u, std::thread::hardware_concurrency()), 10000);
server.run();
}
멤버는 선언 순서대로 초기화되므로, 다른 멤버를 참조하는 멤버(rooms_가 pool_을, router_가 rooms_와 registry_를)는 반드시 뒤에 선언합니다. 초기화 리스트에 적은 순서는 실제 초기화 순서와 무관하며, 순서가 어긋나면 아직 생성되지 않은 객체의 참조를 저장하게 됩니다. GCC·Clang의 -Wreorder 경고가 이를 알려 줍니다.
reuse_address는 서버를 재시작할 때 이전 연결이 TIME_WAIT 상태로 남아 있어도 같은 포트에 바인딩할 수 있게 합니다. 이 옵션 없이 서버를 껐다 켜면 “Address already in use”로 한동안 시작하지 못합니다.
# Boost.Asio는 헤더 전용 (Boost 1.69 이후 boost_system 링크 불필요)
g++ -std=c++17 -O2 -pthread -o chat_server chat_server.cpp
./chat_server
# 다른 터미널에서
nc localhost 9000
JOIN general
MSG general 안녕하세요
인증과 메시지 기록
위 서버는 NICK 명령으로 이름을 정하므로 누구나 아무 이름으로 들어올 수 있습니다. 답장이나 멘션, 기록 조회처럼 사용자를 확정해야 하는 기능이 붙으면 연결 직후 토큰을 검증해 사용자 ID를 정하는 단계가 필요합니다.
토큰 인증
로그인은 별도의 HTTP 서비스가 처리하고, 채팅 서버는 그 서비스가 발급한 JWT만 검증하는 구성이 흔합니다. jwt-cpp를 쓰면 다음과 같습니다.
#include <jwt-cpp/jwt.h>
#include <optional>
class AuthManager {
std::string secret_key_;
public:
explicit AuthManager(std::string key) : secret_key_(std::move(key)) {
if (secret_key_.size() < 32) throw std::runtime_error("JWT secret too short");
}
std::string generate_token(const std::string& user_id) {
auto now = std::chrono::system_clock::now();
return jwt::create()
.set_subject(user_id) // 사용자 ID는 표준 sub 클레임
.set_issued_at(now)
.set_expires_at(now + std::chrono::hours(1)) // 표준 exp 클레임
.sign(jwt::algorithm::hs256{secret_key_});
}
std::optional<std::string> verify_token(const std::string& token) {
try {
auto decoded = jwt::decode(token);
jwt::verify()
.allow_algorithm(jwt::algorithm::hs256{secret_key_})
.leeway(30) // 서버 간 시계 오차 30초 허용
.verify(decoded); // 서명과 exp 검사, 실패 시 예외
return decoded.get_subject();
} catch (const std::exception& e) {
spdlog::warn("JWT verify failed: {}", e.what());
return std::nullopt;
}
}
};
만료 시각을 사용자 정의 클레임 안에 문자열로 넣는 실수가 흔합니다. 검증기는 표준 등록 클레임인 최상위 exp만 확인하므로, 그렇게 만든 토큰은 서명만 맞으면 영원히 유효합니다. 테스트는 한 시간 안에 끝나니 드러나지 않고, 유출된 토큰이 몇 달 뒤에도 통과하는 것을 보고서야 알게 됩니다. set_expires_at을 쓰면 verify()가 만료된 토큰에 예외를 던지므로 만료를 따로 비교할 필요가 없습니다. 로그에 남긴 e.what()이 “token expired”, “invalid signature”, “decoding failed” 중 무엇인지로 오래된 토큰인지, 서버 간 시크릿 불일치인지, 전송 중 토큰이 잘린 것인지 구분되지만, 클라이언트에는 “Invalid token” 하나로만 알려야 공격자에게 단서를 주지 않습니다. 생성자에서 키 길이를 검사한 것은 비어 있는 키로도 HMAC 서명이 문제없이 만들어지기 때문입니다. 환경 변수를 빠뜨린 채 서버가 뜨면 누구나 유효한 토큰을 만들 수 있게 됩니다.
세션 쪽에서는 인증 실패 응답을 보낸 직후 socket_.close()를 부르지 않도록 주의합니다. deliver는 송신 큐에 넣고 반환할 뿐이라, 바로 닫으면 클라이언트는 에러 메시지 없이 끊긴 것만 봅니다. 송신 완료 핸들러에서 “보낸 뒤 닫기” 표시를 확인해 닫아야 합니다. 또 인증 전 연결에는 몇 초짜리 타이머를 걸어 그 안에 인증 메시지가 오지 않으면 끊고, 인증 전에 받을 수 있는 메시지 크기도 더 작게 잡습니다. 인증하지 않은 연결이 무한정 열려 있으면 연결 수만으로 서버 자원을 소모시키는 공격이 가능합니다.
SQLite 메시지 기록과 페이지네이션
새로 입장한 사용자에게 최근 메시지를 보여 주려면 메시지를 저장해야 합니다. 서버 한 대 규모라면 SQLite로 충분합니다.
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX idx_messages_room_id ON messages(room_id, id DESC);
// before_id 이전의 메시지를 최신순으로 count개 (before_id == 0이면 가장 최근부터)
std::vector<Message> Database::get_messages(const std::string& room_id,
int count, int64_t before_id) {
count = std::clamp(count, 1, 100); // 클라이언트가 보낸 값은 상한 강제
const char* sql = before_id > 0
? "SELECT id, user_id, content, timestamp FROM messages "
"WHERE room_id = ? AND id < ? ORDER BY id DESC LIMIT ?"
: "SELECT id, user_id, content, timestamp FROM messages "
"WHERE room_id = ? ORDER BY id DESC LIMIT ?";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error(sqlite3_errmsg(db_));
}
int idx = 1;
sqlite3_bind_text(stmt, idx++, room_id.c_str(), -1, SQLITE_TRANSIENT);
if (before_id > 0) sqlite3_bind_int64(stmt, idx++, before_id);
sqlite3_bind_int(stmt, idx++, count);
std::vector<Message> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
out.push_back({sqlite3_column_int64(stmt, 0),
reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1)),
reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2)),
sqlite3_column_int64(stmt, 3)});
}
sqlite3_finalize(stmt);
std::reverse(out.begin(), out.end()); // 화면에는 오래된 순으로
return out;
}
페이지 커서로 timestamp를 쓰고 싶어지지만, 초 단위 타임스탬프는 같은 초에 여러 메시지가 저장되면 timestamp < ? 경계에서 그 초의 나머지 메시지를 통째로 건너뜁니다. 활발한 방에서 스크롤을 올리다 대화 일부가 사라지는 버그로 나타나므로, 위처럼 자동 증가하는 id를 커서로 쓰고 인덱스도 (room_id, id)로 맞춥니다. count 상한이 없으면 요청 하나로 수백만 행을 읽게 만들 수 있고, sqlite3_prepare_v2의 반환값을 확인하지 않으면 스키마 오류 때 NULL 문장에 바인딩을 시도하게 됩니다.
쓰기 쪽에서는 SQLITE_BUSY가 가장 흔한 문제입니다. SQLite는 한 번에 하나의 쓰기만 허용하므로 여러 I/O 스레드가 메시지마다 INSERT하면 락 충돌이 납니다. PRAGMA journal_mode=WAL로 쓰기 중에도 읽기가 막히지 않게 하고 sqlite3_busy_timeout으로 대기를 허용할 수는 있지만, 쓰기끼리는 여전히 직렬이라 부하가 몰리면 결국 타임아웃이 납니다. 쓰기 전용 스레드 하나가 큐에서 메시지를 꺼내 수십에서 수백 개씩 한 트랜잭션으로 저장하는 구조가 가장 안정적이고, 커밋마다 일어나는 디스크 동기화가 줄어 처리량도 올라갑니다. 한 sqlite3* 연결을 여러 스레드가 공유하려면 serialized 모드 빌드가 필요하므로, 연결 접근을 이 쓰기 스레드로 모으는 편이 안전합니다. I/O 스레드에서 DB를 직접 기다리면 그 스레드에 붙은 모든 세션이 함께 멈춘다는 점도 큐로 분리하는 이유입니다.
재접속과 놓친 메시지
모바일 클라이언트는 연결이 자주 끊기므로, 다시 붙었을 때 이전 방 목록과 놓친 메시지를 복구해야 합니다. 까다로운 부분은 경계입니다. 방에 다시 들어간 뒤 DB에서 놓친 메시지를 조회하면 그 사이 도착한 메시지가 실시간 브로드캐스트와 조회 결과 양쪽에 들어가 중복되고, 조회를 먼저 하면 그 틈의 메시지가 누락됩니다. 메시지 ID가 단조 증가하므로 클라이언트가 마지막으로 받은 ID를 보내고 id > ? 조건으로 조회한 뒤, 클라이언트가 이미 받은 ID를 버리게 해 중복을 허용하는 쪽이 다루기 쉽습니다. 사용자가 속한 방 목록과 마지막으로 읽은 ID를 서버 메모리에만 두면 재시작이나 다른 인스턴스로의 재접속 때 사라지므로, DB나 Redis에 둡니다.
파일 전송
파일을 Base64로 인코딩해 채팅 메시지 경로로 보내는 방식은 구현이 쉽지만 크기가 약 33% 늘고 인코딩 비용이 붙으며, 큰 파일을 한 번에 받으면 동시 업로드 몇 개로 메모리가 바닥납니다. 64KB 정도의 청크로 나눠 받더라도 존재하지 않는 전송 ID로 온 청크, 선언한 크기를 넘는 청크, 연결이 끊겨 반쯤 쓰인 파일을 모두 처리해야 하고, 저장 파일명은 원래 이름 대신 서버가 만든 ID를 써야 ../ 경로 조작을 피할 수 있습니다. 파일이 크거나 자주 오간다면 채팅 서버는 업로드 URL(예: S3 사전 서명 URL)만 발급하고 실제 파일은 HTTP로 객체 스토리지에 직접 올리게 한 뒤, 방에는 파일 메타데이터만 브로드캐스트하는 구조가 서버 부담이 훨씬 적습니다.
운영을 위한 추가 장치
Graceful Shutdown
io_context::stop()을 바로 호출하면 송신 큐에 남은 메시지까지 버려집니다. 종료 신호를 받으면 새 연결 수락을 멈추고, 세션들에 종료를 알린 뒤 잠시 기다렸다가 멈추는 순서가 필요합니다.
void Server::shutdown() {
asio::post(accept_io_, [this] {
error_code ignored;
acceptor_.close(ignored); // 새 연결 중단
// 각 세션에 "SERVER shutting down"을 보내고 close 예약 (세션 목록 순회는 생략)
auto timer = std::make_shared<asio::steady_timer>(accept_io_, std::chrono::seconds(5));
timer->async_wait([this, timer](error_code) {
pool_.stop(); // 유예 시간이 지나면 남은 작업 정리
accept_io_.stop();
});
});
}
asio::signal_set으로 SIGINT·SIGTERM을 받아 shutdown()을 부르면 됩니다. 쿠버네티스 같은 환경에서는 유예 시간을 terminationGracePeriodSeconds보다 짧게 잡아야 강제 종료 전에 정리가 끝납니다.
클라이언트 송신 속도 제한
송신 큐 상한은 “서버 → 느린 클라이언트” 방향을 보호합니다. 반대로 클라이언트가 메시지를 너무 빨리 보내는 경우에는 세션마다 토큰 버킷 같은 속도 제한을 둡니다. 초당 허용량을 넘는 메시지는 거절 응답을 보내고 버리며, 계속 넘치면 연결을 끊습니다. 더 근본적인 백프레셔는 처리할 여유가 없을 때 그 세션의 다음 async_read를 잠시 미루는 것입니다. 서버가 읽지 않으면 커널 수신 버퍼가 차고, TCP 흐름 제어가 클라이언트의 송신을 자연스럽게 늦춥니다.
헬스 체크와 메트릭
로드 밸런서가 서버 상태를 확인할 수 있도록 별도 포트에 간단한 HTTP 엔드포인트를 두는 것이 일반적입니다. 같은 엔드포인트에서 현재 연결 수(limiter_.current()), 방 수(rooms_.room_count()), 초당 메시지 수, 송신 큐 상한으로 끊은 횟수를 Prometheus 형식으로 노출하면, 느린 클라이언트 문제나 상한 설정이 맞는지를 숫자로 확인할 수 있습니다.
수평 확장
서버 한 대의 연결 수를 넘어서면 여러 인스턴스를 띄우고, 같은 방의 참가자가 서로 다른 인스턴스에 붙어 있어도 메시지가 전달되도록 Redis Pub/Sub이나 NATS 같은 메시지 브로커를 둡니다. 각 인스턴스는 자기 로컬 방에 메시지를 전달하는 동시에 브로커에 발행하고, 브로커에서 받은 메시지는 로컬 방에만 전달합니다. 이때 방 상태(참가자 목록, 기록)는 인스턴스마다 로컬이 되므로, 전체 참가자 목록이나 기록이 필요하면 별도 저장소가 필요합니다.
[Client A] → [Server 1] → [Redis Pub/Sub] → [Server 2] → [Client B]