[2026] C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지
이 글의 핵심
C++ 멀티스레드 네트워크 서버 : io_context 풀·strand·data race…. 실무에서 겪은 문제·시스템 아키텍처.
들어가며: “멀티스레드 서버에서 data race가 발생해요”
문제 상황
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 문제: 여러 스레드에서 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는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 시스템 아키텍처
- 여러 스레드에서 run
- strand로 순서 보장
- 세션과 수명 관리
- 공유 상태 동기화
- 완전한 스레드 풀 서버 예시
- 채팅 서버·HTTP 스타일 서버 예시
- 일반적인 에러와 해결법
- 성능 벤치마크
- 프로덕션 배포 가이드
1. 시스템 아키텍처
전체 구조
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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
스레딩 모델
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 실행 예제
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 코어를 나눠 쓸 수 있습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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";
}
스레드 풀 클래스 (재사용 가능)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 사용 예시
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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로 보관하고, 비동기 연산 완료 시 그 포인터를 캡처해 수명을 유지.
- 연결 종료 시 참조를 줄여 소멸.
완전한 세션 클래스
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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: 뮤텍스
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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에서만 접근하게 해서 뮤텍스 없이 단일 스레드처럼 쓸 수 있습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 + 세션)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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로 보호합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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로 요청/응답 순서를 보장합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 수만큼 스레드가 필요합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <vector>
#include <atomic>
// 필요한 모듈 import
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 핸들러가 서로 다른 스레드에서 동시에 실행될 수 있음. 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 방법
socket.async_read_some(boost::asio::buffer(buffer_), {
// 스레드 1에서 실행
process(buffer_); // buffer_ 수정
});
socket.async_write(boost::asio::buffer(data), {
// 스레드 2에서 동시 실행 가능 → data race!
});
해결: 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 올바른 방법: 모든 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 (세션 조기 소멸)
원인: 비동기 핸들러가 실행되기 전에 세션 객체가 소멸됨. 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 방법
void accept_handler(tcp::socket socket) {
Session session(std::move(socket));
session.start(); // async_read_some 등록
} // session 소멸! → 핸들러 실행 시 이미 소멸된 객체 접근
해결: 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 올바른 방법: 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하면 데드락 가능. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 위험한 패턴
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 순환 참조 (메모리 누수)
원인: 세션이 공유 컨테이너에 자기 자신을 넣고, 컨테이너가 세션을 소유하면 순환 참조로 메모리 해제되지 않음. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 위험: Session이 sessions_를 소유하고, sessions_가 Session을 소유
class BadSession {
std::shared_ptr<std::set<std::shared_ptr<BadSession>>> sessions_;
// sessions_->insert(self) → 순환 참조!
};
해결: 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 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()이 예외를 전파하고 스레드가 종료됨.
다음은 간단한 cpp 코드 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 위험: JSON 파싱 실패 시 예외
socket_.async_read_some(..., [this](error_code ec, size_t bytes) {
auto obj = json::parse(buffer_); // 예외 발생 시 전체 스레드 종료!
});
해결: 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 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에 임시 버퍼를 넘기면, 쓰기 완료 전에 버퍼가 소멸함.
다음은 간단한 cpp 코드 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 위험
void send(const std::string& msg) {
boost::asio::async_write(socket_, boost::asio::buffer(msg), ...);
} // msg 소멸! async_write는 아직 진행 중
해결: 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 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/
다음은 cpp를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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
다음은 cpp를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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: 연결 수 제한
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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: 헬스체크 엔드포인트
다음은 cpp를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 별도 포트에서 헬스체크 (로드밸런서용)
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: 메트릭 수집
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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)
아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
#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가지와 해결법