C++ 채팅 서버 완성하기 | 인증·방 관리·메시지 히스토리 구현 [#50-1]
이 글의 핵심
C++ 채팅 서버 완성하기: 인증·방 관리·메시지 히스토리 구현 [#50-1]. 실무에서 겪는 채팅 서버 이슈·전체 아키텍처.
들어가며: “기본 채팅 서버에 실전 기능을 추가하고 싶어요”
실전 채팅 서버의 요구사항
기본 브로드캐스트 채팅 서버를 만들었다면, 이제 사용자 인증, 여러 방 관리, 메시지 히스토리, 파일 전송, 재연결 처리 등 실무에서 필요한 기능을 추가해야 합니다. 목표:
- 사용자 인증 및 세션 관리
- 다중 방(채널) 생성 및 입장/퇴장
- 메시지 히스토리 저장 및 조회
- 파일 전송 프로토콜
- 재연결 시 상태 복구 요구 환경: C++17 이상, Boost.Asio, SQLite 또는 PostgreSQL 이 글을 읽으면:
- 실전 채팅 서버 아키텍처를 이해할 수 있습니다.
- 사용자 인증 및 세션 관리를 구현할 수 있습니다.
- 다중 방 관리 시스템을 만들 수 있습니다.
- 메시지 히스토리를 DB에 저장하고 조회할 수 있습니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
문제 시나리오: 실무에서 겪는 채팅 서버 이슈
시나리오 1: “누가 보낸 메시지인지 알 수 없어요”
기본 채팅 서버는 연결만 받고 사용자 식별이 없습니다. “안녕하세요” 메시지가 왔을 때 누가 보냈는지 알 수 없어, 답장이나 멘션(@username) 기능을 구현할 수 없습니다. 해결: JWT 기반 인증으로 연결 시점에 사용자 ID를 확정하고, 모든 메시지에 user_id를 포함합니다.
시나리오 2: “방이 하나뿐이라 대화가 뒤섞여요”
단일 방만 있으면 팀 A와 팀 B 대화가 한곳에 섞입니다. 프로젝트별, 채널별로 다중 방이 필요합니다. 해결: RoomManager로 방 생성/삭제/입장/퇴장을 관리하고, 각 방마다 독립적인 참가자 목록과 히스토리를 유지합니다.
시나리오 3: “나중에 들어온 사람이 이전 대화를 못 봐요”
새로 입장한 사용자에게 최근 N개 메시지를 보내주지 않으면 대화 맥락을 놓칩니다. 해결: 메시지를 DB에 저장하고, 입장 시 get_messages(room_id, 100)로 최근 100개를 조회해 전송합니다.
시나리오 4: “네트워크 끊김 후 재접속하면 방 목록이 사라져요”
모바일에서는 네트워크가 자주 끊깁니다. 재연결 시 이전에 있던 방 목록과 놓친 메시지를 복구해야 합니다. 해결: 세션 상태(현재 방 목록, 마지막 확인 시각)를 서버에 저장하고, 재연결 시 복구합니다.
시나리오 5: “대용량 파일 전송 시 메모리 폭발”
10MB 파일을 한 번에 메모리에 올리면 여러 클라이언트가 동시에 업로드할 때 OOM이 발생합니다. 해결: 청크 단위(예: 64KB)로 나눠 전송하고, Base64 인코딩으로 JSON에 실어 보냅니다.
시나리오 6: “동시 접속 1만 명에서 브로드캐스트가 느려요”
한 방에 1000명이 있을 때 for (auto& p : participants_) p->send(msg)를 순차 실행하면 지연이 누적됩니다. 해결: strand로 직렬화하되, async_write를 병렬로 걸고, 필요 시 여러 서버로 부하 분산합니다.
개념을 잡는 비유
소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.
1. 전체 아키텍처
시스템 구성
flowchart TB
Client["Client\n(WebSocket)"]
subgraph ChatServer[Chat Server]
CM["\"Connection Manager\n- 세션 관리\n- 인증 처리\""]
RM["\"Room Manager\n- 방 생성/삭제\n- 참가자 관리\""]
MR["\"Message Router\n- 메시지 라우팅\n- 브로드캐스트\""]
end
DB["\"Database\n- 사용자 정보\n- 메시지 히스토리\n- 방 정보\""]
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);
};
2. 사용자 인증 시스템
JWT 기반 인증
class AuthManager {
std::string secret_key_;
public:
std::string generate_token(const std::string& user_id) {
// JWT 토큰 생성
json payload = {
{"user_id", user_id},
{"exp", std::time(nullptr) + 3600} // 1시간
};
return jwt::create()
.set_payload_claim("data", jwt::claim(payload.dump()))
.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_});
verifier.verify(decoded);
auto payload = json::parse(
decoded.get_payload_claim("data").as_string()
);
return payload[user_id];
} catch (const std::exception&) {
return std::nullopt;
}
}
};
세션 인증 처리
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}
});
}
3. 다중 방 관리
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);
}
});
}
};
4. 메시지 히스토리
데이터베이스 스키마
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}
});
}
5. 파일 전송
청크 기반 전송
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);
}
}
}
6. 재연결 처리
세션 복구
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}
});
}
}
7. 완전한 채팅 서버 예제
최소 동작 예제: 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));
auto 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();
} 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());
}
});
}
8. 자주 발생하는 에러와 해결법
에러 1: “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;
}
}
에러 2: “double free” / shared_ptr 순환 참조
증상: 프로그램이 크래시하거나 메모리 누수가 발생합니다.
원인: 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_; // 소유하지 않고 참조만
};
에러 3: “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();
});
}
에러 4: “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 사용
에러 5: “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();
에러 6: 파일 업로드 시 “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;
}
9. 성능 최적화 팁
팁 1: 메시지 큐로 쓰기 직렬화
한 세션에서 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;
}
});
}
};
팁 2: 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{...});
});
}
}
팁 3: 메모리 풀 for 메시지 버퍼
매 메시지마다 new char[size]를 하면 할당 오버헤드가 큽니다. 객체 풀 또는 고정 크기 버퍼를 재사용합니다.
// 고정 크기 버퍼 풀
class BufferPool {
std::vector<std::array<char, 4096>> pool_;
std::mutex mutex_;
public:
std::span<char> acquire() {
std::lock_guard lock(mutex_);
if (pool_.empty()) {
pool_.emplace_back();
}
auto& buf = pool_.back();
pool_.pop_back();
return std::span(buf);
}
void release(std::span<char> s) {
std::lock_guard lock(mutex_);
// 버퍼를 풀에 반환
}
};
팁 4: 방 단위 strand 분리
모든 방이 하나의 strand를 쓰면 한 방의 브로드캐스트가 다른 방을 블로킹합니다. 방마다 별도 strand를 두면 병렬성이 올라갑니다.
class Room {
asio::strand<asio::io_context::executor_type> strand_;
// 각 Room이 자신만의 strand를 가짐
};
팁 5: 연결 수 제한
무제한 연결을 허용하면 메모리와 파일 디스크립터가 고갈됩니다. 최대 연결 수를 두고 초과 시 거부합니다.
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;
}
};
10. 프로덕션 패턴
패턴 1: 부하 분산 (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];
}
};
패턴 2: 헬스 체크
// 주기적으로 서버 상태 확인
void ChatServer::start_health_check() {
asio::steady_timer timer(io_context_);
std::function<void()> check;
check = [&]() {
timer.expires_after(std::chrono::seconds(30));
timer.async_wait([&](error_code ec) {
if (!ec) {
spdlog::info("Connections: {}, Rooms: {}",
connection_mgr_.size(), room_mgr_.size());
check();
}
});
};
check();
}
패턴 3: 그레이스풀 셧다운
void ChatServer::stop() {
acceptor_.close();
connection_mgr_.stop_all();
io_context_.stop();
// 모든 세션이 정리될 때까지 대기
while (connection_mgr_.size() > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
패턴 4: 로깅 및 메트릭
// 구조화된 로깅
spdlog::info("msg_sent room={} user={} size={}",
room_id, user_id, msg.size());
// Prometheus 메트릭 (예시)
metrics::counter messages_sent_total;
metrics::gauge active_connections;
패턴 5: 설정 외부화
// 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://) | ☐ |
| 헬스 체크 엔드포인트 | ☐ |
| 그레이스풀 셧다운 | ☐ |
핵심 구현 재확인
아래는 앞선 장에서 다룬 패턴을 한 번에 다시 보는 용도입니다. 새로운 개념이라기보다, 배포 전에 구조를 점검할 때 참고하시면 됩니다.
1. 메시지 큐 관리 (재확인)
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;
}
});
}
};
2. 부하 분산 (재확인)
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];
}
};
정리
| 기능 | 구현 방법 |
|---|---|
| 인증 | JWT 토큰 |
| 방 관리 | RoomManager + strand |
| 히스토리 | SQLite + 페이지네이션 |
| 파일 전송 | 청크 기반 + Base64 |
| 재연결 | 세션 상태 저장/복구 |
| 핵심 원칙: |
- 모든 상태 변경은 strand로 직렬화
- 메시지는 DB에 저장하여 영속성 보장
- 파일은 청크 단위로 전송
- 재연결 시 놓친 메시지 전송
- 부하 분산으로 확장성 확보
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가? 이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 실시간 채팅 서비스, 게임 로비 시스템, 협업 도구, IoT 디바이스 통신 등 다중 클라이언트 실시간 메시징이 필요한 모든 서비스에 활용합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다. 한 줄 요약: 인증, 방 관리, 히스토리, 파일 전송, 재연결 처리로 실전 채팅 서버를 완성할 수 있습니다. 다음 글: [C++ 실전 가이드 #50-2] REST API 서버 만들기: 라우팅·미들웨어·인증 이전 글: [C++ 실전 가이드 #49-3] Asio 데드락 디버깅
관련 글
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「C++ 채팅 서버 완성하기 | 인증·방 관리·메시지 히스토리 구현 [#50-1]」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ 채팅 서버 완성하기 | 인증·방 관리·메시지 히스토리 구현 [#50-1]」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 채팅 서버 아키텍처 완벽 가이드 | Acceptor-Worker·방 관리·메시지 라우팅·커넥션 풀
- C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]
- C++ WebSocket 심화 가이드 | 핸드셰이크·프레임·Ping/Pong·에러·프로덕션 패턴
이 글에서 다루는 키워드 (관련 검색어)
C++, 채팅서버, Asio, 인증, 방관리, 실전프로젝트 등으로 검색하시면 이 글이 도움이 됩니다.