Boost.Asio 입문: io_context, 비동기 타이머·읽기·쓰기, 핸들러 수명과 work_guard
들어가며: “비동기 I/O가 왜 필요한가?”
채팅 서버를 만든다고 상상해 봅시다. 동기(블로킹) 방식으로 구현하면 다음과 같습니다.
// 동기 서버: 한 연결 처리 중에는 다른 연결을 받을 수 없음
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;
// 클라이언트 A가 10초 동안 아무것도 안 보내면?
// → 다른 클라이언트 B, C는 연결조차 못 받음!
}
}
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로 프로세스 전체를 종료시킵니다. 클라이언트 하나가 연결을 끊는 것만으로 서버가 죽는 전형적인 형태입니다.
문제점:
- 클라이언트가 데이터를 보내지 않으면 스레드가 그대로 대기
- 연결 1만 개 = 스레드 1만 개 → 메모리·컨텍스트 스위칭 폭발
- 멀티스레드로 해결하려 해도 스케일 한계에 부딪힘
문제 시나리오 2: 게임 서버·IoT·실시간 데이터
| 시나리오 | 겪는 문제 | 동기 방식 한계 |
|---|---|---|
| 게임 서버 | 5,000명 동시 접속, 저지연 응답 필요 | 스레드 5,000개 → 스레드 스택만 수 GB 예약 (리눅스 기본 8MB) |
| IoT 센서 수집 | 10,000개 디바이스가 주기적 전송 | 블로킹 recv로 처리 불가 |
| 실시간 시세 | 수백 연결에서 동시 푸시 | 한 연결 지연 시 전체 영향 |
| HTTP API 서버 | 요청당 대기 시간 변동 큼 | 느린 클라이언트가 전체 블로킹 |
Asio 비동기 I/O의 해결책:
- 한 스레드가 수천 개 연결을 논블로킹으로 처리
- I/O 완료 시 콜백으로 알림 → 다음 작업 등록
- 이벤트 기반 모델로 리소스 효율 극대화
이 글을 읽기 전에: C++ 기본 문법과 소켓의 개념(#28 소켓 기초)을 알고 있으면 이해가 쉽습니다. “블로킹 서버는 한 연결을 처리하는 동안 다른 연결을 못 받는다”는 한계를 느꼈다면, 이 글의 비동기·io_context·run()이 그다음 단계입니다. 더 깊은 run/poll/Strand는 고성능 네트워크 가이드 #1에서 이어서 다룹니다.
요구 환경: Boost.Asio(
vcpkg install boost-asio등) 또는 standalone Asio, C++14 이상이 필요합니다. Linux/macOS에서는 g++/Clang으로 빌드·실행하고, Windows에서는 WSL 또는 MSVC + vcpkg를 권장합니다.
개념을 잡는 비유
소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보면 됩니다.
비동기 I/O가 왜 필요한가요?
실제 겪는 문제
| 상황 | 동기(블로킹) | 비동기(Asio) |
|---|---|---|
| 10,000 동시 연결 | 스레드 10,000개 필요 | 1~8 스레드로 처리 |
| 클라이언트가 30초 대기 | 30초 동안 스레드 점유 | 다른 작업 처리 가능 |
| 연결 수 증가 | 메모리·CPU 선형 증가 | 거의 일정한 리소스 |
| 네트워크 지연 | 전체 처리량 저하 | 영향 최소화 |
핵심: 비동기 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 대기 시간에는 다른 연결을 처리합니다.
시각적 비교
flowchart TB
subgraph Blocking["블로킹 (연결 3개)"]
B1[스레드1: 연결A 대기]
B2[스레드2: 연결B 대기]
B3[스레드3: 연결C 대기]
end
subgraph Async["비동기 (연결 3개)"]
A1[스레드1: A 콜백]
A2[스레드1: B 콜백]
A3[스레드1: C 콜백]
end
Blocking --> |"스레드 3개"| Blocking
Async --> |"스레드 1개"| Async
io_context와 run
기본 개념
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: run() 내부에서 호출 시 즉시 실행, 외부에서 호출 시 post와 동일
- run(): 작업이 없으면 반환. work_guard를 두면 작업이 없어도 run이 끝나지 않게 할 수 있음
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 <iostream>
#include <chrono>
namespace asio = boost::asio;
int main() {
asio::io_context io;
asio::steady_timer timer(io);
auto start = std::chrono::steady_clock::now();
auto 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 vs async_write_some:
async_write: 전체 버퍼 전송 완료까지 반복 (부분 전송 시 자동 재시도)async_write_some: 일부만 전송해도 콜백 호출
async_read_some: 가변 길이 데이터
// 한 번에 최대 1024바이트만 읽음 (버퍼 가득 차지 않아도 완료)
void do_read_some(tcp::socket& socket) {
auto buf = std::make_shared<std::array<char, 1024>>();
asio::async_read_some(socket, 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 클라이언트
흐름도
flowchart TD
A[async_resolve] --> B[async_connect]
B --> C[async_write 요청]
C --> D[async_read 응답]
D --> E{더 읽을 데이터?}
E -->|예| D
E -->|아니오| F[종료]
- async_connect로 연결
- 연결 완료 핸들러에서 async_read / 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 <iostream>
#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>>();
asio::async_read_some(socket, 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을 비동기로 읽을 수도 있습니다). 또 std::array를 쓰므로 실제 빌드 시에는 <array>를 include해야 합니다.
비동기 서버 (async_accept)
Session 클래스와 async_read 체이닝
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();
}
- 수락 핸들러에서 socket을 받으며, 그 소켓으로 async_read / async_write 시작
- 핸들러 끝에서 다시 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: work_guard 없이 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(); // 연결 하나 받고 바로 끝!
// ✅ 해결 1: 핸들러에서 do_accept 재호출 (이미 예제에 포함)
// ✅ 해결 2: work_guard로 "할 일 있음" 유지
asio::executor_work_guard<asio::io_context::executor_type> work =
asio::make_work_guard(io);
// 이제 io에 작업이 없어도 run()이 반환하지 않음
실수 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로 다시 run() 호출 시 아무 일도 안 함
io.run(); // 작업 완료로 반환
io.run(); // 즉시 반환 (아무 작업 없음)
// ✅ 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() {
asio::async_read(socket_, buf, [this](boost::system::error_code ec, size_t n) {
if (ec) {
socket_.close(); // 1. 소켓 먼저 닫기
cleanup(); // 2. 세션 정리
return; // 3. 재등록하지 않음
}
process();
do_read(); // 정상 시에만 다음 읽기
});
}
타임아웃은 타이머 + cancel 조합
// 읽기와 타이머를 함께 등록, 먼저 완료되는 쪽이 나머지 취소
timer_.expires_after(std::chrono::seconds(30));
timer_.async_wait([this](auto ec) { if (!ec) socket_.cancel(); });
asio::async_read_until(socket_, buffer_, '\n',
[this](auto ec, auto n) {
timer_.cancel(); // 읽기 완료 시 타이머 취소
if (!ec) handle_read(n);
});
로깅은 핸들러 진입/종료 시
acceptor.async_accept([&](boost::system::error_code ec, tcp::socket socket) {
if (ec) {
spdlog::error("Accept failed: {}", ec.message());
return;
}
spdlog::info("Connection from {}", socket.remote_endpoint().address().to_string());
// ...
});
remote_endpoint()를 에러 코드 인자 없이 호출하면, accept 직후 상대가 이미 연결을 끊은 경우 Transport endpoint is not connected 예외를 던집니다. 로깅 한 줄 때문에 핸들러에서 예외가 나 run()이 중단되는 일이 실제로 있으므로, boost::system::error_code ec; auto ep = socket.remote_endpoint(ec); 형태를 쓰는 편이 안전합니다.
동기 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--
타임아웃 적용
// async_read에 30초 타임아웃
void Session::do_read() {
timer_.expires_after(std::chrono::seconds(30));
timer_.async_wait([this](boost::system::error_code ec) {
if (!ec) socket_.cancel();
});
asio::async_read_until(socket_, buffer_, '\n', ...);
}
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()이 스스로 반환하도록 만드는 편이 좋습니다. 이 방식은 #29-2 이벤트 루프에서 더 자세히 다룹니다.
로깅
acceptor.async_accept([&](boost::system::error_code ec, tcp::socket socket) {
if (ec) {
spdlog::error("Accept failed: {}", ec.message());
return;
}
spdlog::info("New connection from {}", socket.remote_endpoint());
// ...
});
이 형태처럼 tcp::endpoint를 spdlog/fmt에 그대로 넘기면, fmt 9 이상에서는 operator<<만 있는 타입을 자동으로 포맷하지 않아 Cannot format an argument 계열의 컴파일 에러가 납니다. endpoint.address().to_string()과 endpoint.port()를 따로 넘기거나, <fmt/ostream.h>의 fmt::streamed()로 감싸야 합니다.
멀티스레드 run 패턴
asio::io_context 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(); });
}
for (auto& t : threads) t.join();
HTTP/WebSocket
실제 프로토콜 구현에는 Boost.Beast처럼 Asio 기반 라이브러리를 사용하는 것을 권장합니다.
같이 보면 좋은 글
- C++ Boost.Asio io_context 이벤트 루프 | 동작 원리 정리 [#1]
- C++ Asio 데드락 디버깅 | 비동기 콜백 실전 [#49-3]
- C++ 멀티스레드 네트워크 서버
- Asio 이벤트 루프 동작 원리
- C++ 소켓 프로그래밍
- C++ 네트워크 에러 처리
- C++에서 HTTP 제대로 파싱하기
자주 묻는 질문 (FAQ)
Q. io_context::run()이 바로 리턴해서 프로그램이 끝나는 이유는 무엇인가요?
A. run()은 처리할 비동기 작업이 하나도 남아 있지 않으면 즉시 반환합니다. 비동기 작업을 등록하기 전에 run()을 호출했거나 모든 핸들러가 끝나면 그대로 종료됩니다. make_work_guard로 io_context를 붙잡아 두거나 완료 핸들러에서 다음 비동기 작업을 다시 등록하면 되고, 한 번 반환된 io_context를 다시 돌리려면 restart()를 먼저 호출해야 합니다.