C++ 채팅 서버 완성하기 | 인증·방 관리·메시지 히스토리 구현
들어가며: 실전 채팅 서버 구축
실전 채팅 서버의 요구사항
기본 브로드캐스트 채팅 서버를 만들었다면, 이제 사용자 인증, 여러 방 관리, 메시지 히스토리, 파일 전송, 재연결 처리 등 실무에서 필요한 기능을 추가해야 합니다.
목표:
- 사용자 인증 및 세션 관리
- 다중 방(채널) 생성 및 입장/퇴장
- 메시지 히스토리 저장 및 조회
- 파일 전송 프로토콜
- 재연결 시 상태 복구
요구 환경: C++17 이상(일부 예제의 std::span은 C++20), Boost.Asio, SQLite 또는 PostgreSQL, jwt-cpp, nlohmann/json
이 글의 코드는 기능별 구조를 보여 주는 조각이라 그대로 붙여서 한 번에 컴파일되지는 않습니다. 대신 각 조각에서 실제 운영에서 문제가 되는 지점(메시지 경계, 토큰 만료, 객체 수명, 종료 순서)을 짚어 두었으니, 직접 조립할 때 그 부분을 먼저 확인하는 것이 좋습니다.
발신자 식별·방 분리·재접속: 실무 채팅 서버 이슈
기본 채팅 서버는 연결만 받고 사용자를 식별하지 않습니다. “안녕하세요” 메시지가 왔을 때 누가 보냈는지 알 수 없어, 답장이나 멘션(@username) 기능을 구현할 수 없습니다. 해결: JWT 기반 인증으로 연결 시점에 사용자 ID를 확정하고, 모든 메시지에 user_id를 포함합니다.
단일 방만 있으면 팀 A와 팀 B의 대화가 한곳에 섞입니다. 프로젝트별, 채널별로 다중 방이 필요합니다. 해결: RoomManager로 방 생성/삭제/입장/퇴장을 관리하고, 각 방마다 독립적인 참가자 목록과 히스토리를 유지합니다.
새로 입장한 사용자에게 최근 N개 메시지를 보내 주지 않으면 대화 맥락을 놓칩니다. 해결: 메시지를 DB에 저장하고, 입장 시 get_messages(room_id, 100)로 최근 100개를 조회해 전송합니다.
모바일에서는 네트워크가 자주 끊깁니다. 재연결 시 이전에 있던 방 목록과 놓친 메시지를 복구해야 합니다. 해결: 세션 상태(현재 방 목록, 마지막 확인 시각)를 서버에 저장하고, 재연결 시 복구합니다.
10MB 파일을 한 번에 메모리에 올리면 여러 클라이언트가 동시에 업로드할 때 OOM이 발생합니다. 해결: 청크 단위(예: 64KB)로 나눠 전송하고, Base64 인코딩으로 JSON에 실어 보냅니다.
한 방에 1000명이 있을 때 for (auto& p : participants_) p->send(msg)를 순차 실행하면 지연이 누적됩니다. 해결: strand로 직렬화하되 async_write를 병렬로 걸고, 필요하면 여러 서버로 부하를 분산합니다.
여기서 “병렬로 건다”는 것은 참가자마다 async_write를 시작만 해 두고 완료를 기다리지 않는다는 뜻입니다. 브로드캐스트 루프가 각 세션의 전송 완료를 순서대로 기다리면, 느린 클라이언트 하나(모바일 네트워크가 나쁜 사용자)가 방 전체의 메시지 전달을 붙잡습니다. 각 세션이 자기만의 송신 큐를 갖고 브로드캐스트는 큐에 넣기만 하는 구조(성능 절의 메시지 큐)가 이 문제를 해결합니다. 다만 느린 세션의 큐가 끝없이 쌓이면 서버 메모리가 늘어나므로, 큐 길이 상한을 넘은 세션은 연결을 끊는 정책도 함께 필요합니다.
ChatServer 전체 아키텍처
시스템 구성
flowchart TB
Client["Client\n(WebSocket)"]
subgraph ChatServer[Chat Server]
CM["Connection Manager<br/>세션 관리, 인증 처리"]
RM["Room Manager<br/>방 생성/삭제, 참가자 관리"]
MR["Message Router<br/>메시지 라우팅, 브로드캐스트"]
end
DB["Database<br/>사용자 정보, 메시지 히스토리, 방 정보"]
Client --> ChatServer
CM --> RM
CM --> MR
ChatServer --> DB
아키텍처 다이어그램 (Mermaid)
flowchart TB
subgraph Clients[클라이언트]
C1[WebSocket A]
C2[WebSocket B]
C3[WebSocket C]
end
subgraph Server[채팅 서버]
CM[Connection Manager]
RM[Room Manager]
MR[Message Router]
end
subgraph DB[데이터베이스]
Users["(users)"]
Rooms["(rooms)"]
Messages["(messages)"]
end
C1 --> CM
C2 --> CM
C3 --> CM
CM --> RM
CM --> MR
RM --> Rooms
MR --> Messages
CM --> Users
핵심 클래스
class ChatServer {
asio::io_context& io_context_;
tcp::acceptor acceptor_;
ConnectionManager connection_mgr_;
RoomManager room_mgr_;
MessageRouter router_;
Database db_;
public:
void start();
void stop();
};
class Session : public std::enable_shared_from_this<Session> {
tcp::socket socket_;
std::string user_id_;
std::string current_room_;
bool authenticated_ = false;
public:
void start();
void authenticate(const std::string& token);
void join_room(const std::string& room_id);
void send_message(const std::string& content);
};
class Room {
std::string id_;
std::string name_;
std::set<std::shared_ptr<Session>> participants_;
std::deque<Message> history_;
public:
void join(std::shared_ptr<Session> session);
void leave(std::shared_ptr<Session> session);
void broadcast(const Message& msg);
std::vector<Message> get_history(size_t count);
};
JWT 기반 사용자 인증
토큰 검증 흐름
class AuthManager {
std::string secret_key_;
public:
std::string generate_token(const std::string& user_id) {
// JWT 토큰 생성: 표준 클레임(sub, exp)을 사용해야 검증기가 만료를 확인함
auto now = std::chrono::system_clock::now();
return jwt::create()
.set_subject(user_id)
.set_issued_at(now)
.set_expires_at(now + std::chrono::hours(1)) // 1시간
.sign(jwt::algorithm::hs256{secret_key_});
}
std::optional<std::string> verify_token(const std::string& token) {
try {
auto decoded = jwt::decode(token);
auto verifier = jwt::verify()
.allow_algorithm(jwt::algorithm::hs256{secret_key_})
.leeway(30); // 서버 간 시계 오차 30초 허용
verifier.verify(decoded); // 서명 + exp 검사, 실패 시 예외
return decoded.get_subject();
} catch (const std::exception&) {
return std::nullopt;
}
}
};
처음 이 코드를 짤 때 흔히 하는 실수가 원래 예제처럼 만료 시각을 {"data": "{\"user_id\":...,\"exp\":...}"} 같은 사용자 정의 클레임 안의 문자열에 넣는 것입니다. jwt-cpp의 검증기는 표준 등록 클레임인 최상위 exp만 확인하므로, 이렇게 만든 토큰은 서명만 맞으면 영원히 유효합니다. 테스트에서는 한 시간 안에 모든 것이 끝나니 문제가 드러나지 않고, 유출된 토큰이 몇 달 뒤에도 통과하는 것을 보고서야 알게 됩니다. 위처럼 set_expires_at을 쓰면 verify()가 만료된 토큰에 token_verification_exception(“token expired”)을 던지므로, 에러 해결 절의 “Invalid token” 항목처럼 만료를 따로 검사할 필요도 없어집니다. 사용자 ID는 표준 sub(subject) 클레임에 담는 것이 관례입니다.
secret_key_가 비어 있어도 HMAC 서명은 문제없이 만들어진다는 점도 주의해야 합니다. 환경 변수를 빠뜨린 채 서버를 띄우면 빈 문자열로 서명된 토큰을 누구나 만들 수 있게 되므로, 운영 패턴의 “설정 외부화” 단계에서 키가 없거나 너무 짧으면 서버 시작을 거부하는 편이 안전합니다.
세션 인증 처리
void Session::handle_auth_message(const json& msg) {
std::string token = msg["token"];
auto user_id = auth_mgr_.verify_token(token);
if (!user_id) {
send_error("Invalid token");
socket_.close();
return;
}
user_id_ = *user_id;
authenticated_ = true;
// 사용자 정보 로드
auto user_info = db_.get_user(user_id_);
send_response({
{"type", "auth_success"},
{"user", user_info}
});
// 이전 방 목록 로드
auto rooms = db_.get_user_rooms(user_id_);
send_response({
{"type", "room_list"},
{"rooms", rooms}
});
}
인증 실패 시 send_error() 다음 줄에서 바로 socket_.close()를 호출하는 부분은 의도대로 동작하지 않습니다. send_error가 내부적으로 async_write를 시작만 하고 반환하기 때문에, 실제 전송이 일어나기 전에 소켓이 닫혀 클라이언트는 에러 메시지 없이 연결이 끊긴 것만 보게 됩니다. 에러 응답의 쓰기 완료 핸들러에서 소켓을 닫도록 순서를 바꿔야 합니다. 또 인증 전에는 받을 수 있는 메시지 크기와 대기 시간을 엄격하게 제한하는 것이 좋습니다. 인증하지 않은 연결이 무한정 열려 있으면 연결 수만으로 서버 자원을 소모시키는 공격이 가능하므로, 연결 후 몇 초 안에 auth 메시지가 오지 않으면 끊는 타이머를 두는 것이 일반적입니다.
다중 방 관리
RoomManager 구현
class RoomManager {
std::unordered_map<std::string, std::shared_ptr<Room>> rooms_;
std::mutex mutex_;
public:
std::shared_ptr<Room> create_room(
const std::string& name,
const std::string& creator_id
) {
std::lock_guard lock(mutex_);
std::string room_id = generate_uuid();
auto room = std::make_shared<Room>(room_id, name, creator_id);
rooms_[room_id] = room;
// DB에 저장
db_.insert_room(room_id, name, creator_id);
return room;
}
std::shared_ptr<Room> get_room(const std::string& room_id) {
std::lock_guard lock(mutex_);
auto it = rooms_.find(room_id);
return it != rooms_.end() ? it->second : nullptr;
}
void delete_room(const std::string& room_id) {
std::lock_guard lock(mutex_);
rooms_.erase(room_id);
db_.delete_room(room_id);
}
std::vector<RoomInfo> list_rooms() {
std::lock_guard lock(mutex_);
std::vector<RoomInfo> result;
for (const auto& [id, room] : rooms_) {
result.push_back(room->get_info());
}
return result;
}
};
Room 클래스 상세
class Room {
std::string id_;
std::string name_;
std::string creator_id_;
std::set<std::shared_ptr<Session>> participants_;
std::deque<Message> recent_messages_; // 최근 100개
asio::strand<asio::io_context::executor_type> strand_;
public:
void join(std::shared_ptr<Session> session) {
asio::post(strand_, [this, session]() {
participants_.insert(session);
// 입장 알림
broadcast({
{"type", "user_joined"},
{"user_id", session->user_id()},
{"room_id", id_}
});
// 최근 메시지 전송
session->send_history(recent_messages_);
});
}
void leave(std::shared_ptr<Session> session) {
asio::post(strand_, [this, session]() {
participants_.erase(session);
// 퇴장 알림
broadcast({
{"type", "user_left"},
{"user_id", session->user_id()},
{"room_id", id_}
});
});
}
void broadcast(const json& msg) {
asio::post(strand_, [this, msg]() {
std::string data = msg.dump();
for (auto& participant : participants_) {
participant->send(data);
}
// 메시지 히스토리 저장
if (msg["type"] == "message") {
Message m{
msg["user_id"],
msg["content"],
std::time(nullptr)
};
recent_messages_.push_back(m);
if (recent_messages_.size() > 100) {
recent_messages_.pop_front();
}
// DB에 저장
db_.insert_message(id_, m);
}
});
}
};
RoomManager는 뮤텍스로, Room은 strand로 보호하는 구조가 섞여 있는 데는 이유가 있습니다. 방 목록 조회와 생성은 드물고 짧아서 뮤텍스로 충분하지만, 한 방 안의 참가자 목록과 히스토리는 메시지마다 접근되므로 스레드를 막지 않는 strand가 적합합니다. strand 안에서 실행되는 코드끼리는 절대 동시에 실행되지 않으므로 participants_를 락 없이 다룰 수 있습니다. 대신 strand 밖에서 participants_를 직접 읽으면 그 보호가 사라집니다. 예를 들어 방 참가자 수를 보여 주려고 다른 스레드에서 participants_.size()를 호출하면 데이터 레이스입니다.
이 코드에는 수명 문제도 있습니다. 람다가 this만 캡처하므로, delete_room으로 RoomManager가 마지막 shared_ptr을 지운 직후에 strand에 남아 있던 작업이 실행되면 이미 파괴된 Room에 접근합니다. Room도 enable_shared_from_this를 상속하고 [self = shared_from_this()]로 캡처해야 안전합니다. 반대 방향으로는 participants_가 Session을 shared_ptr로 들고 있으므로, 클라이언트 연결이 끊겨도 leave()를 호출하지 않으면 세션 객체와 소켓이 방에 계속 남습니다. 연결 종료 처리(connection_mgr_.stop)에서 세션이 속한 모든 방의 leave()를 호출하는 것을 빠뜨리지 않아야 하며, 그렇지 않으면 끊긴 사용자에게 브로드캐스트하다 Broken pipe 에러가 계속 쌓입니다.
broadcast 안에서 db_.insert_message를 동기로 호출하는 것도 문제입니다. SQLite 쓰기가 디스크 동기화로 수 밀리초 걸리는 동안 이 방의 strand가 멈추고, 스레드 풀의 스레드 하나도 함께 막힙니다. 성능 절의 “DB 쓰기 비동기화”에서 이 부분을 별도 실행기로 옮기는 방법을 다룹니다.
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,
FOREIGN KEY (room_id) REFERENCES rooms(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_messages_room_time
ON messages(room_id, timestamp DESC);
히스토리 조회
class Database {
sqlite3* db_;
public:
std::vector<Message> get_messages(
const std::string& room_id,
size_t count,
int64_t before_timestamp = 0
) {
std::string sql = R"(
SELECT user_id, content, timestamp
FROM messages
WHERE room_id = ?
)";
if (before_timestamp > 0) {
sql += " AND timestamp < ?";
}
sql += " ORDER BY timestamp DESC LIMIT ?";
sqlite3_stmt* stmt;
sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
int idx = 1;
sqlite3_bind_text(stmt, idx++, room_id.c_str(), -1, SQLITE_TRANSIENT);
if (before_timestamp > 0) {
sqlite3_bind_int64(stmt, idx++, before_timestamp);
}
sqlite3_bind_int(stmt, idx++, static_cast<int>(count));
std::vector<Message> messages;
while (sqlite3_step(stmt) == SQLITE_ROW) {
messages.push_back({
reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0)),
reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1)),
sqlite3_column_int64(stmt, 2)
});
}
sqlite3_finalize(stmt);
std::reverse(messages.begin(), messages.end());
return messages;
}
};
페이지네이션
void Session::handle_history_request(const json& msg) {
std::string room_id = msg["room_id"];
size_t count = msg.value("count", 50);
int64_t before = msg.value("before", 0);
auto messages = db_.get_messages(room_id, count, before);
send_response({
{"type", "history"},
{"room_id", room_id},
{"messages", messages},
{"has_more", messages.size() == count}
});
}
timestamp를 페이지네이션 커서로 쓰는 방식에는 함정이 있습니다. 타임스탬프가 초 단위라서, 같은 초에 메시지가 여러 개 저장되면 페이지 경계에서 timestamp < before 조건 때문에 그 초의 나머지 메시지가 통째로 건너뛰어집니다. 활발한 방에서는 스크롤을 올리다 대화 일부가 사라지는 버그로 나타납니다. 자동 증가하는 id를 커서로 쓰거나(WHERE room_id = ? AND id < ? ORDER BY id DESC), (timestamp, id) 쌍으로 비교하면 해결됩니다. 이 경우 인덱스도 (room_id, id)로 맞춰야 합니다.
sqlite3_prepare_v2의 반환값을 확인하지 않는 것도 운영 코드에서는 고쳐야 합니다. 테이블 이름 오타나 스키마 불일치로 준비가 실패하면 stmt가 NULL인 채 sqlite3_bind_*가 호출되고, 에러 메시지는 sqlite3_errmsg(db_)로만 확인할 수 있습니다. 또 count가 클라이언트에서 오는 값이므로 count = 1000000 같은 요청으로 한 번에 수백만 행을 읽게 만들 수 있습니다. 서버에서 상한(예: 100)을 강제해야 합니다.
청크 단위 파일 전송
청크 기반 전송
struct FileTransfer {
std::string file_id;
std::string filename;
size_t total_size;
size_t received_size = 0;
std::ofstream file;
};
void Session::handle_file_upload(const json& msg) {
if (msg["type"] == "file_start") {
std::string file_id = generate_uuid();
std::string filename = msg["filename"];
size_t size = msg["size"];
FileTransfer transfer{
file_id,
filename,
size,
0,
std::ofstream("uploads/" + file_id, std::ios::binary)
};
file_transfers_[file_id] = std::move(transfer);
send_response({
{"type", "file_ready"},
{"file_id", file_id}
});
}
else if (msg["type"] == "file_chunk") {
std::string file_id = msg["file_id"];
std::string data = msg["data"]; // Base64 encoded
auto& transfer = file_transfers_[file_id];
std::vector<uint8_t> chunk = base64_decode(data);
transfer.file.write(
reinterpret_cast<const char*>(chunk.data()),
chunk.size()
);
transfer.received_size += chunk.size();
if (transfer.received_size >= transfer.total_size) {
transfer.file.close();
// 방에 파일 메시지 브로드캐스트
room_->broadcast({
{"type", "file_message"},
{"user_id", user_id_},
{"file_id", file_id},
{"filename", transfer.filename},
{"size", transfer.total_size}
});
file_transfers_.erase(file_id);
}
}
}
Base64로 JSON에 싣는 방식은 기존 텍스트 메시지 경로를 그대로 쓸 수 있다는 장점이 있지만, 데이터 크기가 약 33% 늘고 인코딩·디코딩 비용이 붙습니다. 파일이 크거나 자주 오간다면 WebSocket 바이너리 프레임으로 보내거나, 채팅 서버는 업로드 URL만 발급하고 실제 파일은 HTTP나 객체 스토리지(S3의 사전 서명 URL 등)로 직접 올리게 하는 구조가 서버 부담이 훨씬 적습니다.
이 핸들러를 그대로 쓰면 생기는 문제도 몇 가지 있습니다. file_transfers_[file_id]는 존재하지 않는 file_id로 청크가 오면 빈 항목을 새로 만들어 버리므로, find로 확인하고 없으면 에러를 돌려줘야 합니다. received_size가 total_size를 넘는 청크가 계속 와도 막지 않으므로 클라이언트가 선언한 크기보다 큰 파일을 쓸 수 있습니다. 중간에 연결이 끊기면 uploads/ 폴더에 반쯤 쓰인 파일이 남으므로, 세션 종료 시 미완료 전송을 정리하는 코드가 필요합니다. 파일명은 file_id로 저장하고 원래 이름은 메타데이터로만 보관한 것은 ../../etc/passwd 같은 경로 조작을 피하는 올바른 선택입니다.
재접속 시 세션 복구
세션 복구
class SessionManager {
std::unordered_map<std::string, SessionState> saved_states_;
std::mutex mutex_;
public:
void save_state(const std::string& user_id, const SessionState& state) {
std::lock_guard lock(mutex_);
saved_states_[user_id] = state;
}
std::optional<SessionState> restore_state(const std::string& user_id) {
std::lock_guard lock(mutex_);
auto it = saved_states_.find(user_id);
if (it != saved_states_.end()) {
auto state = it->second;
saved_states_.erase(it);
return state;
}
return std::nullopt;
}
};
void Session::handle_reconnect() {
auto state = session_mgr_.restore_state(user_id_);
if (!state) {
send_error("No saved state");
return;
}
// 이전 방 재입장
for (const auto& room_id : state->room_ids) {
auto room = room_mgr_.get_room(room_id);
if (room) {
room->join(shared_from_this());
}
}
// 놓친 메시지 전송
for (const auto& room_id : state->room_ids) {
auto messages = db_.get_messages(
room_id,
100,
state->last_seen_timestamp
);
send_response({
{"type", "missed_messages"},
{"room_id", room_id},
{"messages", messages}
});
}
}
재연결 복구에서 가장 까다로운 부분은 “놓친 메시지”의 경계입니다. 위 코드는 재입장(join)을 먼저 한 뒤 DB에서 놓친 메시지를 조회하므로, 그 사이에 새로 도착한 메시지가 실시간 브로드캐스트와 DB 조회 결과 양쪽에 모두 들어가 중복 표시될 수 있습니다. 반대로 조회를 먼저 하고 재입장을 나중에 하면 그 틈의 메시지가 누락됩니다. 실무에서는 메시지마다 단조 증가하는 ID를 붙이고, 클라이언트가 이미 받은 ID는 버리게 해 중복을 허용하는 쪽을 택하는 경우가 많습니다. 누락보다 중복이 처리하기 쉽기 때문입니다. 또 get_messages(room_id, 100, last_seen)은 before 조건으로 그 이전 메시지를 가져오는 함수라서, 재연결 후 놓친 메시지(그 이후)를 가져오려면 after 조건을 받는 별도 쿼리가 필요합니다. 원래 예제는 이 방향이 뒤바뀌어 있습니다.
SessionManager가 상태를 메모리에만 두므로 서버가 재시작되면 모든 재연결 정보가 사라지고, 서버가 여러 대라면 다른 서버로 재접속한 클라이언트는 상태를 찾지 못합니다. 사용자가 속한 방 목록과 마지막으로 읽은 메시지 ID는 DB나 Redis에 두는 것이 맞습니다.
서버 기동부터 메시지 루프까지 동작 예제
최소 동작 예제: main 함수와 서버 기동
// chat_server_main.cpp
#include <boost/asio.hpp>
#include <iostream>
#include <memory>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
int main(int argc, char* argv[]) {
try {
asio::io_context io_context;
tcp::acceptor acceptor(io_context, tcp::endpoint(tcp::v4(), 9000));
RoomManager room_mgr_; // (앞 절에서 정의한 클래스)
Database db_;
// 람다가 자기 자신을 재귀 호출하려면 std::function으로 선언해야 함
std::function<void()> do_accept;
do_accept = [&]() {
acceptor.async_accept(
[&](boost::system::error_code ec, tcp::socket socket) {
if (!ec) {
// 새 세션 생성 및 시작
auto session = std::make_shared<Session>(
std::move(socket), room_mgr_, db_
);
session->start();
}
do_accept(); // 다음 연결 대기
});
};
do_accept();
std::cout << "Chat server listening on port 9000\n";
io_context.run();
// 참고: auto do_accept = [&]() { ... do_accept(); };로 쓰면
// "use of 'do_accept' before deduction of 'auto'" 컴파일 에러가 남
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << "\n";
return 1;
}
return 0;
}
클라이언트-서버 메시지 프로토콜 예제
// 1. 인증 요청 (클라이언트 → 서버)
{"type": "auth", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
// 2. 인증 성공 (서버 → 클라이언트)
{"type": "auth_success", "user": {"id": "user1", "name": "홍길동"}}
// 3. 방 입장 요청
{"type": "join_room", "room_id": "room-abc-123"}
// 4. 메시지 전송
{"type": "message", "room_id": "room-abc-123", "content": "안녕하세요!"}
// 5. 히스토리 요청 (페이지네이션)
{"type": "history", "room_id": "room-abc-123", "count": 50, "before": 1709876543}
WebSocket 핸드셰이크 후 메시지 처리 루프
void Session::do_read() {
auto self(shared_from_this());
socket_.async_read_some(
asio::buffer(buffer_),
[this, self](boost::system::error_code ec, std::size_t length) {
if (!ec) {
std::string data(buffer_.data(), length);
auto msg = json::parse(data);
std::string type = msg["type"];
if (type == "auth") {
handle_auth_message(msg);
} else if (!authenticated_) {
send_error("Not authenticated");
return;
} else if (type == "join_room") {
handle_join_room(msg);
} else if (type == "message") {
handle_message(msg);
} else if (type == "history") {
handle_history_request(msg);
} else if (type == "file_start" || type == "file_chunk") {
handle_file_upload(msg);
} else if (type == "reconnect") {
handle_reconnect();
}
do_read(); // 다음 메시지 대기
} else {
connection_mgr_.stop(shared_from_this());
}
});
}
이 루프는 흐름을 보여 주기 위한 단순화이며, 제목과 달리 실제로는 WebSocket이 아니라 생 TCP 소켓을 읽고 있습니다. 여기서 가장 중요한 결함은 async_read_some의 결과를 메시지 하나로 가정하고 바로 json::parse한다는 점입니다. TCP는 바이트 스트림이라 메시지 경계를 보존하지 않으므로, 클라이언트가 JSON 두 개를 연달아 보내면 한 번에 붙어서 오고, 큰 메시지는 여러 번에 나뉘어 옵니다. 로컬에서는 거의 항상 한 번에 오기 때문에 테스트를 통과하다가, 실제 네트워크에서 간헐적으로 [json.exception.parse_error.101] parse error ... unexpected end of input 예외가 나는 전형적인 버그입니다. 더구나 이 예외는 완료 핸들러 밖으로 빠져나가 io_context.run()까지 전파되므로, 클라이언트 한 명의 잘못된 메시지가 서버 전체를 종료시킬 수 있습니다.
해결책은 프레이밍을 정하는 것입니다. 줄바꿈으로 메시지를 구분한다면 asio::async_read_until(socket_, streambuf_, '\n', ...)으로 한 줄씩 읽고, 바이너리 데이터가 섞인다면 4바이트 길이 접두사를 먼저 async_read로 읽은 뒤 그 길이만큼 다시 읽습니다. 브라우저 클라이언트를 받는다면 Boost.Beast의 websocket::stream을 쓰는 것이 가장 간단한데, async_read가 WebSocket 메시지 하나를 통째로 모아 전달해 주기 때문입니다. 어느 방식이든 json::parse는 try로 감싸거나 json::parse(data, nullptr, false)로 예외 없이 파싱해 실패를 확인하고, 메시지 최대 크기를 제한해야 합니다. 또 인증되지 않은 메시지를 받았을 때 return으로 빠져나가면 do_read()가 다시 호출되지 않아 세션이 아무 말 없이 멈추므로, 에러를 보낸 뒤 연결을 명시적으로 닫는 편이 상태를 예측하기 쉽습니다.
JWT 검증 실패, 순환 참조, SQLITE_BUSY: 에러 해결
”Invalid token” / JWT 검증 실패
증상: 클라이언트가 토큰을 보냈는데 서버가 “Invalid token”을 반환합니다. 원인:
- 토큰 만료 (exp 초과)
- 시크릿 키 불일치 (서버 재시작 시 환경 변수 누락)
- Base64 디코딩 오류 (토큰에 공백/줄바꿈 포함) 해결법:
// ✅ 토큰 검증 시 만료 시간 체크
std::optional<std::string> verify_token(const std::string& token) {
try {
auto decoded = jwt::decode(token);
auto exp = decoded.get_expires_at();
if (exp && std::chrono::system_clock::now() > *exp) {
return std::nullopt; // 만료됨
}
// ... 나머지 검증
} catch (const std::exception& e) {
spdlog::warn("JWT verify failed: {}", e.what());
return std::nullopt;
}
}
JWT 인증 절처럼 토큰을 표준 exp 클레임으로 만들었다면 verifier.verify()가 만료를 이미 검사하므로 이 수동 비교는 필요 없습니다. 이 코드에서 실제로 유용한 부분은 e.what()을 로그로 남기는 것입니다. jwt-cpp의 예외 메시지가 “token expired”, “invalid signature”, “decoding failed” 중 무엇인지에 따라 원인이 클라이언트의 오래된 토큰인지, 서버 간 시크릿 불일치인지, 전송 중 토큰이 잘린 것인지 바로 구분됩니다. 클라이언트에 보내는 응답에는 이 상세 이유를 담지 말고 “Invalid token” 하나로 통일하는 편이 안전합니다.
메모리가 계속 늘어남 / shared_ptr 순환 참조
증상: 연결이 끊긴 세션의 소멸자가 호출되지 않고, 접속과 종료를 반복할수록 메모리 사용량이 늘어납니다. 순환 참조는 크래시가 아니라 누수로 나타나며, Session 소멸자에 로그를 찍어 보면 끊긴 세션이 파괴되지 않는 것을 확인할 수 있습니다.
원인: Room이 Session을 shared_ptr로 보관하며, Session이 Room을 shared_ptr로 보관하면 순환 참조가 됩니다.
해결법:
// ❌ 잘못된 예: Room이 Session을 shared_ptr로, Session이 Room을 shared_ptr로
class Room {
std::set<std::shared_ptr<Session>> participants_; // Session 소유
};
class Session {
std::shared_ptr<Room> room_; // Room 소유 → 순환!
};
// ✅ 올바른 예: Session은 Room을 weak_ptr로 참조
class Session {
std::weak_ptr<Room> room_; // 소유하지 않고 참조만
};
weak_ptr로 바꾼 뒤에는 방에 접근할 때마다 if (auto room = room_.lock())로 살아 있는지 확인해야 합니다. 어느 쪽을 weak_ptr로 둘지는 “누가 누구의 수명을 결정하는가”로 정합니다. 방은 참가자가 모두 나가도 남아 있어야 하고 세션은 방에 머무는 동안만 의미가 있으므로, 방이 세션을 강하게 들고 세션은 방을 약하게 참조하는 구조가 자연스럽습니다. 다만 이것만으로 끝나지 않고, 연결 종료 시 방에서 leave()를 호출해 방이 들고 있는 강한 참조를 끊어야 세션이 실제로 파괴됩니다.
”Connection reset by peer” / 비정상 종료
증상: 클라이언트가 갑자기 끊기고 서버 로그에 “Connection reset”이 찍힙니다.
원인: 클라이언트가 close() 없이 프로세스를 종료했거나, 네트워크 불안정입니다.
해결법:
// ✅ 에러 시 graceful shutdown
void Session::do_read() {
socket_.async_read_some(asio::buffer(buffer_),
[this, self = shared_from_this()](error_code ec, size_t length) {
if (ec) {
if (ec != asio::error::operation_aborted) {
spdlog::info("Client disconnected: {}", ec.message());
}
connection_mgr_.stop(self);
return;
}
// ... 처리
do_read();
});
}
“SQLITE_BUSY” / DB 락 충돌
증상: 메시지 저장 시 SQLITE_BUSY 에러가 발생합니다.
원인: SQLite는 기본적으로 한 번에 하나의 쓰기만 허용합니다. 여러 스레드가 동시에 INSERT하면 락이 걸립니다.
해결법:
// ✅ WAL 모드 + busy_timeout 설정
sqlite3_exec(db_, "PRAGMA journal_mode=WAL;", nullptr, nullptr, nullptr);
sqlite3_busy_timeout(db_, 5000); // 5초 대기
// 또는 쓰기 전용 connection pool 사용
WAL 모드는 쓰기 중에도 읽기가 막히지 않게 해 주지만, 쓰기끼리는 여전히 한 번에 하나만 가능합니다. busy_timeout은 락이 풀릴 때까지 기다려 주는 장치일 뿐이라, 쓰기가 계속 몰리면 대기 시간이 늘어나고 결국 타임아웃 뒤 같은 에러가 납니다. 그래서 채팅처럼 쓰기가 잦은 서버에서는 연결을 여러 개 두기보다, 쓰기 전용 스레드 하나가 큐에서 메시지를 꺼내 모아서 한 트랜잭션으로 저장하는 구조가 가장 안정적입니다. 메시지 100개를 트랜잭션 하나로 묶으면 커밋마다 일어나는 디스크 동기화가 한 번으로 줄어 처리량도 크게 올라갑니다. 또 여러 스레드가 한 sqlite3* 연결을 공유하려면 SQLite가 serialized 모드로 빌드돼 있어야 하므로, 스레드마다 연결을 따로 열거나 연결 접근을 한 스레드로 모으는 편이 안전합니다.
”Address already in use” / 포트 충돌
증상: 서버 기동 시 bind: Address already in use 에러가 납니다.
원인: 이전 프로세스가 아직 종료되지 않았거나, SO_REUSEADDR 미설정입니다.
해결법:
// ✅ SO_REUSEADDR 설정
acceptor_.open(endpoint.protocol());
acceptor_.set_option(asio::socket_base::reuse_address(true));
acceptor_.bind(endpoint);
acceptor_.listen();
파일 업로드 시 “No space left on device”
증상: 대용량 파일 업로드 중 디스크 풀 에러가 발생합니다. 원인: 업로드 디렉터리 용량이 부족하거나, 업로드 전에 크기를 검증하지 않았습니다. 해결법:
// ✅ 업로드 전 크기 제한 (예: 50MB)
constexpr size_t MAX_FILE_SIZE = 50 * 1024 * 1024;
if (msg["size"].get<size_t>() > MAX_FILE_SIZE) {
send_error("File too large");
return;
}
// 디스크 여유 공간 확인
namespace fs = std::filesystem;
auto space = fs::space("uploads/");
if (space.available < msg["size"].get<size_t>()) {
send_error("Insufficient storage");
return;
}
쓰기 큐·비동기 DB·방 단위 strand로 성능 올리기
메시지 큐로 쓰기 직렬화
한 세션에서 async_write를 동시에 여러 번 호출하면 데이터가 섞입니다. 메시지 큐로 직렬화합니다.
class MessageQueue {
std::deque<std::string> queue_;
bool writing_ = false;
public:
void push(const std::string& msg) {
queue_.push_back(msg);
if (!writing_) {
do_write();
}
}
void do_write() {
if (queue_.empty()) {
writing_ = false;
return;
}
writing_ = true;
auto& msg = queue_.front();
asio::async_write(socket_, asio::buffer(msg),
[this](error_code ec, size_t) {
queue_.pop_front();
if (!ec) {
do_write();
} else {
writing_ = false;
}
});
}
};
이 큐가 안전하려면 push와 완료 핸들러가 같은 strand에서 실행돼야 합니다. queue_와 writing_에 락이 없으므로, 다른 방의 strand에서 push가 동시에 호출되면 데이터 레이스가 됩니다. 브로드캐스트하는 쪽에서는 asio::post(session_strand_, [self, msg] { self->queue_.push(msg); })처럼 세션의 strand로 넘겨 넣는 것이 일반적입니다. asio::buffer(msg)가 큐 안 문자열을 참조하므로 쓰기가 끝날 때까지 pop_front하지 않는 것도 중요합니다. std::deque는 앞뒤에 원소를 추가해도 기존 원소의 참조가 유지되므로 쓰기 중에 push_back해도 안전하지만, std::vector로 바꾸면 재할당 때 버퍼가 이동해 전송 중인 데이터가 깨집니다.
DB 쓰기 비동기화
메시지 저장을 동기로 하면 브로드캐스트가 블로킹됩니다. 별도 스레드 풀에서 DB 쓰기를 수행합니다.
// DB 쓰기를 thread pool로 오프로드
void Room::broadcast(const json& msg) {
// 1. 먼저 브로드캐스트 (빠른 경로)
std::string data = msg.dump();
for (auto& p : participants_) {
p->send(data);
}
// 2. DB 저장은 나중에 (백그라운드)
if (msg["type"] == "message") {
db_executor_.post([this, msg]() {
db_.insert_message(id_, Message{...});
});
}
}
메모리 풀 for 메시지 버퍼
매 메시지마다 new char[size]를 하면 할당 오버헤드가 큽니다. 객체 풀 또는 고정 크기 버퍼를 재사용합니다.
// 고정 크기 버퍼 풀: 소유권을 unique_ptr로 주고받음
class BufferPool {
using Buffer = std::array<char, 4096>;
std::vector<std::unique_ptr<Buffer>> pool_;
std::mutex mutex_;
public:
std::unique_ptr<Buffer> acquire() {
std::lock_guard lock(mutex_);
if (pool_.empty()) {
return std::make_unique<Buffer>();
}
auto buf = std::move(pool_.back());
pool_.pop_back();
return buf;
}
void release(std::unique_ptr<Buffer> buf) {
std::lock_guard lock(mutex_);
pool_.push_back(std::move(buf)); // 버퍼를 풀에 반환
}
};
이 풀의 원래 버전은 pool_.back()의 참조를 받은 직후 pop_back()으로 그 원소를 파괴하고, 파괴된 배열을 가리키는 span을 반환했습니다. 반환된 버퍼에 쓰는 순간 이미 해제된 메모리를 건드리는 use-after-free이며, 풀이 비어 있지 않을 때만 조건부로 드러나서 재현이 어렵습니다. 위처럼 unique_ptr로 소유권을 넘기면 풀에서 꺼낸 버퍼의 수명이 명확해집니다. 참고로 버퍼 풀은 std::string의 할당이 프로파일러에서 실제 병목으로 확인된 뒤에 도입해도 늦지 않습니다. 현대의 메모리 할당기(glibc malloc, jemalloc 등)는 작은 크기의 할당을 스레드별 캐시로 빠르게 처리하므로, 풀을 두면 오히려 뮤텍스 경합이 새 병목이 되는 경우도 있습니다.
방 단위 strand 분리
모든 방이 하나의 strand를 쓰면 한 방의 브로드캐스트가 다른 방을 블로킹합니다. 방마다 별도 strand를 두면 병렬성이 올라갑니다.
class Room {
asio::strand<asio::io_context::executor_type> strand_;
// 각 Room이 자신만의 strand를 가짐
};
연결 수 제한
무제한 연결을 허용하면 메모리와 파일 디스크립터가 고갈됩니다. 최대 연결 수를 두고 초과 시 거부합니다.
class ConnectionManager {
std::set<std::shared_ptr<Session>> sessions_;
static constexpr size_t MAX_CONNECTIONS = 10000;
public:
bool try_add(std::shared_ptr<Session> session) {
if (sessions_.size() >= MAX_CONNECTIONS) {
return false;
}
sessions_.insert(session);
return true;
}
};
부하 분산·헬스 체크·설정 외부화
부하 분산 (Round-Robin)
class LoadBalancer {
std::vector<std::shared_ptr<ChatServer>> servers_;
std::atomic<size_t> next_server_{0};
public:
std::shared_ptr<ChatServer> get_server() {
size_t idx = next_server_.fetch_add(1) % servers_.size();
return servers_[idx];
}
};
이 라운드 로빈은 개념을 보여 주는 코드일 뿐, 채팅 서버를 여러 대로 늘리는 데 필요한 핵심은 아닙니다. 실제로 연결을 나누는 일은 앞단의 L4/L7 로드 밸런서(Nginx, HAProxy, 클라우드 LB)가 하고, 채팅 서버 쪽에서 풀어야 하는 문제는 서버 사이의 메시지 전달입니다. 같은 방의 참가자가 서로 다른 서버에 붙어 있으면 한 서버의 Room::broadcast는 다른 서버의 참가자를 모릅니다. 보통은 모든 서버가 Redis Pub/Sub이나 NATS의 방 채널을 구독하고, 메시지를 받으면 브로커에 발행해 각 서버가 자기 쪽 참가자에게 전달하게 만듭니다. 장기 연결이라 서버를 추가해도 기존 연결은 재분배되지 않는다는 점도 운영 시 알아 둬야 합니다.
헬스 체크
// health_timer_는 ChatServer 멤버: asio::steady_timer health_timer_{io_context_};
void ChatServer::start_health_check() {
health_timer_.expires_after(std::chrono::seconds(30));
health_timer_.async_wait([this](error_code ec) {
if (ec) return; // 타이머 취소(서버 종료) 시 중단
spdlog::info("Connections: {}, Rooms: {}",
connection_mgr_.size(), room_mgr_.size());
start_health_check(); // 다음 주기 예약
});
}
이 코드의 원래 버전은 timer와 check를 함수의 지역 변수로 만들고 람다에서 참조로 캡처했습니다. start_health_check()가 반환되는 순간 두 지역 변수는 파괴되는데, 타이머 파괴는 대기 중인 작업을 취소하고, 설령 핸들러가 실행되더라도 이미 사라진 check를 호출하게 됩니다. 비동기 코드에서 [&] 캡처가 거의 항상 위험한 이유가 이것입니다. 타이머를 멤버로 두고 멤버 함수가 자신을 다시 예약하는 형태가 가장 단순하고 안전합니다.
그레이스풀 셧다운
void ChatServer::stop() {
// io_context 스레드에서 실행되도록 post
asio::post(io_context_, [this] {
acceptor_.close(); // 1. 새 연결 거부
health_timer_.cancel(); // 2. 주기 작업 중단
connection_mgr_.stop_all(); // 3. 각 세션 소켓 닫기 → 대기 중인 읽기/쓰기가 operation_aborted로 끝남
});
// 남은 작업이 없어지면 io_context_.run()이 스스로 반환됨
}
원래 버전은 io_context_.stop()을 호출한 뒤에 세션 수가 0이 될 때까지 sleep하며 기다렸습니다. 그런데 stop()은 이벤트 루프를 즉시 멈추므로, 세션 정리를 담당하는 완료 핸들러들이 더 이상 실행되지 않아 세션 수가 영원히 줄지 않고 루프가 끝나지 않습니다. 종료는 “새 작업을 만들지 않고, 진행 중인 작업을 닫고, 작업이 모두 끝나 run()이 자연히 반환되게 한다”는 순서로 짜야 합니다. 보내던 메시지를 끝까지 전송하고 싶다면 소켓을 바로 닫는 대신 각 세션의 송신 큐가 비면 닫게 하고, 무한히 기다리지 않도록 전체 종료에 타임아웃을 둡니다.
로깅 및 메트릭
// 구조화된 로깅
spdlog::info("msg_sent room={} user={} size={}",
room_id, user_id, msg.size());
// Prometheus 메트릭 (예시)
metrics::counter messages_sent_total;
metrics::gauge active_connections;
설정 외부화
// config.json 또는 환경 변수
struct ServerConfig {
int port = 9000;
size_t max_connections = 10000;
size_t max_file_size = 50 * 1024 * 1024;
std::string db_path = "chat.db";
std::string jwt_secret;
};
ServerConfig load_config() {
ServerConfig cfg;
if (const char* p = std::getenv("CHAT_PORT")) {
cfg.port = std::stoi(p);
}
if (const char* s = std::getenv("JWT_SECRET")) {
cfg.jwt_secret = s;
}
return cfg;
}
배포 전 운영 점검표
| 항목 | 확인 |
|---|---|
| JWT 시크릿 환경 변수로 관리 | ☐ |
| DB 백업 스케줄 설정 | ☐ |
| 로그 로테이션 (logrotate) | ☐ |
| 최대 연결 수 제한 | ☐ |
| 파일 업로드 크기 제한 | ☐ |
| SSL/TLS 적용 (wss://) | ☐ |
| 헬스 체크 엔드포인트 | ☐ |
| 그레이스풀 셧다운 | ☐ |
기능별 구현 방법 요약
| 기능 | 구현 방법 |
|---|---|
| 인증 | JWT 토큰 |
| 방 관리 | RoomManager + strand |
| 히스토리 | SQLite + 페이지네이션 |
| 파일 전송 | 청크 기반 + Base64 |
| 재연결 | 세션 상태 저장/복구 |
핵심 원칙:
- 모든 상태 변경은 strand로 직렬화
- 메시지는 DB에 저장하여 영속성 보장
- 파일은 청크 단위로 전송
- 재연결 시 놓친 메시지 전송 (중복은 메시지 ID로 걸러 냄)
- 여러 서버로 확장할 때는 메시지 브로커로 서버 간 브로드캐스트