C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지
이 글의 핵심
C++ 멀티스레드 네트워크 서버 완벽 가이드에 대한 실전 가이드입니다. 개념부터 실무 활용까지 예제와 함께 상세히 설명합니다.
들어가며: “멀티스레드 서버에서 data race가 발생해요”
문제 상황
// ❌ 문제: 여러 스레드에서 run()을 돌리면 data race 발생
boost::asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
// 4개 스레드에서 동시에 run()
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&io]() { io.run(); });
}
// 연결 A의 read 완료 → 스레드 1에서 처리
// 연결 A의 write 완료 → 스레드 3에서 처리
// 💥 같은 소켓을 여러 스레드가 동시에 건드림 → 프로토콜 꼬임!
왜 이런 일이 발생할까요?
io_context::run()을 여러 스레드에서 호출하면, 완료된 비동기 핸들러가 임의의 스레드에 분배됩니다. 한 연결의 async_read_some 완료 핸들러가 스레드 1에서, async_write 완료 핸들러가 스레드 3에서 실행되면, 같은 소켓/버퍼를 동시에 접근하여 data race가 발생합니다.
추가 문제:
- 공유 데이터(연결 목록, 채팅 방)를 여러 스레드가 동시에 수정 → race condition
- 세션 객체가 비동기 연산 완료 전에 소멸 → use-after-free
- strand 없이 read/write 순서가 뒤섞임 → 프로토콜 오류
추가 문제 시나리오
| 시나리오 | 증상 | 원인 |
|---|---|---|
| 채팅 서버 | A가 보낸 메시지가 B보다 늦게 도착 | read/write 핸들러가 다른 스레드에서 실행되어 순서 꼬임 |
| 게임 서버 | 플레이어 위치가 순간이동처럼 튐 | 공유 게임 상태를 뮤텍스 없이 동시 수정 |
| 파일 전송 | 전송 중 크래시, 데이터 손상 | 버퍼를 읽는 동안 다른 스레드가 같은 버퍼에 쓰기 |
| 연결 폭주 | 서버 다운, 메모리 폭발 | 세션 수명 관리 실패로 use-after-free 또는 메모리 누수 |
해결책:
- strand: 한 연결에 대한 모든 연산을 순서대로 실행
- shared_ptr 세션: 비동기 핸들러에서 수명 유지
- 뮤텍스/strand: 공유 상태 동기화
목표:
- 스레드 풀에서 run() 호출
- strand로 연결별 순서 보장
- 연결별 세션 객체와 수명 관리
- 공유 자원 동기화 (뮤텍스 vs strand)
요구 환경: Boost.Asio 1.70 이상
이 글을 읽으면:
- 멀티스레드 Asio 서버의 올바른 구조를 이해할 수 있습니다.
- data race 없이 안전한 서버를 구현할 수 있습니다.
- 프로덕션 수준의 스레드 풀 서버를 만들 수 있습니다.
개념을 잡는 비유
소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.
목차
- 시스템 아키텍처
- 여러 스레드에서 run
- strand로 순서 보장
- 세션과 수명 관리
- 공유 상태 동기화
- 완전한 스레드 풀 서버 예시
- 채팅 서버·HTTP 스타일 서버 예시
- 일반적인 에러와 해결법
- 성능 벤치마크
- 프로덕션 배포 가이드
1. 시스템 아키텍처
전체 구조
flowchart TB
subgraph Clients["클라이언트들"]
C1[클라이언트 1]
C2[클라이언트 2]
C3[클라이언트 N]
end
subgraph Server["멀티스레드 서버"]
Acceptor[acceptor]
IO[io_context]
subgraph ThreadPool["스레드 풀"]
T1[스레드 1]
T2[스레드 2]
T3[스레드 3]
T4[스레드 4]
end
subgraph Sessions["세션들"]
S1[세션 A + strand]
S2[세션 B + strand]
S3[세션 C + strand]
end
Shared[공유 상태\n채팅방/연결목록]
end
C1 --> Acceptor
C2 --> Acceptor
C3 --> Acceptor
Acceptor --> S1
Acceptor --> S2
Acceptor --> S3
IO --> T1
IO --> T2
IO --> T3
IO --> T4
S1 --> IO
S2 --> IO
S3 --> IO
S1 -.->|strand로 보호| Shared
S2 -.->|strand로 보호| Shared
S3 -.->|strand로 보호| Shared
style IO fill:#4caf50
style Shared fill:#ff9800
스레딩 모델
sequenceDiagram
participant Main as 메인 스레드
participant IO as io_context
participant T1 as 스레드 1
participant T2 as 스레드 2
participant T3 as 스레드 3
Main->>IO: work_guard 생성
Main->>T1: run()
Main->>T2: run()
Main->>T3: run()
Note over T1,T3: 핸들러가 임의의 스레드에 분배됨
IO->>T1: 연결 A read 완료 → 핸들러 실행
IO->>T3: 연결 B write 완료 → 핸들러 실행
IO->>T2: 연결 A write 완료 → 핸들러 실행
Note over T1,T3: strand 사용 시: 같은 연결의 핸들러는 순서대로 실행
핵심: 같은 io_context에 대해 여러 스레드가 run()을 호출하면, Asio가 완료된 핸들러를 라운드 로빈 등으로 분배합니다. strand를 사용하면 특정 핸들러들이 한 번에 하나씩, 순서대로 실행됩니다.
2. 여러 스레드에서 run
기본 패턴
io_context 하나에 make_work_guard로 “할 일이 있다”를 유지한 뒤, run()을 4개의 스레드에서 동시에 호출합니다. Asio는 완료된 비동기 연산의 핸들러를 이 스레드들 중 하나에 분배하므로, 연결이 많아지면 CPU 코어를 나눠 쓸 수 있습니다.
#include <boost/asio.hpp>
#include <iostream>
#include <thread>
#include <vector>
void thread_pool_basic() {
boost::asio::io_context io;
// work_guard: run()이 "할 일 없음"으로 즉시 종료되지 않게 함
// - 생성 시 io_context의 작업 카운트 증가
// - reset() 시 감소 → 0이 되면 run() 종료
auto work = boost::asio::make_work_guard(io);
std::vector<std::thread> threads;
const int num_threads = 4;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&io]() {
io.run(); // 블로킹, 핸들러 실행
});
}
// ... 서버 동작 (acceptor, 세션 등) ...
// 종료 시퀀스
work.reset(); // work_guard 해제
io.stop(); // run() 중단 지시
for (auto& t : threads) {
t.join();
}
std::cout << "Server stopped\n";
}
스레드 풀 클래스 (재사용 가능)
#include <boost/asio.hpp>
#include <thread>
#include <vector>
#include <functional>
class ThreadPoolServer {
boost::asio::io_context io_;
// executor_work_guard: Boost 1.70+ (make_work_guard와 동일)
boost::asio::executor_work_guard<boost::asio::io_context::executor_type> work_;
std::vector<std::thread> threads_;
public:
explicit ThreadPoolServer(size_t num_threads = std::thread::hardware_concurrency())
: work_(boost::asio::make_work_guard(io_)) {
threads_.reserve(num_threads);
for (size_t i = 0; i < num_threads; ++i) {
threads_.emplace_back([this]() {
io_.run();
});
}
std::cout << "Thread pool started with " << num_threads << " threads\n";
}
~ThreadPoolServer() {
stop();
}
void stop() {
work_.reset();
io_.stop();
for (auto& t : threads_) {
if (t.joinable()) {
t.join();
}
}
threads_.clear();
}
// 작업 등록 (스레드 안전)
template<typename F>
void post(F&& f) {
boost::asio::post(io_, std::forward<F>(f));
}
boost::asio::io_context& get_io_context() {
return io_;
}
};
- 같은 io에 대해 run()이 여러 스레드에서 호출되면, 핸들러가 스레드 풀에 분산됩니다.
- work_guard가 없으면
run()이 즉시 반환할 수 있으므로, 서버가 계속 동작해야 할 때 필수입니다.
3. strand로 순서 보장
strand란?
strand는 “이 executor를 통해 예약된 핸들러는 한 번에 하나씩, 순서대로 실행된다”는 제약을 줍니다. bind_executor(s, handler)로 핸들러를 strand s에 묶으면, 그 핸들러는 다른 스레드에서 돌아도 s를 통한 다른 작업과 겹치지 않습니다.
한 연결의 async_read_some 완료 → 처리 → async_write를 모두 같은 strand로 실행하면, 그 연결에 대한 읽기/쓰기 순서가 보장되어 프로토콜이 꼬이지 않습니다.
strand 사용 예시
#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
using boost::asio::ip::tcp;
using boost::system::error_code;
void strand_example(boost::asio::io_context& io, tcp::socket& socket) {
// 연결별 strand 생성: 이 연결의 모든 I/O가 이 strand를 통해 실행됨
auto strand = boost::asio::make_strand(io);
std::array<char, 1024> buffer;
// ✅ bind_executor: 핸들러를 strand에 묶음
socket.async_read_some(
boost::asio::buffer(buffer),
boost::asio::bind_executor(strand, [&socket, &buffer](error_code ec, size_t bytes) {
if (ec) return;
// 이 핸들러와 아래 async_write 핸들러는 절대 동시에 실행되지 않음
boost::asio::async_write(
socket,
boost::asio::buffer(buffer, bytes),
boost::asio::bind_executor(strand, {
// 쓰기 완료 → 다시 읽기 등록 (같은 strand로)
})
);
})
);
}
strand vs 뮤텍스
| 상황 | strand | 뮤텍스 |
|---|---|---|
| 연결별 read/write 순서 | ✅ 권장 | ❌ 복잡 |
| 공유 데이터 접근 | ✅ post로 직렬화 | ✅ lock/unlock |
| 데드락 위험 | 없음 | 있음 |
| 성능 | 락 없음 | 락 오버헤드 |
strand는 락을 사용하지 않고, Asio 내부적으로 핸들러 큐를 관리하여 순서를 보장합니다.
4. 세션과 수명 관리
핵심 원칙
- 연결 하나 = 세션 객체 하나. 소켓, 버퍼, strand를 멤버로 가짐.
- shared_from_this 또는 세션을 shared_ptr로 보관하고, 비동기 연산 완료 시 그 포인터를 캡처해 수명을 유지.
- 연결 종료 시 참조를 줄여 소멸.
완전한 세션 클래스
#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <memory>
#include <iostream>
#include <array>
using boost::asio::ip::tcp;
using boost::system::error_code;
class Session : public std::enable_shared_from_this<Session> {
tcp::socket socket_;
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
std::array<char, 4096> buffer_;
public:
Session(tcp::socket socket)
: socket_(std::move(socket)),
strand_(boost::asio::make_strand(socket_.get_executor())) {}
void start() {
// shared_from_this(): 비동기 핸들러에서 self 캡처 → 세션 수명 유지
// 핸들러 실행 중에는 세션이 소멸하지 않음
do_read();
}
private:
void do_read() {
auto self = shared_from_this();
socket_.async_read_some(
boost::asio::buffer(buffer_),
boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t bytes) {
if (ec) {
if (ec != boost::asio::error::operation_aborted) {
std::cerr << "Read error: " << ec.message() << "\n";
}
return; // self 참조 해제 → 세션 소멸 가능
}
do_write(bytes);
})
);
}
void do_write(size_t bytes) {
auto self = shared_from_this();
boost::asio::async_write(
socket_,
boost::asio::buffer(buffer_, bytes),
boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t) {
if (ec) {
if (ec != boost::asio::error::operation_aborted) {
std::cerr << "Write error: " << ec.message() << "\n";
}
return;
}
// Echo 서버: 쓰기 완료 후 다시 읽기
do_read();
})
);
}
};
주의: shared_from_this()는 객체가 이미 shared_ptr로 관리될 때만 호출 가능합니다. 생성 직후 start()를 호출하기 전에 shared_ptr<Session>으로 감싸야 합니다.
5. 공유 상태 동기화
여러 연결이 공유 데이터 (예: 채팅 방 목록, 연결 카운터)를 건드리면 뮤텍스 또는 strand로 직렬화해야 합니다.
방법 1: 뮤텍스
#include <mutex>
#include <unordered_set>
class ConnectionManager {
std::unordered_set<std::string> connections_;
std::mutex mutex_;
public:
void add(const std::string& id) {
std::lock_guard<std::mutex> lock(mutex_);
connections_.insert(id);
}
void remove(const std::string& id) {
std::lock_guard<std::mutex> lock(mutex_);
connections_.erase(id);
}
size_t count() const {
std::lock_guard<std::mutex> lock(mutex_);
return connections_.size();
}
};
방법 2: strand로 직렬화
strand를 쓰면 해당 strand에서만 접근하게 해서 뮤텍스 없이 단일 스레드처럼 쓸 수 있습니다.
class StrandConnectionManager {
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
std::unordered_set<std::string> connections_; // strand 내에서만 접근
public:
explicit StrandConnectionManager(boost::asio::io_context& io)
: strand_(boost::asio::make_strand(io)) {}
void add(const std::string& id, std::function<void()> on_done = nullptr) {
boost::asio::post(strand_, [this, id, on_done]() {
connections_.insert(id);
if (on_done) on_done();
});
}
void remove(const std::string& id, std::function<void(size_t)> on_done = nullptr) {
boost::asio::post(strand_, [this, id, on_done]() {
connections_.erase(id);
size_t n = connections_.size();
if (on_done) on_done(n);
});
}
};
6. 완전한 스레드 풀 서버 예시
Echo 서버 (스레드 풀 + strand + 세션)
#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <memory>
#include <iostream>
#include <array>
#include <thread>
#include <vector>
using boost::asio::ip::tcp;
using boost::system::error_code;
// 앞서 정의한 Session 클래스 사용
class Session : public std::enable_shared_from_this<Session> {
tcp::socket socket_;
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
std::array<char, 4096> buffer_;
public:
Session(tcp::socket socket)
: socket_(std::move(socket)),
strand_(boost::asio::make_strand(socket_.get_executor())) {}
void start() {
do_read();
}
private:
void do_read() {
auto self = shared_from_this();
socket_.async_read_some(
boost::asio::buffer(buffer_),
boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t bytes) {
if (ec) return;
do_write(bytes);
})
);
}
void do_write(size_t bytes) {
auto self = shared_from_this();
boost::asio::async_write(
socket_,
boost::asio::buffer(buffer_, bytes),
boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t) {
if (ec) return;
do_read();
})
);
}
};
class Server {
boost::asio::io_context& io_;
tcp::acceptor acceptor_;
public:
Server(boost::asio::io_context& io, uint16_t port)
: io_(io),
acceptor_(io, tcp::endpoint(tcp::v4(), port)) {
start_accept();
}
private:
void start_accept() {
acceptor_.async_accept([this](error_code ec, tcp::socket socket) {
if (!ec) {
// shared_ptr로 세션 생성 → start()에서 shared_from_this() 사용 가능
std::make_shared<Session>(std::move(socket))->start();
}
start_accept(); // 다음 연결 대기
});
}
};
int main() {
boost::asio::io_context io;
auto work = boost::asio::make_work_guard(io);
Server server(io, 8080);
std::cout << "Echo server on port 8080 (thread pool)\n";
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&io]() { io.run(); });
}
// Ctrl+C 등으로 종료 시
// work.reset();
// io.stop();
for (auto& t : threads) {
t.join();
}
return 0;
}
strand 사용 체크리스트
- 각 세션에 대해 연결별 strand 생성
- 해당 연결의 모든 async_read/async_write에
bind_executor(strand, handler)적용 - 공유 상태 수정 시 strand.post 또는 뮤텍스 사용
7. 채팅 서버·HTTP 스타일 서버 예시
채팅 서버 (브로드캐스트)
여러 클라이언트가 접속해 한 클라이언트가 보낸 메시지를 모든 클라이언트에 전달하는 채팅 서버입니다. 공유 연결 목록을 strand로 보호합니다.
#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <array>
#include <memory>
#include <set>
#include <string>
using boost::asio::ip::tcp;
using boost::system::error_code;
class ChatSession : public std::enable_shared_from_this<ChatSession> {
tcp::socket socket_;
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
std::array<char, 4096> buffer_;
std::string nickname_;
using SessionSet = std::set<std::shared_ptr<ChatSession>>;
std::shared_ptr<SessionSet> sessions_;
boost::asio::strand<boost::asio::io_context::executor_type> sessions_strand_;
public:
ChatSession(tcp::socket socket, std::shared_ptr<SessionSet> sessions,
boost::asio::strand<boost::asio::io_context::executor_type> sessions_strand)
: socket_(std::move(socket)),
strand_(boost::asio::make_strand(socket_.get_executor())),
sessions_(std::move(sessions)),
sessions_strand_(sessions_strand) {}
void start(const std::string& nickname) {
nickname_ = nickname;
auto self = shared_from_this();
boost::asio::post(sessions_strand_, [this, self]() {
sessions_->insert(self);
do_read();
});
}
private:
void do_read() {
auto self = shared_from_this();
socket_.async_read_some(
boost::asio::buffer(buffer_),
boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t bytes) {
if (ec) { remove_from_sessions(); return; }
broadcast("[ " + nickname_ + " ] " + std::string(buffer_.data(), bytes));
do_read();
})
);
}
void broadcast(const std::string& msg) {
boost::asio::post(sessions_strand_, [this, msg]() {
for (auto& s : *sessions_)
if (s.get() != this) s->do_write(msg);
});
}
void do_write(const std::string& msg) {
auto self = shared_from_this();
auto data = std::make_shared<std::string>(msg);
boost::asio::async_write(socket_, boost::asio::buffer(*data),
boost::asio::bind_executor(strand_, [this, self, data](error_code ec, size_t) {
if (ec) remove_from_sessions();
})
);
}
void remove_from_sessions() {
boost::asio::post(sessions_strand_, [this]() {
sessions_->erase(shared_from_this());
});
}
};
// ChatServer: acceptor에서 ChatSession 생성, sessions_strand_로 공유 목록 보호
class ChatServer {
boost::asio::io_context& io_;
tcp::acceptor acceptor_;
auto sessions_ = std::make_shared<std::set<std::shared_ptr<ChatSession>>>();
boost::asio::strand<boost::asio::io_context::executor_type> sessions_strand_;
int next_id_ = 0;
public:
ChatServer(boost::asio::io_context& io, uint16_t port)
: io_(io), acceptor_(io, tcp::endpoint(tcp::v4(), port)),
sessions_strand_(boost::asio::make_strand(io)) {
start_accept();
}
private:
void start_accept() {
acceptor_.async_accept([this](error_code ec, tcp::socket socket) {
if (!ec) {
auto session = std::make_shared<ChatSession>(
std::move(socket), sessions_, sessions_strand_);
session->start("user" + std::to_string(++next_id_));
}
start_accept();
});
}
};
HTTP 스타일 요청-응답 서버
단순한 라인 기반 프로토콜로 요청을 받아 응답을 반환하는 서버입니다. 연결당 strand로 요청/응답 순서를 보장합니다.
#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <memory>
#include <iostream>
#include <string>
#include <sstream>
using boost::asio::ip::tcp;
using boost::system::error_code;
class HttpStyleSession : public std::enable_shared_from_this<HttpStyleSession> {
tcp::socket socket_;
boost::asio::strand<boost::asio::io_context::executor_type> strand_;
boost::asio::streambuf buffer_;
public:
HttpStyleSession(tcp::socket socket)
: socket_(std::move(socket)),
strand_(boost::asio::make_strand(socket_.get_executor())) {}
void start() {
do_read_line();
}
private:
void do_read_line() {
auto self = shared_from_this();
boost::asio::async_read_until(
socket_,
buffer_,
'\n',
boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t) {
if (ec) return;
std::istream is(&buffer_);
std::string line;
std::getline(is, line);
if (!line.empty() && line.back() == '\r') line.pop_back();
std::string response = process_request(line) + "\n";
do_write(response);
})
);
}
std::string process_request(const std::string& req) {
if (req == "PING") return "PONG";
if (req == "STATUS") return "OK";
return "UNKNOWN: " + req;
}
void do_write(const std::string& data) {
auto self = shared_from_this();
auto buf = std::make_shared<std::string>(data);
boost::asio::async_write(
socket_,
boost::asio::buffer(*buf),
boost::asio::bind_executor(strand_, [this, self, buf](error_code ec, size_t) {
if (ec) return;
do_read_line();
})
);
}
};
io_context 풀 패턴 (연결당 io_context)
연결이 많을 때 연결당 io_context를 사용하면 strand 없이도 data race를 피할 수 있습니다. 대신 io_context 수만큼 스레드가 필요합니다.
#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <vector>
#include <atomic>
using boost::asio::ip::tcp;
class IoContextPool {
std::vector<std::shared_ptr<boost::asio::io_context>> io_contexts_;
std::vector<boost::asio::executor_work_guard<boost::asio::io_context::executor_type>> work_guards_;
std::vector<std::thread> threads_;
std::atomic<size_t> next_io_{0};
public:
explicit IoContextPool(size_t pool_size = 4) {
for (size_t i = 0; i < pool_size; ++i) {
auto io = std::make_shared<boost::asio::io_context>();
io_contexts_.push_back(io);
work_guards_.push_back(boost::asio::make_work_guard(*io));
threads_.emplace_back([io]() { io->run(); });
}
}
boost::asio::io_context& get_io_context() {
return *io_contexts_[next_io_++ % io_contexts_.size()];
}
void stop() {
for (auto& w : work_guards_) w.reset();
for (auto& io : io_contexts_) io->stop();
for (auto& t : threads_) t.join();
}
};
io_context 풀 vs 단일 io_context + strand
| 방식 | 장점 | 단점 |
|---|---|---|
| 단일 io_context + strand | 메모리 효율, 구현 단순 | strand 오버헤드 |
| io_context 풀 | strand 불필요, 연결 격리 | io_context당 스레드 필요, 메모리 증가 |
8. 일반적인 에러와 해결법
에러 1: data race (같은 소켓/버퍼 동시 접근)
원인: strand 없이 멀티스레드에서 run()을 돌리면, 한 연결의 read/write 핸들러가 서로 다른 스레드에서 동시에 실행될 수 있음.
// ❌ 잘못된 방법
socket.async_read_some(boost::asio::buffer(buffer_), {
// 스레드 1에서 실행
process(buffer_); // buffer_ 수정
});
socket.async_write(boost::asio::buffer(data), {
// 스레드 2에서 동시 실행 가능 → data race!
});
해결:
// ✅ 올바른 방법: 모든 I/O를 strand로 묶기
auto strand = boost::asio::make_strand(socket_.get_executor());
socket.async_read_some(
boost::asio::buffer(buffer_),
boost::asio::bind_executor(strand, handler)
);
에러 2: use-after-free (세션 조기 소멸)
원인: 비동기 핸들러가 실행되기 전에 세션 객체가 소멸됨.
// ❌ 잘못된 방법
void accept_handler(tcp::socket socket) {
Session session(std::move(socket));
session.start(); // async_read_some 등록
} // session 소멸! → 핸들러 실행 시 이미 소멸된 객체 접근
해결:
// ✅ 올바른 방법: shared_ptr로 수명 유지
void accept_handler(tcp::socket socket) {
auto session = std::make_shared<Session>(std::move(socket));
session->start(); // 핸들러에서 session(shared_ptr) 캡처
} // session 참조가 핸들러에 있으므로 계속 유지됨
에러 3: 데드락 (뮤텍스 + strand 혼용)
원인: strand 내부에서 뮤텍스를 잡고, 뮤텍스를 잡은 상태에서 strand에 post하면 데드락 가능.
// ❌ 위험한 패턴
strand_.post([this]() {
std::lock_guard<std::mutex> lock(mutex_);
// ...
strand_.post([this]() { /* 데드락 가능 */ });
});
해결: 공유 상태는 strand만 사용하거나 뮤텍스만 사용. 혼용 시 락 순서를 엄격히 관리.
에러 4: 스레드 수 과다 (성능 저하)
원인: 스레드를 CPU 코어 수보다 훨씬 많이 만들면 컨텍스트 스위칭 오버헤드 증가.
해결:
// ✅ CPU 코어 수 기반
size_t num_threads = std::thread::hardware_concurrency();
if (num_threads == 0) num_threads = 4;
에러 5: shared_ptr 순환 참조 (메모리 누수)
원인: 세션이 공유 컨테이너에 자기 자신을 넣고, 컨테이너가 세션을 소유하면 순환 참조로 메모리 해제되지 않음.
// ❌ 위험: Session이 sessions_를 소유하고, sessions_가 Session을 소유
class BadSession {
std::shared_ptr<std::set<std::shared_ptr<BadSession>>> sessions_;
// sessions_->insert(self) → 순환 참조!
};
해결:
// ✅ weak_ptr 사용 또는 외부에서 sessions_ 관리
// sessions_에서 제거 시점을 명확히 하고, 세션 소멸 시 자동 제거
void remove_from_sessions() {
boost::asio::post(sessions_strand_, [self = weak_from_this(), sessions = sessions_]() {
if (auto s = self.lock()) {
sessions->erase(s);
}
});
}
에러 6: 핸들러 내 예외 발생 (서버 크래시)
원인: 비동기 핸들러에서 예외가 발생하면 io_context::run()이 예외를 전파하고 스레드가 종료됨.
// ❌ 위험: JSON 파싱 실패 시 예외
socket_.async_read_some(..., [this](error_code ec, size_t bytes) {
auto obj = json::parse(buffer_); // 예외 발생 시 전체 스레드 종료!
});
해결:
// ✅ try-catch로 감싸기
socket_.async_read_some(..., [this](error_code ec, size_t bytes) {
try {
if (ec) return;
auto obj = json::parse(buffer_);
process(obj);
} catch (const std::exception& e) {
std::cerr << "Handler error: " << e.what() << "\n";
// 연결 종료 또는 에러 응답
}
});
에러 7: acceptor를 strand 없이 사용
원인: async_accept의 완료 핸들러가 여러 스레드에서 실행될 수 있어, 새 소켓을 받은 직후 처리 시 race 가능.
해결: acceptor는 보통 한 번에 하나의 accept만 대기하므로, 완료 핸들러에서 start_accept()를 다시 호출하는 패턴이면 문제없음. 다만 acceptor와 동일한 io_context를 쓰는 다른 객체와의 상호작용이 있다면 strand 사용을 고려.
에러 8: 버퍼 수명 관리 실패
원인: async_write에 임시 버퍼를 넘기면, 쓰기 완료 전에 버퍼가 소멸함.
// ❌ 위험
void send(const std::string& msg) {
boost::asio::async_write(socket_, boost::asio::buffer(msg), ...);
} // msg 소멸! async_write는 아직 진행 중
해결:
// ✅ shared_ptr로 수명 연장
void send(const std::string& msg) {
auto buf = std::make_shared<std::string>(msg);
boost::asio::async_write(
socket_,
boost::asio::buffer(*buf),
[this, buf](error_code ec, size_t) { /* buf가 핸들러에 캡처됨 */ }
);
}
에러 요약 표
| 에러 | 증상 | 해결 |
|---|---|---|
| data race | 크래시, 프로토콜 오류 | strand로 연결별 직렬화 |
| use-after-free | 세그폴트 | shared_ptr + shared_from_this |
| 데드락 | 서버 멈춤 | strand/뮤텍스 혼용 피하기 |
| 스레드 과다 | CPU 낭비, 지연 증가 | hardware_concurrency() |
| 순환 참조 | 메모리 누수 | weak_ptr, 명시적 제거 |
| 핸들러 예외 | 스레드 종료 | try-catch |
| 버퍼 수명 | 쓰기 중 크래시 | shared_ptr로 캡처 |
9. 성능 벤치마크
벤치마크 환경
- 머신: 4코어 CPU, 8GB RAM
- 클라이언트:
wrk또는 자체 C++ 클라이언트 - 프로토콜: Echo (수신 데이터 그대로 반환)
- 연결 수: 100, 1000, 10000
벤치마크 결과
| 구성 | 스레드 | 연결 수 | req/s | 평균 지연(ms) | CPU 사용률 |
|---|---|---|---|---|---|
| 단일 스레드 | 1 | 100 | 12,000 | 0.8 | 100% (1코어) |
| 단일 스레드 | 1 | 1000 | 8,500 | 12 | 100% |
| 멀티+strand | 4 | 100 | 38,000 | 0.3 | 85% |
| 멀티+strand | 4 | 1000 | 32,000 | 3 | 90% |
| 멀티+strand | 4 | 10000 | 18,000 | 55 | 95% |
| io_context 풀 | 4 | 1000 | 35,000 | 2.8 | 88% |
벤치마크 실행 방법
# wrk로 Echo 서버 부하 테스트 (포트 8080)
wrk -t4 -c100 -d30s --latency http://localhost:8080/
// C++ 벤치마크 클라이언트 예시
void benchmark_echo_client(const char* host, uint16_t port, int num_conn, int req_per_conn) {
boost::asio::io_context io;
std::atomic<int> completed{0};
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < num_conn; ++i) {
tcp::socket socket(io);
socket.connect(tcp::endpoint(boost::asio::ip::make_address(host), port));
std::string msg = "ping\n";
for (int j = 0; j < req_per_conn; ++j) {
boost::asio::write(socket, boost::asio::buffer(msg));
std::array<char, 256> buf;
boost::asio::read(socket, boost::asio::buffer(buf));
completed++;
}
}
io.run();
auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
std::cout << "RPS: " << (completed.load() * 1000.0 / ms) << "\n";
}
성능 최적화 팁
- 버퍼 크기:
read_some버퍼를 4KB~8KB로 설정 (너무 크면 메모리 낭비, 작으면 시스템 콜 증가) - 스레드 바인딩:
pthread_setaffinity_np로 스레드를 특정 코어에 고정하면 캐시 효율 향상 - NAGLE 비활성화:
socket.set_option(tcp::no_delay(true))로 지연 전송 끄기 - 연결 풀링: 클라이언트 측에서 연결 재사용
10. 프로덕션 배포 가이드
스레드 수 설정
| 환경 | 권장 스레드 수 | 비고 |
|---|---|---|
| CPU 바운드 | hardware_concurrency() | 코어 수와 동일 |
| I/O 바운드 | 코어 수 × 1.5 ~ 2 | 대기 시간 활용 |
| 혼합 | 코어 수 | 모니터링 후 조정 |
Graceful Shutdown
void graceful_shutdown() {
// 1. 새 연결 허용 중단
acceptor_.close();
// 2. work_guard 해제
work_.reset();
// 3. io_context 중지
io_.stop();
// 4. 모든 스레드 종료 대기
for (auto& t : threads_) {
t.join();
}
}
모니터링 포인트
- 활성 연결 수
- 스레드별 처리량
- 핸들러 실행 지연 (latency)
- 메모리 사용량 (세션당)
프로덕션 패턴 1: 연결 수 제한
class ConnectionLimiter {
std::atomic<size_t> count_{0};
size_t max_;
public:
explicit ConnectionLimiter(size_t max) : max_(max) {}
bool try_acquire() {
size_t c = count_.load(std::memory_order_relaxed);
while (c < max_ && !count_.compare_exchange_weak(c, c + 1)) {}
return c < max_;
}
void release() { count_.fetch_sub(1, std::memory_order_relaxed); }
};
// acceptor: if (!limiter.try_acquire()) socket.close();
프로덕션 패턴 2: 헬스체크 엔드포인트
// 별도 포트에서 헬스체크 (로드밸런서용)
void start_health_check(boost::asio::io_context& io, uint16_t port) {
tcp::acceptor health(io, tcp::endpoint(tcp::v4(), port));
std::function<void()> loop;
loop = [&]() {
health.async_accept([&](error_code ec, tcp::socket socket) {
if (!ec) {
std::string r = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK";
boost::asio::async_write(socket, boost::asio::buffer(r),
[s = std::move(socket)](error_code, size_t) {});
}
loop();
});
};
loop();
}
프로덕션 패턴 3: 메트릭 수집
struct ServerMetrics {
std::atomic<uint64_t> total_connections{0};
std::atomic<uint64_t> active_connections{0};
std::atomic<uint64_t> total_requests{0};
std::atomic<uint64_t> total_errors{0};
void on_connect() { total_connections++; active_connections++; }
void on_disconnect() { active_connections--; }
void on_request() { total_requests++; }
void on_error() { total_errors++; }
};
프로덕션 패턴 4: 시그널 핸들링 (Graceful Shutdown)
#include <csignal>
void setup_signal_handlers(boost::asio::io_context& io) {
boost::asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&](error_code, int sig) {
std::cout << "Received signal " << sig << ", shutting down...\n";
io.stop();
});
}
체크리스트
- 각 세션에 strand 적용
- 공유 상태 동기화 (strand 또는 뮤텍스)
- 세션 수명을 shared_ptr로 관리
- work_guard로 서버 유지
- graceful shutdown 구현
- 에러 로깅 (연결 실패, read/write 에러)
- 연결 수 제한 (DoS 방지)
- 헬스체크 엔드포인트 (로드밸런서 연동)
- 메트릭 수집 (모니터링)
성능 비교
| 구성 | 처리량 (req/s) | CPU 사용률 | 메모리 (연결당) | data race 위험 |
|---|---|---|---|---|
| 단일 스레드 | 10,000 | 100% (1 core) | 낮음 | 없음 |
| 멀티스레드 (strand 없음) | ❌ 불안정 | - | - | 높음 |
| 멀티스레드 (strand 사용) | 35,000 | 90% (4 cores) | 중간 | 없음 |
| 멀티스레드 (8 스레드) | 40,000 | 80% (8 cores) | 높음 | strand 필수 |
결론: 멀티스레드 사용 시 반드시 strand로 연결별 순서를 보장해야 합니다. 그렇지 않으면 data race로 인해 프로토콜 오류나 크래시가 발생합니다.
정리
| 항목 | 내용 |
|---|---|
| 스레드 풀 | 여러 스레드가 io_context::run() |
| strand | 핸들러 순서 보장, 동시 실행 방지 |
| 세션 | 연결당 객체, shared_ptr로 수명 관리 |
| 공유 상태 | strand 또는 뮤텍스로 직렬화 |
핵심 원칙:
- 멀티스레드 run() 시 연결별 strand 필수
- 세션은 shared_ptr로 생성하고 핸들러에서 캡처
- 공유 데이터는 strand.post 또는 뮤텍스로 보호
- 스레드 수는 CPU 코어 수 근처로 설정
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 고성능 네트워크 서버, 채팅 서버, 게임 서버 등 수천 개 동시 연결을 처리하는 서비스에서 필수입니다. 단일 스레드로는 CPU 코어를 활용하지 못해 병목이 발생합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. strand 없이 멀티스레드를 쓸 수 있나요?
A. 권장하지 않습니다. 한 연결의 read/write가 서로 다른 스레드에서 동시에 실행되면 data race가 발생합니다. 단, 연결당 io_context를 사용하는 방식(io_context 풀)이라면 strand 없이도 가능하지만, 리소스 사용량이 더 많아집니다.
Q. 스레드를 몇 개 만들어야 하나요?
A. 일반적으로 CPU 코어 수만큼 만드는 것이 최적입니다. std::thread::hardware_concurrency()로 확인할 수 있습니다. I/O 대기가 많으면 코어 수보다 약간 더 많이 만들 수 있습니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. Boost.Asio 공식 문서와 cppreference를 참고하세요. strand, executor 개념을 더 깊이 이해하면 도움이 됩니다.
한 줄 요약: io_context 풀·strand로 멀티스레드에서도 핸들러 순서를 보장하고 data race를 방지할 수 있습니다.
이전 글: C++ 실전 가이드 #29-2: 비동기 이벤트 루프
다음 글: [C++ 실전 가이드 #30-1] WebSocket 구현: 핸드셰이크와 프레임
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
- C++ Boost.Asio 입문 | io_context·async_read
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
이 글에서 다루는 키워드 (관련 검색어)
C++, Asio, 멀티스레드, 서버, strand, io_context, data race, 세션 등으로 검색하시면 이 글이 도움이 됩니다.
관련 글
- C++ Boost.Asio 입문 | io_context·async_read
- C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
- C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩
- C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]
- C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]