C++ Boost.Asio 고급 패턴 | 커스텀 서비스·타이머·시그널 [#52-1]
이 글의 핵심
C++ Asio 고급 기법: strand, work_guard, co_spawn, awaitable, composed 연산, 커스텀 서비스, 타이머·시그널 처리. 실전 문제 시나리오, 자주 발생하는 에러, 프로덕션 패턴까지.
들어가며: “타이머가 수천 개면 io_context가 느려져요”
문제 시나리오: 대규모 타이머의 한계
연결당 하나의 타임아웃 타이머를 두는 서버를 상상해 보세요. 연결 10,000개면 타이머 10,000개. 각 타이머가 steady_timer로 구현되어 있다면:
// ❌ 문제: 연결마다 steady_timer → 10,000개 타이머 객체
class Session : public std::enable_shared_from_this<Session> {
tcp::socket socket_;
asio::steady_timer timer_; // 연결당 1개
// ...
};
실제 겪는 문제:
- 타이머 객체가 많을수록 메모리·스케줄링 오버헤드 증가
steady_timer는 내부적으로 타이머 큐를 사용하는데, 수만 개가 되면 O(log n) 연산이 누적됨- SIGINT/SIGTERM 수신 시 graceful shutdown을 해야 하는데, 시그널 핸들링을 Asio 이벤트 루프에 통합하지 않으면 데드락 발생
해결책: 커스텀 서비스(타이머 휠), 타이머 최적화, signal_set 시그널 처리, 완료 토큰. 요구 환경: C++17 이상, Boost.Asio 1.70+.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 문제 시나리오 정리
- 커스텀 서비스
- 타이머 최적화
- 시그널 처리
- 커스텀 완료 토큰
- Strand·work_guard·co_spawn·awaitable·composed 연산
- 자주 발생하는 문제
- 성능 최적화
- 프로덕션 패턴
- 실전 예제: Graceful Shutdown 서버
1. 문제 시나리오 정리
시나리오 1: 타이머 폭발
| 상황 | 증상 | 원인 |
|---|---|---|
| 연결 10,000개 | 메모리 50MB+ 타이머만 사용 | steady_timer 연결당 1개 |
| 타이머 만료 빈도 높음 | CPU 사용률 급증 | 타이머 큐 O(log n) × N |
| 주기적 keepalive | 매 초 수천 개 타이머 만료 | 개별 타이머 대신 휠 타이머 |
해결 방향: 커스텀 타이머 휠 서비스로 여러 타이머를 하나의 버킷으로 묶기.
시나리오 2: 시그널과 run() 충돌
// ❌ 문제: main 스레드에서 signal() 등록 → io_context와 분리
int main() {
signal(SIGINT, { /* 뭘 해야 하지? */ }); // 블로킹 시그널 핸들러
io_context io;
// run() 중에 SIGINT 오면? 스레드 안전하지 않음
io.run();
}
문제점:
signal()핸들러는 시그널 컨텍스트에서 실행 →io.stop()호출 시 데드락 가능io_context의run()과 동기화되지 않음
해결 방향: asio::signal_set으로 시그널을 비동기 이벤트로 받아, io_context 스레드에서 처리.
시나리오 3: 비동기 연산 관측성 부족
// 모든 async_read/write에 수동 로깅?
asio::async_read(socket, buf, {
log_duration(...); // 매번 복붙
if (!ec) process(n);
});
해결 방향: 커스텀 완료 토큰으로 “완료 시 자동 로깅·메트릭”을 주입.
시나리오 4: Strand 없이 멀티스레드에서 race condition
// ❌ 문제: 여러 스레드가 io.run() 실행 시, 같은 Session의 read/write 핸들러가 동시 실행
asio::io_context io;
// 4개 스레드에서 io.run()
// Session::do_read()와 do_write()가 서로 다른 스레드에서 동시에 buffer_ 접근 → 데이터 손상
해결 방향: asio::strand로 연결당 핸들러를 직렬화하여 락 없이 스레드 안전 보장.
시나리오 5: work_guard 없이 run()이 즉시 반환
// ❌ 문제: 비동기 작업만 등록하고 run() 호출 시, 작업이 큐에 들어가기 전에 run()이 끝남
asio::io_context io;
acceptor_.async_accept(...); // 비동기 등록
io.run(); // accept 완료 전에 run() 반환 가능!
해결 방향: asio::make_work_guard(io)로 미완료 작업이 있음을 알려 run()이 대기하도록 함.
2. 커스텀 서비스
io_context::service 상속
Asio의 io_context는 서비스를 등록해 확장할 수 있습니다. 서비스는 io_context의 수명에 묶여, io_context가 파괴될 때 함께 파괴됩니다.
#include <boost/asio.hpp>
#include <iostream>
namespace asio = boost::asio;
// 1. io_context::service를 상속
class metrics_service : public asio::io_context::service {
public:
// 서비스 타입 식별자 (고유해야 함)
static asio::io_context::id id;
explicit metrics_service(asio::io_context& io)
: asio::io_context::service(io) {}
// shutdown 시 정리
void shutdown() override {
std::cout << "Metrics: total_ops=" << total_ops_ << "\n";
}
void record_async_op(const char* name) {
++total_ops_;
// 프로메테우스 등으로 메트릭 전송 가능
}
private:
std::atomic<uint64_t> total_ops_{0};
};
asio::io_context::id metrics_service::id;
// 2. io_context에 서비스 등록
int main() {
asio::io_context io;
asio::add_service<metrics_service>(io, new metrics_service(io));
// 3. 서비스 사용
auto& svc = asio::use_service<metrics_service>(io);
svc.record_async_op("accept");
svc.record_async_op("read");
io.run();
return 0;
}
핵심:
io_context::id는 타입별 고유 식별자. 서비스 타입마다 하나씩 선언.add_service로 등록 (한 번만).use_service로 참조 획득.shutdown()에서 리소스 정리.
타이머 휠 서비스 예시
#include <boost/asio.hpp>
#include <chrono>
#include <functional>
#include <vector>
namespace asio = boost::asio;
// 간단한 타이머 휠: N 버킷, 각 버킷에 콜백 리스트
class timer_wheel_service : public asio::io_context::service {
public:
static asio::io_context::id id;
using callback_t = std::function<void(boost::system::error_code)>;
explicit timer_wheel_service(asio::io_context& io)
: asio::io_context::service(io)
, timer_(io)
, bucket_count_(1024)
, current_bucket_(0) {
buckets_.resize(bucket_count_);
start_tick();
}
// "delay_ms 후에 callback 호출" 등록
void schedule(int delay_ms, callback_t cb) {
size_t bucket = (current_bucket_ + delay_ms) % bucket_count_;
buckets_[bucket].push_back(std::move(cb));
}
void shutdown() override {
timer_.cancel();
}
private:
void start_tick() {
timer_.expires_after(std::chrono::milliseconds(1));
timer_.async_wait([this](boost::system::error_code ec) {
if (ec) return;
// 현재 버킷의 모든 콜백 실행
for (auto& cb : buckets_[current_bucket_]) {
cb(boost::system::error_code{});
}
buckets_[current_bucket_].clear();
current_bucket_ = (current_bucket_ + 1) % bucket_count_;
start_tick();
});
}
asio::steady_timer timer_;
size_t bucket_count_;
size_t current_bucket_;
std::vector<std::vector<callback_t>> buckets_;
};
asio::io_context::id timer_wheel_service::id;
장점: 수천 개의 “1ms 단위 타이머”를 1024개 버킷으로 묶어, 매 틱마다 한 버킷만 처리. steady_timer 1개로 대체 가능.
타이머 휠 동작 원리
flowchart LR
subgraph Wheel["타이머 휠 (1024 버킷)"]
B0[0]
B1[1]
B2[2]
Bdot[...]
B1023[1023]
end
T[1ms tick] --> B0
B0 -->|현재 버킷 콜백 실행| Exec[실행]
Exec --> B1
- 매 1ms마다
current_bucket_의 모든 콜백 실행 schedule(delay_ms, cb):(current + delay) % 1024버킷에 추가- 해상도 1ms, 최대 1024ms 지연. 더 긴 지연은 체인으로 확장 가능.
3. 타이머 최적화
steady_timer vs deadline_timer
| 항목 | steady_timer | deadline_timer |
|---|---|---|
| 기준 시각 | monotonic (시스템 부팅 후) | 시스템 시계 (NTP 영향) |
| 용도 | 타임아웃, keepalive | ”오후 3시에 실행” 같은 절대 시각 |
| 시계 변경 | 영향 없음 | 영향 받음 (드리프트) |
#include <boost/asio.hpp>
#include <chrono>
namespace asio = boost::asio;
void timer_comparison(asio::io_context& io) {
// ✅ 타임아웃·keepalive: steady_timer
asio::steady_timer steady_timer(io);
steady_timer.expires_after(std::chrono::seconds(30));
steady_timer.async_wait( {
if (!ec) { /* 30초 타임아웃 */ }
});
// ⚠️ 절대 시각 필요 시에만 deadline_timer
asio::deadline_timer deadline_timer(io);
deadline_timer.expires_at(boost::posix_time::second_clock::local_time() +
boost::posix_time::seconds(30));
}
타이머 재사용 패턴
// ❌ 나쁜 예: 매번 새 타이머 생성
void do_read_with_timeout() {
auto timer = std::make_shared<asio::steady_timer>(io_);
timer->expires_after(std::chrono::seconds(30));
timer->async_wait([timer, this](auto ec) {
if (!ec) socket_.cancel();
});
asio::async_read(socket_, buf, [timer, this](auto ec, auto n) {
timer->cancel(); // 읽기 완료 시 타이머 취소
if (!ec) process(n);
});
}
// ✅ 좋은 예: 세션에 타이머 1개, 재사용
class Session : public std::enable_shared_from_this<Session> {
asio::steady_timer timer_;
// ...
void do_read() {
timer_.expires_after(std::chrono::seconds(30));
timer_.async_wait([self = shared_from_this()](auto ec) {
if (!ec) self->socket_.cancel();
});
asio::async_read(socket_, buf,
asio::bind_executor(strand_, [self = shared_from_this()](auto ec, auto n) {
self->timer_.cancel(); // 재사용
if (!ec) self->process(n);
}));
}
};
타이머 취소 시 주의점
// cancel() 호출 시 operation_aborted로 완료됨
timer_.async_wait([this](boost::system::error_code ec) {
if (ec == asio::error::operation_aborted) {
// 정상: 타이머가 취소됨 (읽기 완료 등)
return;
}
if (!ec) {
// 타임아웃 발생
socket_.cancel();
}
});
4. 시그널 처리
signal_set으로 SIGINT/SIGTERM 통합
#include <boost/asio.hpp>
#include <csignal>
#include <iostream>
namespace asio = boost::asio;
int main() {
asio::io_context io;
// SIGINT(Ctrl+C), SIGTERM 수신 시 async_wait 완료
asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&io](boost::system::error_code ec, int signo) {
if (ec) return;
std::cout << "Received signal " << signo << ", stopping...\n";
io.stop(); // run()이 반환하도록
});
// 서버 초기화 (async_accept 등)
// ...
io.run();
std::cout << "Graceful shutdown complete\n";
return 0;
}
핵심: 시그널이 비동기 이벤트로 전달되므로, io_context 스레드에서 안전하게 io.stop() 호출 가능.
Graceful Shutdown 시퀀스
sequenceDiagram
participant User as 사용자
participant OS as OS
participant SS as signal_set
participant IO as io_context
participant Server as 서버
User->>OS: Ctrl+C (SIGINT)
OS->>SS: 시그널 전달
SS->>IO: async_wait 완료
IO->>SS: 핸들러 실행
SS->>IO: io.stop()
IO->>Server: run() 반환
Server->>Server: 연결 정리, 리소스 해제
work_guard와 함께 사용
asio::io_context io;
asio::executor_work_guard<asio::io_context::executor_type> work =
asio::make_work_guard(io);
asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&io, &work](auto ec, int signo) {
if (ec) return;
work.reset(); // work 해제 → run()이 종료 가능
io.stop();
});
// 서버 시작
io.run();
5. 커스텀 완료 토큰
완료 토큰이란?
Asio 비동기 연산의 마지막 인자는 완료 토큰(Completion Token)입니다. 토큰 타입에 따라:
- 콜백:
void(error_code, size_t)형태의 핸들러 - use_awaitable: 코루틴에서
co_await - use_future:
std::future반환
커스텀 토큰으로 “완료 시 자동 로깅”을 주입할 수 있습니다.
로깅 완료 토큰 예시
#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
namespace asio = boost::asio;
// 로깅 래퍼: 기존 토큰을 감싸서 완료 시 로그 출력
template <typename InnerToken>
struct with_logging {
InnerToken token_;
const char* op_name_;
template <typename... Args>
auto operator()(Args&&... args) {
auto start = std::chrono::steady_clock::now();
return asio::async_initiate<InnerToken, void(boost::system::error_code, size_t)>(
[start, op_name = op_name_, token = token_](auto&& handler) mutable {
// 내부적으로 원래 토큰으로 연산 시작
// 완료 시 로그 후 원래 핸들러 호출
// (실제 구현은 async_initiate + 연산별 특화 필요)
},
token
);
}
};
// 사용 (개념)
// asio::async_read(socket, buf, with_logging{callback, "read"});
실무에서는 기존 콜백을 래핑하는 헬퍼 함수가 더 단순합니다:
template <typename Handler>
auto with_metrics(const char* op_name, Handler&& h) {
return [op_name, h = std::forward<Handler>(h)](
boost::system::error_code ec, size_t n) mutable {
auto duration = /* 측정 */;
if (ec) {
log_error(op_name, ec, duration);
} else {
log_success(op_name, n, duration);
}
h(ec, n);
};
}
// 사용
asio::async_read(socket_, buf,
with_metrics("read", [this](auto ec, auto n) {
if (!ec) process(n);
}));
6. Strand·work_guard·co_spawn·awaitable·composed 연산
Strand 완전 예제: 멀티스레드에서 연결당 직렬화
Strand는 동일 executor에서 실행되는 핸들러들을 직렬화합니다. 여러 스레드가 io.run()을 호출해도, 같은 strand에 바인드된 핸들러는 동시에 실행되지 않습니다.
class StrandSession : public std::enable_shared_from_this<StrandSession> {
tcp::socket socket_;
asio::strand<asio::io_context::executor_type> strand_;
std::array<char, 4096> buf_;
public:
StrandSession(tcp::socket socket, asio::io_context& io)
: socket_(std::move(socket)), strand_(asio::make_strand(io)) {}
void start() { do_read(); }
private:
void do_read() {
asio::async_read_some(socket_, asio::buffer(buf_),
asio::bind_executor(strand_, [self = shared_from_this()](auto ec, size_t n) {
if (ec) return;
self->do_write(n);
}));
}
void do_write(size_t n) {
asio::async_write(socket_, asio::buffer(buf_, n),
asio::bind_executor(strand_, [self = shared_from_this()](auto ec, size_t) {
if (!ec) self->do_read();
}));
}
};
// 4개 스레드에서 io.run() → strand 덕분에 Session 내부 race 없음
핵심: asio::bind_executor(strand_, handler)로 핸들러를 strand에 묶으면, 해당 핸들러들은 한 번에 하나씩만 실행됩니다.
work_guard 완전 예제: 서버 수명 주기 제어
work_guard는 io_context에 “아직 할 일이 있다”는 신호를 줍니다. work_guard가 살아 있는 동안 run()은 빈 큐가 되어도 반환하지 않습니다.
asio::io_context io;
auto work = asio::make_work_guard(io);
asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&io, &work](auto ec, int signo) {
if (ec) return;
work.reset(); // work 해제 → run()이 종료 가능
io.stop();
});
io.run(); // work.reset() 전까지 대기
주의: Graceful shutdown 시 반드시 work.reset() 후 io.stop() 호출.
co_spawn과 awaitable: C++20 코루틴 에코 서버
C++20 코루틴과 use_awaitable을 사용하면 콜백 지옥 없이 동기 스타일로 비동기 코드를 작성할 수 있습니다.
using boost::asio::awaitable;
using boost::asio::co_spawn;
using boost::asio::detached;
using boost::asio::use_awaitable;
asio::awaitable<void> echo_session(tcp::socket socket) {
try {
char buf[1024];
for (;;) {
std::size_t n = co_await socket.async_read_some(asio::buffer(buf), use_awaitable);
co_await asio::async_write(socket, asio::buffer(buf, n), use_awaitable);
}
} catch (const std::exception& e) {
std::cerr << "Echo: " << e.what() << "\n";
}
}
asio::awaitable<void> listener(tcp::acceptor acceptor) {
for (;;) {
tcp::socket socket = co_await acceptor.async_accept(use_awaitable);
co_spawn(acceptor.get_executor(), echo_session(std::move(socket)), detached);
}
}
// main: co_spawn(io, listener(std::move(acceptor)), detached); io.run();
빌드: -std=c++20 필요. Boost 1.78+ 또는 standalone Asio.
핵심: co_await async_xxx(..., use_awaitable)로 비동기 대기, co_spawn(executor, awaitable, detached)로 코루틴 실행.
composed 연산: async_compose로 복합 비동기 연산 구현
async_compose로 여러 비동기 단계를 하나의 연산으로 묶을 수 있습니다.
template <typename CompletionToken>
auto async_read_then_echo(tcp::socket& socket, asio::mutable_buffer buffer, CompletionToken&& token) {
return asio::async_compose<CompletionToken, void(boost::system::error_code, std::size_t)>(
[&socket, buffer, state = 0](auto& self, boost::system::error_code ec = {}, std::size_t n = 0) mutable {
if (ec) { self.complete(ec, 0); return; }
switch (state) {
case 0: state = 1; socket.async_read_some(buffer, std::move(self)); break;
case 1: state = 2; asio::async_write(socket, asio::buffer(buffer, n), std::move(self)); break;
case 2: self.complete(ec, n); break;
}
},
token, socket);
}
핵심: Self& self를 다음 비동기 연산에 std::move(self)로 전달. 완료 시 self.complete(ec, result) 호출 필수.
7. 자주 발생하는 문제
문제 1: service_already_exists
// ❌ 에러: 같은 타입 서비스를 두 번 등록
asio::add_service<metrics_service>(io, new metrics_service(io));
asio::add_service<metrics_service>(io, new metrics_service(io)); // 예외!
해결법:
// ✅ use_service는 없으면 자동 생성하지 않음. add_service 한 번만.
if (!io.has_service<metrics_service>()) {
asio::add_service<metrics_service>(io, new metrics_service(io));
}
auto& svc = asio::use_service<metrics_service>(io);
문제 2: 시그널 핸들러에서 io.stop()만 호출
// ⚠️ work_guard가 있으면 io.stop()만으로 run()이 안 끝남
asio::executor_work_guard guard = asio::make_work_guard(io);
signals.async_wait([&](auto ec, int) {
io.stop(); // run()은 여전히 대기 (work가 있음)
});
io.run(); // 종료 안 됨
해결법:
signals.async_wait([&guard, &io](auto ec, int) {
guard.reset(); // work 해제
io.stop();
});
문제 3: 타이머와 소켓의 수명 불일치
// ❌ Session 소멸 후 타이머 콜백 실행
void do_read() {
timer_.async_wait([this](auto ec) { // this 포착
socket_.cancel(); // this가 이미 소멸됐을 수 있음!
});
}
해결법:
void do_read() {
auto self = shared_from_this();
timer_.async_wait([self](auto ec) {
if (!ec) self->socket_.cancel();
});
}
문제 4: deadline_timer 시계 드리프트
// ❌ NTP 동기화로 시스템 시계가 바뀌면 deadline_timer 동작 이상
asio::deadline_timer t(io);
t.expires_from_now(boost::posix_time::seconds(60));
// 시스템 시계가 1시간 뒤로 맞춰지면? 60초가 아닌 1시간+ 대기
해결법: 타임아웃·keepalive에는 steady_timer 사용.
문제 5: add_service 후 서비스 소유권
// ❌ add_service에 전달한 포인터는 io_context가 소유권 가져감
auto* svc = new metrics_service(io);
asio::add_service<metrics_service>(io, svc);
// 이후 svc를 delete하면 안 됨! io가 파괴될 때 자동 삭제
해결법: add_service 후에는 해당 포인터를 delete하지 않음. io_context가 소멸 시 자동 정리.
문제 6: signal_set을 여러 번 async_wait
// ✅ 시그널 수신 후 다시 대기하려면 재등록
signals_.async_wait([this](error_code ec, int signo) {
if (ec) return;
handle_signal(signo);
signals_.async_wait(/* 같은 핸들러 또는 다른 람다 */); // 재등록
});
참고: graceful shutdown 목적이면 한 번 수신 후 io.stop() 호출이 일반적. 재등록은 “여러 시그널 처리” 시 필요.
문제 7: co_spawn에서 예외가 코루틴 밖으로 전파되지 않음
// ❌ co_spawn에 detached 사용 시, 코루틴 내 예외가 무시됨
co_spawn(io, risky_operation(), asio::detached);
// risky_operation()에서 throw → 아무도 처리 안 함
해결법:
// ✅ use_awaitable + try-catch로 예외 처리
asio::awaitable<void> safe_operation() {
try {
co_await risky_operation();
} catch (const std::exception& e) {
spdlog::error("Operation failed: {}", e.what());
}
}
co_spawn(io, safe_operation(), asio::detached);
문제 8: Strand를 잘못 사용해 데드락
// ❌ 같은 strand에서 co_await로 자기 자신을 대기하면 데드락
co_await asio::post(strand_, asio::use_awaitable); // strand에서 실행 중
co_await asio::post(strand_, asio::use_awaitable); // 같은 strand 대기 → 데드락
해결법: strand 내에서 co_await할 때 중첩된 strand 호출을 피하세요.
문제 9: composed 연산에서 self.complete() 누락
// ❌ state_ == 2일 때 self.complete() 호출 안 함 → 영원히 멈춤
case 2:
// self.complete(ec, n); // 누락!
break;
해결법: 모든 종료 경로에서 self.complete() 또는 self.complete(ec, result) 호출 필수.
8. 성능 최적화
타이머 개수 vs 휠
| 방식 | 타이머 10,000개 시 |
|---|---|
| steady_timer 10,000개 | 메모리 ~2MB, 스케줄링 O(N log N) |
| 타이머 휠 1개 | 메모리 ~100KB, O(1) per tick |
Strand로 락 제거
// ❌ Mutex로 보호
std::mutex mtx_;
void on_read(size_t n) {
std::lock_guard<std::mutex> lk(mtx_);
write_queue_ += data;
}
// ✅ Strand로 직렬화 (락 없음)
asio::strand<asio::io_context::executor_type> strand_;
void on_read(size_t n) {
// Strand에서만 실행 → 락 불필요
write_queue_ += data;
}
버퍼 재사용
// ❌ 매 읽기마다 새 버퍼
void do_read() {
auto buf = std::make_shared<std::vector<char>>(1024);
asio::async_read(socket_, asio::buffer(*buf), ...);
}
// ✅ 세션에 버퍼 고정
std::array<char, 4096> read_buf_;
void do_read() {
asio::async_read_some(socket_, asio::buffer(read_buf_), ...);
}
성능 비교 요약
| 항목 | 비최적화 | 최적화 |
|---|---|---|
| 타이머 10,000개 | steady_timer 10,000개, ~2MB | 휠 1개, ~100KB |
| 동시성 제어 | Mutex per session | Strand (락 없음) |
| 버퍼 할당 | 매 읽기마다 heap | 세션당 고정 버퍼 |
| 시그널 | signal() + 락 | signal_set (이벤트 통합) |
9. 프로덕션 패턴
Graceful Shutdown 체크리스트
// 1. signal_set 등록
asio::signal_set signals(io, SIGINT, SIGTERM);
// 2. acceptor 닫기
// 3. 모든 연결에 "더 이상 읽지 않음" 전파
// 4. 남은 쓰기 완료 대기
// 5. 소켓 닫기
// 6. work_guard 해제 후 io.stop()
연결 제한 + 타이머
std::atomic<int> conn_count{0};
const int max_conn = 10000;
void do_accept() {
acceptor_.async_accept([this](error_code ec, tcp::socket socket) {
if (ec) return;
if (conn_count >= max_conn) {
socket.close();
do_accept();
return;
}
++conn_count;
std::make_shared<Session>(std::move(socket), io_)->start();
do_accept();
});
}
// Session::~Session() { --conn_count; }
로깅 통합
#include <spdlog/spdlog.h>
acceptor_.async_accept([this](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());
// ...
});
멀티스레드 + 시그널 주의점
// 여러 스레드가 io.run() 실행 시, 시그널 핸들러는 한 번만 실행됨
asio::io_context io;
asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&io](auto ec, int signo) {
if (ec) return;
io.stop(); // 모든 run() 중인 스레드에 stop 전파
});
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&io]() { io.run(); });
}
for (auto& t : threads) t.join();
주의: io.stop()은 이미 대기 중인 스레드만 깨움. 새로 post된 작업은 실행되지 않음.
타임아웃과 읽기 경쟁
// 타이머와 async_read가 동시에 완료될 수 있음
void do_read() {
timer_.expires_after(std::chrono::seconds(30));
timer_.async_wait([self = shared_from_this()](error_code ec) {
if (!ec) self->socket_.cancel(); // operation_aborted 유발
});
asio::async_read_until(socket_, buffer_, '\n',
[self = shared_from_this()](error_code ec, size_t n) {
self->timer_.cancel(); // 타이머 취소 (operation_aborted)
if (ec == asio::error::operation_aborted) return; // 정상: 타임아웃에 의한 취소
if (ec) { /* 네트워크 에러 */ return; }
self->process(n);
});
}
정리: operation_aborted는 “의도적 취소”이므로 에러로 간주하지 않음.
Best Practices 요약
| 항목 | 권장 | 비권장 |
|---|---|---|
| 타이머 | steady_timer + 세션당 1개 재사용 | deadline_timer (타임아웃용), 매번 새 타이머 |
| 시그널 | signal_set + io_context 통합 | signal() 직접 사용 |
| 동시성 | strand로 연결당 직렬화 | std::mutex로 핸들러 보호 |
| 수명 | shared_from_this()로 핸들러에 전달 | this 직접 포착 |
| Shutdown | work_guard.reset() → io.stop() | io.stop()만 호출 |
| 에러 | operation_aborted 별도 처리 | 모든 ec를 동일하게 처리 |
| 코루틴 | try-catch로 예외 처리 | detached만 사용 |
10. 실전 예제: Graceful Shutdown 서버
#include <boost/asio.hpp>
#include <csignal>
#include <iostream>
#include <memory>
#include <atomic>
namespace asio = boost::asio;
using boost::asio::ip::tcp;
using boost::system::error_code;
class Session : public std::enable_shared_from_this<Session> {
public:
Session(tcp::socket socket, asio::io_context& io)
: socket_(std::move(socket))
, strand_(asio::make_strand(io))
, timer_(io)
, io_(io) {}
void start() {
do_read();
}
private:
void do_read() {
timer_.expires_after(std::chrono::seconds(30));
timer_.async_wait([self = shared_from_this()](error_code ec) {
if (!ec) self->socket_.cancel();
});
asio::async_read_until(socket_, buffer_, '\n',
asio::bind_executor(strand_, [self = shared_from_this()](error_code ec, size_t n) {
self->timer_.cancel();
if (ec) return;
std::istream is(&self->buffer_);
std::string line;
std::getline(is, line);
self->do_write("Echo: " + line + "\n");
}));
}
void do_write(const std::string& msg) {
asio::async_write(socket_, asio::buffer(msg),
asio::bind_executor(strand_, [self = shared_from_this()](error_code ec, size_t) {
if (!ec) self->do_read();
}));
}
tcp::socket socket_;
asio::strand<asio::io_context::executor_type> strand_;
asio::streambuf buffer_;
asio::steady_timer timer_;
asio::io_context& io_;
};
class Server {
public:
Server(asio::io_context& io, uint16_t port)
: io_(io)
, acceptor_(io, tcp::endpoint(tcp::v4(), port))
, work_(asio::make_work_guard(io))
, signals_(io, SIGINT, SIGTERM) {
signals_.async_wait([this](error_code ec, int signo) {
if (ec) return;
std::cout << "Signal " << signo << ", shutting down...\n";
work_.reset();
acceptor_.close();
io_.stop();
});
do_accept();
}
private:
void do_accept() {
acceptor_.async_accept([this](error_code ec, tcp::socket socket) {
if (ec) return;
std::make_shared<Session>(std::move(socket), io_)->start();
do_accept();
});
}
asio::io_context& io_;
tcp::acceptor acceptor_;
asio::executor_work_guard<asio::io_context::executor_type> work_;
asio::signal_set signals_;
};
int main() {
asio::io_context io;
Server server(io, 8080);
std::cout << "Echo server on :8080 (Ctrl+C to stop)\n";
io.run();
std::cout << "Shutdown complete\n";
return 0;
}
동작:
- Echo 서버가 8080 포트에서 대기
- Ctrl+C 또는 SIGTERM 수신 시
signal_set완료 work_guard해제 →acceptor닫기 →io.stop()run()반환 후 정리 완료
빌드 및 실행
# vcpkg로 Boost 설치
vcpkg install boost-asio
# 컴파일 (g++)
g++ -std=c++17 -O2 -o echo_server main.cpp -lboost_system -pthread
# 실행
./echo_server
# 다른 터미널에서: echo "hello" | nc localhost 8080
# Ctrl+C로 종료
메트릭 서비스 통합
add_service<metrics_service>(io, ...) 후 use_service<metrics_service>(io)로 참조. io.run() 종료 시 shutdown()에서 메트릭 출력.
아키텍처 다이어그램
flowchart TB
subgraph IO[io_context]
SS[signal_set]
ACC[acceptor]
S1[Session 1]
S2[Session 2]
end
subgraph Session[Session 구조]
SOCK[socket]
STRAND[strand]
TIMER[steady_timer]
BUF[buffer]
end
SS -->|SIGINT/SIGTERM| IO
ACC -->|새 연결| S1
ACC -->|새 연결| S2
S1 --> SOCK
S1 --> STRAND
S1 --> TIMER
정리
| 항목 | 설명 |
|---|---|
| 커스텀 서비스 | io_context::service 상속, add_service/use_service |
| 타이머 | steady_timer(타임아웃), deadline_timer(절대 시각), 휠로 대량 최적화 |
| 시그널 | signal_set으로 SIGINT/SIGTERM을 비동기 이벤트로 처리 |
| 완료 토큰 | 로깅·메트릭 래퍼로 관측성 강화 |
| Graceful Shutdown | signal_set + work_guard 해제 + acceptor 닫기 |
핵심 원칙:
- 타이머는 steady_timer + 세션당 1개 재사용
- 시그널은 signal_set으로 io_context에 통합
- Strand로 락 없이 동시성 제어
- 커스텀 서비스로 타이머 휠 등 확장
구현 체크리스트
-
steady_timervsdeadline_timer선택 (타임아웃 → steady) -
signal_set으로 SIGINT/SIGTERM 등록 -
work_guard해제 후io.stop()(graceful shutdown) - 핸들러에
shared_from_this로 수명 연장 - Strand로 연결당 직렬화 (락 제거)
- 버퍼 재사용 (세션 멤버로 고정)
- 연결 제한 (max_connections)
- 에러 로깅 (spdlog 등)
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 복잡한 비동기 시스템, 고성능 네트워크 서버, 커스텀 프로토콜 구현에 활용합니다.
Q. 타이머 휠과 steady_timer, 언제 무엇을 쓰나요?
A. 연결 수천 개·타임아웃 10~60초면 steady_timer 연결당 1개로 충분. 수만 개 + ms 단위 keepalive면 타이머 휠 고려.
Q. Windows에서 signal_set이 동작하나요?
A. SetConsoleCtrlHandler로 Ctrl+C 처리. SIGTERM은 Unix 전용.
Q. use_awaitable과 커스텀 토큰을 같이 쓸 수 있나요?
A. 네. with_logging<asio::use_awaitable_t<>>로 감싸면 co_await async_read(..., with_logging{...}) 형태 사용 가능. async_initiate와 연산별 특화 필요.
한 줄 요약: 커스텀 서비스·타이머·시그널을 마스터하면 고성능 Asio 서버를 완성할 수 있습니다.
이 글에서 다루는 키워드 (관련 검색어)
Boost.Asio 고급, strand, work_guard, co_spawn, awaitable, composed 연산, 커스텀 서비스, steady_timer, signal_set, graceful shutdown, 타이머 휠, 완료 토큰 등으로 검색하시면 이 글이 도움이 됩니다.
참고 자료
- Boost.Asio 공식 문서 - Services
- Boost.Asio C++20 Coroutines
- Boost.Asio - async_compose
- Boost.Asio - Timer examples
- Boost.Asio - Signal handling
- 고성능 네트워크 가이드 #3: Strand
- C++ Asio 입문 #29-1
- 비동기 이벤트 루프 #29-2
다음 글 / 이전 글
다음 글: (시리즈 #52-2에서 이어서)
이전 글: [C++ 실전 가이드 #51-3] 멀티스레딩 튜닝과 최적화
관련 글
- C++ 네트워크 성능 최적화 | TCP 튜닝·제로카피·커널 바이패스 [#51-7]
- C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴
- C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]