Boost.Asio 입문: io_context, 비동기 타이머·읽기·쓰기, 핸들러 수명과 work_guard
들어가며
채팅 서버를 동기(블로킹) 방식으로 구현하면 보통 연결마다 스레드를 하나씩 붙이게 됩니다.
#include <array>
#include <thread>
#include <utility>
// 동기 서버: 연결마다 스레드 하나
void handle_client(tcp::socket socket) {
std::array<char, 1024> buf;
while (true) {
size_t n = socket.read_some(boost::asio::buffer(buf)); // 데이터가 올 때까지 블로킹
if (n == 0) break;
// 클라이언트가 10초 동안 아무것도 안 보내면 이 스레드는 10초 동안 잠들어 있음
}
}
int main() {
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
while (true) {
auto socket = acceptor.accept(io); // 여기서도 블로킹
std::thread(handle_client, std::move(socket)).detach(); // 연결 수만큼 스레드 생성
}
}
detach만 하고 생명주기·예외를 관리하지 않으면 크래시·리소스 고갈로 이어질 수 있습니다. 프로덕션에서는 스레드 풀·비동기 모델을 검토하세요. 또 이 코드의 if (n == 0) break;는 실제로는 실행되지 않습니다. 에러 코드 인자 없이 호출한 read_some은 상대가 연결을 닫으면 0을 반환하는 대신 boost::system::system_error(End of file) 예외를 던지고, detach된 스레드에서 잡히지 않은 예외는 std::terminate로 프로세스 전체를 종료시킵니다. 클라이언트 하나가 연결을 끊는 것만으로 서버가 죽는 전형적인 형태입니다.
연결마다 스레드를 두면 클라이언트가 아무것도 보내지 않는 동안에도 스레드 하나가 그대로 잠들어 있습니다. 연결이 수천 개로 늘면 스레드도 수천 개가 되고, 스레드마다 예약되는 스택(리눅스 기본 8MB의 가상 주소 공간)과 문맥 전환 비용, 프로세스당 스레드 수 제한이 먼저 문제가 됩니다. 비동기 I/O는 “읽기를 시작해 두고 데이터가 오면 알려 달라”고 등록한 뒤, 적은 수의 스레드가 완료된 작업의 핸들러만 실행하는 방식으로 이 문제를 피합니다.
C++ 기본 문법과 소켓의 개념(#28 소켓 기초)을 알고 있으면 이해가 쉽습니다. 연결마다 스레드를 붙이는 방식의 한계를 느꼈다면, 이 글의 비동기·io_context·run()이 그다음 단계입니다. 더 깊은 run/poll/Strand는 고성능 네트워크 가이드 #1에서 이어서 다룹니다.
예제를 빌드하려면 Boost.Asio(vcpkg install boost-asio 등) 또는 standalone Asio와 C++17 컴파일러가 필요합니다. Linux/macOS에서는 g++/Clang으로 빌드·실행하고, Windows에서는 WSL 또는 MSVC + vcpkg를 권장합니다.
비동기 I/O가 왜 필요한가요?
비동기 I/O는 “연산을 시작만 해 두고, 완료되면 핸들러로 알려 준다”는 모델입니다. 스레드가 한 연결을 기다리며 멈춰 있지 않으므로 소수의 스레드로 많은 연결을 다룰 수 있습니다.
내부적으로 Asio는 운영체제마다 다른 메커니즘을 감쌉니다. 리눅스에서는 epoll로 “읽을 준비가 된 소켓”을 알아낸 뒤 직접 읽는 방식(Reactor)을, Windows에서는 IOCP로 “읽기가 이미 끝났다”는 완료 통지를 받는 방식(Proactor)을 씁니다. Asio는 두 경우 모두 “완료되면 핸들러를 부른다”는 Proactor 스타일 API로 통일해 보여 주므로 같은 코드가 양쪽에서 돌아갑니다. 대신 이 모델은 공짜가 아닙니다. 코드가 “시작 → 나중에 핸들러”로 쪼개지면서, 동기 코드에서는 지역 변수로 충분하던 버퍼와 소켓의 수명을 직접 관리해야 하고, 한 요청의 흐름이 여러 함수에 흩어져 디버거로 따라가기 어려워집니다. 동시 연결이 수십 개 수준이고 각 요청이 짧다면 스레드 풀 + 동기 I/O가 더 단순하고 충분히 빠른 경우도 많습니다.
블로킹 vs 논블로킹 비교
블로킹 I/O: 스레드가 대기
sequenceDiagram
participant T as 스레드
participant S as 소켓
participant N as 네트워크
T->>S: read_some() 호출
S->>N: 데이터 요청
Note over T: ⏸️ 블로킹 (다른 일 못 함)
N-->>S: 데이터 도착
S-->>T: 반환
T->>T: 다음 작업
read_some()은 데이터가 올 때까지 스레드를 붙잡고 있으므로, 연결 N개를 동시에 기다리려면 스레드도 N개가 필요합니다.
논블로킹 I/O (Asio): 이벤트 기반
sequenceDiagram
participant T as 스레드
participant IO as io_context
participant S1 as 소켓1
participant S2 as 소켓2
T->>IO: async_read(소켓1) 등록
T->>IO: async_read(소켓2) 등록
T->>IO: run()
Note over T,IO: io_context가 완료된 연산만 실행
IO->>S1: 소켓1 데이터 도착
IO->>T: 콜백 실행 (소켓1)
T->>IO: 다음 async_read 등록
IO->>S2: 소켓2 데이터 도착
IO->>T: 콜백 실행 (소켓2)
여기서 스레드는 완료된 작업의 콜백을 실행하는 일만 하고, I/O를 기다리는 동안에는 다른 연결의 콜백을 처리합니다.
io_context와 run
그림: 실제 실행 결과(Boost.Asio 1.87) — run() 안에서 호출한 dispatch는 그 자리에서 바로 실행되어 1, 2, 3(즉시), 4 순서가 된다. poll_one()은 준비된 핸들러 1개, poll()은 나머지 2개를 처리하고, 작업이 없으면 run()은 0을 반환한다. cancel()한 타이머의 핸들러는 operation_aborted로 호출되고 5초를 기다리지 않는다. 100ms 반복 타이머에 30ms짜리 핸들러를 5번 돌리면 이상적 500ms보다 약 165ms 밀렸다. work_guard가 있으면 작업이 없어도 run()이 끝나지 않는다. restart() 없이 두 번째 run()을 호출하면 0개가 실행되고, async_accept를 한 번만 등록하면 연결 하나를 수락한 뒤 run()이 반환되어 두 번째 연결은 수락되지 않는다(클라이언트의 connect는 커널 backlog에서 성공한다). 세션이 소멸된 뒤 this를 캡처한 핸들러가 실행되면 AddressSanitizer가 heap-use-after-free를 잡는다. (환경: macOS 27.0.1 · Boost.Asio 1.87 · Apple clang 21.0.0 · ASan)
기본 개념
io_context는 Asio의 이벤트 루프입니다. async_accept, async_read, async_wait 같은 비동기 연산을 등록해 두면 io.run()이 완료된 연산의 콜백을 실행합니다.
flowchart LR A[async_* 등록] --> B[io_context] B --> C[run] C --> D[완료 시 콜백] D --> A
최소 예제: post로 작업 등록
#include <boost/asio.hpp>
#include <iostream>
int main() {
boost::asio::io_context io;
// 비동기 작업 등록 (post: 즉시 큐에 넣음)
boost::asio::post(io, [] {
std::cout << "Hello from io_context!\n";
});
io.run(); // 등록된 작업이 완료될 때까지 실행
return 0;
}
실행:
g++ -std=c++17 -o asio_hello asio_hello.cpp -lboost_system -pthread && ./asio_hello
Boost 1.69부터 Boost.System은 헤더 전용이 되어 -lboost_system이 없어도 링크되는 경우가 많습니다(오래된 배포판의 Boost라면 필요). 반대로 -pthread를 빼면 일부 환경에서 undefined reference to 'pthread_create'가 납니다. Windows에서 MinGW로 빌드할 때는 -lws2_32 -lmswsock을 추가해야 WSAStartup 관련 링크 에러가 사라집니다.
출력:
Hello from io_context!
post는 작업을 큐에 넣고 바로 반환하며, 작업은 나중에 run() 안에서 실행됩니다. dispatch는 이미 그 io_context의 run()을 돌리는 스레드에서 호출되면 그 자리에서 바로 실행하고, 그렇지 않으면 post처럼 큐에 넣습니다. run()은 남은 작업이 없으면 반환하는데, work_guard를 두면 작업이 없어도 반환하지 않게 할 수 있습니다.
post vs dispatch
boost::asio::io_context io;
// post: 항상 큐에 넣고 반환
boost::asio::post(io, [] { std::cout << "1\n"; });
// dispatch: run() 내부에서 호출 시 즉시 실행
boost::asio::post(io, [&io]() {
std::cout << "2\n";
boost::asio::dispatch(io, [] { std::cout << "3 (즉시)\n"; });
std::cout << "4\n";
});
io.run();
// 출력 순서: 1, 2, 3 (즉시), 4
run vs poll
// run(): 작업이 없을 때까지 블로킹
io.run();
// poll(): 대기 없이, 지금 실행 준비된 핸들러를 모두 처리하고 반환
io.poll();
// poll_one(): 준비된 핸들러 하나만 처리하고 반환
io.poll_one();
비동기 타이머
기본: 1초 후 콜백
// g++ -std=c++17 -o asio_timer asio_timer.cpp -lboost_system -pthread && ./asio_timer
#include <boost/asio.hpp>
#include <iostream>
int main() {
boost::asio::io_context io;
boost::asio::steady_timer timer(io, std::chrono::seconds(1));
timer.async_wait([](const boost::system::error_code& ec) {
if (!ec) {
std::cout << "1초 후 실행됨\n";
}
});
io.run();
return 0;
}
실행 결과:
1초 후 실행됨
반복 타이머: 주기적 실행
#include <boost/asio.hpp>
#include <iostream>
void schedule_timer(boost::asio::steady_timer& timer, int count) {
timer.expires_after(std::chrono::seconds(1));
timer.async_wait([&timer, count](const boost::system::error_code& ec) {
if (ec) return;
std::cout << "Tick " << count << "\n";
if (count < 5) {
schedule_timer(timer, count + 1); // 다음 타이머 등록
}
});
}
int main() {
boost::asio::io_context io;
boost::asio::steady_timer timer(io);
schedule_timer(timer, 1);
io.run();
return 0;
}
출력:
Tick 1
Tick 2
Tick 3
Tick 4
Tick 5
반복 타이머에서 expires_after는 “지금부터 1초 뒤”를 뜻하므로, 핸들러 실행 시간만큼 주기가 조금씩 밀립니다. 정확히 1초 간격을 유지하려면 timer.expires_at(timer.expiry() + std::chrono::seconds(1))처럼 이전 만료 시각을 기준으로 다음 시각을 잡아야 합니다. 타이머를 cancel()하면 대기 중인 핸들러는 사라지지 않고 operation_aborted 에러 코드로 호출되므로, 모든 타이머 핸들러의 첫 줄에 if (ec) return;이 있는 이유가 이것입니다. 이 확인이 없으면 취소한 타이머가 한 번 더 작업을 실행하는 버그가 생깁니다.
타임아웃과 함께 사용 (async_read 취소)
// async_read에 5초 타임아웃 적용
void read_with_timeout(tcp::socket& socket, asio::steady_timer& timer,
asio::mutable_buffer buf) {
timer.expires_after(std::chrono::seconds(5));
timer.async_wait([&socket](const boost::system::error_code& ec) {
if (!ec) {
socket.cancel(); // 5초 지나면 읽기 취소
}
});
asio::async_read(socket, buf, [&timer](boost::system::error_code ec, size_t n) {
timer.cancel(); // 읽기 완료 시 타이머 취소
if (!ec) {
// 데이터 처리
}
});
}
완전한 타이머 예제: 빌드 및 실행
// asio_timer_complete.cpp - 저장 후 아래 명령으로 빌드
// g++ -std=c++17 -o asio_timer_complete asio_timer_complete.cpp -lboost_system -pthread
#include <boost/asio.hpp>
#include <chrono>
#include <functional>
#include <iostream>
namespace asio = boost::asio;
int main() {
asio::io_context io;
asio::steady_timer timer(io);
auto start = std::chrono::steady_clock::now();
// auto 람다는 자기 자신을 호출할 수 없으므로 std::function으로 선언
std::function<void(int)> schedule = [&](int count) {
timer.expires_after(std::chrono::seconds(1));
timer.async_wait([&, count](const boost::system::error_code& ec) {
if (ec) return;
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::steady_clock::now() - start).count();
std::cout << "[" << elapsed << "s] Tick " << count << "\n";
if (count < 3) schedule(count + 1);
});
};
schedule(1);
io.run();
return 0;
}
async_read·async_read_until·async_write·async_read_some
async_read: 버퍼가 찰 때까지
#include <boost/asio.hpp>
#include <array>
#include <iostream>
namespace asio = boost::asio;
using asio::ip::tcp;
void do_read(tcp::socket& socket) {
auto buf = std::make_shared<std::array<char, 1024>>();
asio::async_read(socket, asio::buffer(*buf),
[&socket, buf](boost::system::error_code ec, std::size_t length) {
if (ec) {
if (ec != asio::error::eof) {
std::cerr << "Read error: " << ec.message() << "\n";
}
return;
}
std::cout << "Received " << length << " bytes: ";
std::cout.write(buf->data(), length);
std::cout << "\n";
// 다음 읽기 등록 (체이닝)
do_read(socket);
});
}
int main() {
asio::io_context io;
tcp::socket socket(io);
tcp::resolver resolver(io);
resolver.async_resolve("localhost", "8080",
[&](boost::system::error_code ec, tcp::resolver::results_type results) {
if (ec) return;
asio::async_connect(socket, results,
[&](boost::system::error_code ec, const tcp::endpoint&) {
if (ec) return;
do_read(socket);
});
});
io.run();
return 0;
}
async_read는 버퍼가 가득 찰 때까지 완료되지 않습니다. 그 전에 상대가 연결을 닫으면 eof 에러와 함께 그때까지 읽은 바이트 수가 전달됩니다. 길이가 정해지지 않은 데이터에는 async_read_until이나 소켓의 async_read_some을 씁니다.
async_read_until: 구분자까지 읽기
#include <boost/asio.hpp>
#include <boost/asio/read_until.hpp>
void do_read_until(tcp::socket& socket) {
auto buf = std::make_shared<asio::streambuf>();
asio::async_read_until(socket, *buf, '\n',
[&socket, buf](boost::system::error_code ec, std::size_t length) {
if (ec) return;
std::istream is(buf.get());
std::string line;
std::getline(is, line);
std::cout << "Line: " << line << "\n";
do_read_until(socket);
});
}
async_write: 전송 완료 보장
void do_write(tcp::socket& socket, const std::string& message) {
auto buf = std::make_shared<std::string>(message);
asio::async_write(socket, asio::buffer(*buf),
[buf](boost::system::error_code ec, std::size_t length) {
if (ec) {
std::cerr << "Write error: " << ec.message() << "\n";
return;
}
std::cout << "Sent " << length << " bytes\n";
});
}
자유 함수 async_write는 버퍼 전체가 전송될 때까지 내부에서 쓰기를 반복한 뒤 핸들러를 한 번 호출합니다. 소켓 멤버 함수 async_write_some은 일부만 전송돼도 완료되므로 남은 부분을 직접 다시 보내야 합니다.
async_read_some: 가변 길이 데이터
async_read_some은 자유 함수가 아니라 소켓의 멤버 함수입니다. 도착한 데이터가 있으면 버퍼가 다 차지 않아도 완료됩니다.
// 한 번에 최대 1024바이트만 읽음 (버퍼 가득 차지 않아도 완료)
void do_read_some(tcp::socket& socket) {
auto buf = std::make_shared<std::array<char, 1024>>();
socket.async_read_some(asio::buffer(*buf),
[&socket, buf](boost::system::error_code ec, std::size_t n) {
if (ec) return;
// n바이트 처리 후 다음 읽기 등록
// handle_data(buf->data(), n);
do_read_some(socket);
});
}
비동기 TCP 클라이언트
흐름도
async_connect로 연결하고, 연결 완료 핸들러에서 async_write나 async_read를 시작하며, 읽기 완료 핸들러에서 다시 읽기를 등록해 다음 데이터를 기다립니다. 이렇게 핸들러가 다음 작업을 등록하는 것을 체이닝이라고 부릅니다.
// 클라이언트 흐름
resolver.async_resolve(...)
-> async_connect(...)
-> async_write(요청)
-> async_read(응답)
-> async_read(다음 응답) // 체이닝
완전한 에코 클라이언트 예제
// asio_echo_client.cpp - 서버에 연결 후 입력을 에코로 받음
// g++ -std=c++17 -o asio_echo_client asio_echo_client.cpp -lboost_system -pthread
#include <boost/asio.hpp>
#include <array>
#include <iostream>
#include <memory>
#include <string>
namespace asio = boost::asio;
using asio::ip::tcp;
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: " << argv[0] << " <host> <port>\n";
return 1;
}
asio::io_context io;
tcp::socket socket(io);
tcp::resolver resolver(io);
resolver.async_resolve(argv[1], argv[2],
[&](boost::system::error_code ec, tcp::resolver::results_type results) {
if (ec) {
std::cerr << "Resolve: " << ec.message() << "\n";
return;
}
asio::async_connect(socket, results,
[&](boost::system::error_code ec, const tcp::endpoint&) {
if (ec) {
std::cerr << "Connect: " << ec.message() << "\n";
return;
}
std::cout << "Connected. Waiting for the first message from server.\n";
// 첫 읽기 시작 (이 예제는 한 번만 읽고 종료. stdin 입력 전송은 포함하지 않음)
auto buf = std::make_shared<std::array<char, 1024>>();
socket.async_read_some(asio::buffer(*buf),
[&, buf](boost::system::error_code ec, std::size_t n) {
if (!ec && n > 0) {
std::cout.write(buf->data(), n);
}
});
});
});
io.run();
return 0;
}
이 클라이언트는 연결 후 한 번만 읽고 핸들러가 새 작업을 등록하지 않으므로, 그 시점에 run()이 반환하고 프로그램이 끝납니다. 사용자 입력을 계속 보내려면 표준 입력 읽기가 필요한데, 콘솔 입력은 블로킹이라 io_context 스레드에서 std::getline을 부르면 이벤트 루프 전체가 멈춥니다. 보통은 입력을 별도 스레드에서 읽고 asio::post(io, ...)로 전송 작업을 io_context에 넘기는 구조를 씁니다(POSIX에서는 posix::stream_descriptor로 stdin을 비동기로 읽을 수도 있습니다).
비동기 서버 (async_accept)
Session 클래스와 async_read 체이닝
#include <cstddef>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
namespace asio = boost::asio;
using asio::ip::tcp;
class Session : public std::enable_shared_from_this<Session> {
public:
explicit Session(tcp::socket socket) : socket_(std::move(socket)) {}
void start() {
do_read();
}
private:
void do_read() {
auto self(shared_from_this());
asio::async_read_until(socket_, buffer_, '\n',
[this, self](boost::system::error_code ec, std::size_t length) {
if (!ec) {
std::istream is(&buffer_);
std::string line;
std::getline(is, line);
do_write("Echo: " + line + "\n");
}
});
}
void do_write(std::string msg) {
auto self(shared_from_this());
write_buf_ = std::move(msg); // 쓰기가 끝날 때까지 살아 있도록 멤버에 보관
asio::async_write(socket_, asio::buffer(write_buf_),
[this, self](boost::system::error_code ec, std::size_t) {
if (!ec) {
do_read(); // 다음 읽기
}
});
}
tcp::socket socket_;
asio::streambuf buffer_;
std::string write_buf_;
};
void do_accept(tcp::acceptor& acceptor, asio::io_context& io) {
acceptor.async_accept([&acceptor, &io](boost::system::error_code ec,
tcp::socket socket) {
if (ec) return;
std::make_shared<Session>(std::move(socket))->start();
do_accept(acceptor, io); // 다음 연결 대기
});
}
int main() {
asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
do_accept(acceptor, io);
io.run();
}
수락 핸들러는 새 연결의 소켓을 받아 세션을 만들고 읽기를 시작한 뒤, 끝에서 다시 do_accept를 호출해 다음 연결을 기다립니다.
do_write가 받은 문자열을 write_buf_ 멤버에 옮겨 두는 부분이 이 예제에서 가장 중요한 한 줄입니다. do_write("Echo: " + line + "\n")의 인자는 임시 문자열이라, const std::string&로 받아 그대로 asio::buffer(msg)에 넘기면 do_write가 반환하는 순간 문자열이 사라지고 async_write는 해제된 메모리를 전송합니다. 컴파일 에러도 경고도 없고, 짧은 문자열은 SSO(작은 문자열 최적화) 덕분에 우연히 멀쩡히 전송되는 경우가 많아서 로컬 테스트를 쉽게 통과합니다. 긴 메시지를 보낼 때만 깨진 바이트가 나가거나 크래시하므로, 비동기 쓰기에 넘기는 버퍼는 항상 핸들러가 끝날 때까지 누가 소유하는지를 코드에서 명확히 해 두어야 합니다.
async_read_until로 받은 streambuf에는 구분자 이후의 데이터가 더 들어 있을 수 있다는 점도 알아 둘 만합니다. 클라이언트가 두 줄을 한 번에 보내면 첫 async_read_until이 두 줄을 모두 버퍼에 담은 채 첫 줄 위치까지만 알려 주고, 두 번째 호출은 버퍼에 이미 \n이 있으므로 소켓을 읽지 않고 곧바로 완료됩니다. 그래서 streambuf를 호출마다 새로 만들면(앞의 do_read_until 예제처럼) 남아 있던 두 번째 줄이 사라지므로, 세션처럼 멤버로 유지해야 합니다.
빌드 및 테스트
# 터미널 1: 서버 실행
g++ -std=c++17 -o asio_echo_server asio_echo_server.cpp -lboost_system -pthread
./asio_echo_server
# 터미널 2: 클라이언트 (nc로 테스트)
echo "Hello Asio" | nc localhost 8080
예상 출력:
Echo: Hello Asio
error_code 확인과 연결 종료 처리
기본: error_code 확인
acceptor.async_accept([&](boost::system::error_code ec, tcp::socket socket) {
if (ec) {
std::cerr << "Accept error: " << ec.message() << "\n";
return; // 에러 시 재등록하지 않음 -> run() 종료 가능
}
// 정상 처리
});
연결 종료 처리
void do_read(tcp::socket& socket) {
asio::async_read(socket, buf, [&](boost::system::error_code ec, size_t n) {
if (ec) {
if (ec == asio::error::eof) {
// 정상 종료: 상대가 연결 끊음
return;
}
if (ec == asio::error::operation_aborted) {
// 취소됨 (타임아웃 등)
return;
}
std::cerr << "Error: " << ec.message() << "\n";
return;
}
// 정상 처리
});
}
주요 에러 코드 정리
| 에러 | 의미 | 대응 |
|---|---|---|
eof | 상대가 연결 종료 | 정상 처리, 세션 정리 |
operation_aborted | cancel() 호출됨 | 타임아웃 등 의도적 취소 |
connection_reset | 상대가 비정상 종료 | 로깅 후 정리 |
broken_pipe | 닫힌 소켓에 쓰기 | 쓰기 중단 |
strand에 핸들러 묶기 (멀티스레드 run 시)
asio::async_read(socket, buf,
asio::bind_executor(strand_,
[](boost::system::error_code ec, std::size_t /*n*/) {
if (ec) {
// 로깅 후 재등록 또는 종료
}
}));
핸들러 dangling 참조, work_guard 누락, 버퍼 수명: 자주 하는 실수
실수 1: 핸들러에서 dangling reference
// ❌ 잘못된 예: session이 소멸된 뒤 콜백 실행 가능
void do_read() {
asio::async_read(socket_, buf, [this](boost::system::error_code ec, size_t n) {
// this가 이미 소멸됐을 수 있음!
process_data();
});
}
// ✅ 올바른 예: shared_from_this로 수명 연장
void do_read() {
auto self(shared_from_this());
asio::async_read(socket_, buf, [this, self](boost::system::error_code ec, size_t n) {
if (!ec) process_data();
});
}
실수 2: 다음 작업을 등록하지 않아 run()이 끝남
// ❌ 문제: async_accept 한 번만 등록 -> 연결 하나를 처리하고 나면 할 일이 없어 run() 반환
asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
acceptor.async_accept([](boost::system::error_code, tcp::socket) { /* ... */ });
io.run();
서버라면 해결책은 앞의 예제처럼 수락 핸들러에서 do_accept를 다시 호출하는 것입니다. work_guard는 다른 문제를 푸는 도구입니다. 작업을 나중에 다른 스레드에서 post할 예정이라 지금은 큐가 비어 있는 경우, run()이 그 사이에 반환하지 않도록 붙잡아 둡니다. work_guard만 두면 run()은 끝나지 않지만 두 번째 연결은 여전히 받지 않습니다.
// 나중에 다른 스레드가 post할 작업을 기다리는 경우
auto work = asio::make_work_guard(io);
std::thread worker([&io] { io.run(); }); // 큐가 비어도 반환하지 않음
// ... 종료할 때
work.reset(); // 붙잡기 해제 -> 남은 작업이 끝나면 run() 반환
worker.join();
실수 3: 버퍼 수명
// ❌ 잘못된 예: 스택 버퍼는 콜백 실행 시 이미 소멸
void do_read() {
std::array<char, 1024> buf;
asio::async_read(socket_, asio::buffer(buf), [...]); // 위험!
}
// ✅ 올바른 예: shared_ptr로 버퍼 수명 연장
void do_read() {
auto buf = std::make_shared<std::array<char, 1024>>();
asio::async_read(socket_, asio::buffer(*buf),
[buf, this](boost::system::error_code ec, size_t n) { /* ... */ });
}
실수 4: io_context 재사용 시 restart() 누락
// ❌ run()이 한 번 반환하면 io_context는 stopped 상태
io.run(); // 작업 완료로 반환
// 새 작업 등록 후
io.run(); // restart() 없이는 아무것도 실행하지 않고 즉시 반환
// ✅ restart() 후 재실행
io.run();
io.restart(); // run() 상태 초기화
// 새 작업 등록 후
io.run();
실수 5: 멀티스레드에서 strand 미사용
// ❌ 여러 스레드가 같은 소켓에 async_read/async_write 동시 등록 -> 데이터 레이스
std::thread t1([&]() { do_read(socket); });
std::thread t2([&]() { do_write(socket, "x"); });
// ✅ strand로 핸들러 직렬화 (io_context::strand는 구식, make_strand 권장)
auto strand = asio::make_strand(io);
asio::async_read(socket, buf, asio::bind_executor(strand, handler));
asio::async_write(socket, buf, asio::bind_executor(strand, handler));
// 더 간단한 방법: 처음부터 소켓을 strand executor로 만들기
// tcp::socket socket(asio::make_strand(io));
strand가 보장하는 것은 “같은 strand에 묶인 핸들러가 동시에 실행되지 않는다”는 것뿐입니다. t1, t2처럼 io_context 밖의 스레드가 소켓 멤버 함수를 직접 호출하는 것은 strand로 막을 수 없으므로, 다른 스레드에서 소켓 작업을 요청하려면 asio::post(strand, [&]{ do_write(...); })처럼 작업 자체를 strand 안으로 넘겨야 합니다.
실수 6: 람다 캡처로 인한 수명 문제
// ❌ 참조 캡처: acceptor가 스코프 밖으로 나가면 dangling
acceptor.async_accept([&acceptor](...) {
do_accept(acceptor); // acceptor 참조가 무효화됐을 수 있음
});
// ✅ shared_ptr 또는 포인터로 안전하게 전달
auto acc = std::make_shared<tcp::acceptor>(std::move(acceptor));
acc->async_accept([acc](...) {
do_accept(*acc);
});
버퍼 선택, shared_from_this, 타이머+cancel 타임아웃
버퍼 선택 가이드
| 용도 | 권장 | 이유 |
|---|---|---|
| 고정 길이 프로토콜 | std::array + shared_ptr | 수명 관리 용이 |
| 가변 길이 (줄 단위) | asio::streambuf + read_until | 구분자까지 읽기 |
| 대용량 스트리밍 | std::vector + shared_ptr | 동적 확장 |
shared_from_this 사용 조건
// Session이 shared_ptr로 관리될 때만 사용 가능
class Session : public std::enable_shared_from_this<Session> {
// 생성자 안에서 shared_from_this()를 호출하면 아직 shared_ptr가 없으므로
// C++17부터 std::bad_weak_ptr 예외 (그 이전 표준에서는 미정의 동작)
// 반드시 std::make_shared<Session>(...)로 생성된 뒤, start() 같은 별도 함수에서 호출
};
에러 시 리소스 정리 순서
void do_read() {
auto self(shared_from_this());
asio::async_read(socket_, buf, [this, self](boost::system::error_code ec, size_t n) {
if (ec) {
socket_.close(); // 1. 소켓 먼저 닫기
cleanup(); // 2. 세션 정리
return; // 3. 재등록하지 않음
}
process();
do_read(); // 정상 시에만 다음 읽기
});
}
로깅은 핸들러 진입/종료 시
acceptor.async_accept([&](boost::system::error_code ec, tcp::socket socket) {
if (ec) {
spdlog::error("Accept failed: {}", ec.message());
return;
}
boost::system::error_code ep_ec;
auto ep = socket.remote_endpoint(ep_ec);
if (!ep_ec) {
spdlog::info("Connection from {}:{}", ep.address().to_string(), ep.port());
}
// ...
});
remote_endpoint()를 에러 코드 인자 없이 호출하면, accept 직후 상대가 이미 연결을 끊은 경우 Transport endpoint is not connected 예외를 던집니다. 로깅 한 줄 때문에 핸들러에서 예외가 나 run()이 중단될 수 있으므로 위처럼 에러 코드를 받는 오버로드를 씁니다. 또 tcp::endpoint를 spdlog/fmt에 그대로 넘기면, fmt 9 이상에서는 operator<<만 있는 타입을 자동으로 포맷하지 않아 컴파일 에러가 납니다. 주소와 포트를 따로 넘기거나 <fmt/ostream.h>의 fmt::streamed()로 감싸야 합니다.
동기 vs 비동기: 연결 수가 늘 때 무엇이 달라지나
처리량 숫자는 CPU, 커널 설정, 메시지 크기, 측정 도구에 따라 몇 배씩 달라지므로 여기서는 어떤 자원이 먼저 바닥나는지로 비교합니다.
| 방식 | 연결이 늘 때 먼저 부족해지는 것 | 적합한 경우 |
|---|---|---|
| 동기 (스레드 1개) | 동시 처리 자체가 불가 (한 번에 한 연결) | 테스트용, 일회성 도구 |
| 동기 (연결당 스레드) | 스레드 스택 메모리, 문맥 전환 비용, 스레드 수 제한(ulimit -u) | 연결 수가 적고 요청이 긴 작업 |
| 비동기 (스레드 1개) | CPU 한 코어 | 핸들러가 짧은 I/O 중심 서버 |
| 비동기 (스레드 N개) | 핸들러 안의 CPU 작업, 공유 상태 경합 | 대부분의 고동시성 서버 |
연결당 스레드 모델에서 연결이 수천 개를 넘으면 std::system_error: Resource temporarily unavailable(스레드 생성 실패)이 나거나, 스레드는 만들어지더라도 대부분의 시간이 문맥 전환에 쓰입니다. 비동기 모델은 연결 하나에 소켓과 버퍼 몇 KB만 쓰므로 수만 연결도 메모리 면에서는 부담이 적고, 이때는 파일 디스크립터 한도(ulimit -n, 기본 1024인 경우가 많음)가 먼저 걸려 accept가 Too many open files로 실패하는 경우가 흔합니다. 실제 수치가 필요하면 wrk나 h2load 같은 부하 도구로 대상 환경에서 직접 측정하십시오.
연결 제한, 타임아웃, graceful shutdown, 멀티스레드 run
연결 제한
std::atomic<int> connection_count{0};
const int max_connections = 10000;
void do_accept(tcp::acceptor& acceptor) {
acceptor.async_accept([&](boost::system::error_code ec, tcp::socket socket) {
if (ec) return;
if (connection_count >= max_connections) {
socket.close();
do_accept(acceptor);
return;
}
++connection_count;
std::make_shared<Session>(std::move(socket))->start();
do_accept(acceptor);
});
}
// Session 소멸 시 connection_count--
Graceful Shutdown
// SIGINT/SIGTERM 수신 시 io_context 중지
asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&](auto, auto) {
io.stop(); // run() 반환 유도
});
io.stop()은 남은 핸들러를 실행하지 않은 채 가능한 한 빨리 run()을 반환시키므로, 응답을 쓰던 중인 연결은 중간에 끊깁니다. 요청을 마무리하고 끝내고 싶다면 stop() 대신 acceptor.close()로 새 연결만 막고, 각 세션이 현재 요청을 끝낸 뒤 소켓을 닫게 해서 미완료 작업이 0이 되어 run()이 스스로 반환하도록 만드는 편이 좋습니다.
멀티스레드 run 패턴
asio::io_context io;
// 여러 스레드가 run()하면 핸들러가 동시에 실행될 수 있으므로
// 세션 상태는 strand(make_strand)로 보호해야 합니다.
std::vector<std::thread> threads;
const int num_threads = 4;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&io]() { io.run(); });
}
for (auto& t : threads) t.join();
HTTP/WebSocket
HTTP나 WebSocket을 직접 파싱하기보다는 Asio 위에 만들어진 Boost.Beast 같은 라이브러리를 쓰는 편이 안전합니다.