C++ Boost.Asio 고급 패턴 | 커스텀 서비스·타이머·시그널 [#52-1]

들어가며: “타이머가 수천 개면 io_context가 느려져요”

연결마다 타임아웃 타이머를 하나씩 두는 서버를 생각해 봅시다. 연결이 10,000개면 steady_timer도 10,000개입니다.

// ❌ 문제: 연결마다 steady_timer → 10,000개 타이머 객체
class Session : public std::enable_shared_from_this<Session> {
    tcp::socket socket_;
    asio::steady_timer timer_;  // 연결당 1개
    // ...
};

Asio의 타이머 큐는 힙 구조라 타이머 등록·취소가 O(log n)이고, 요청마다 타이머를 다시 거는 서버에서는 이 비용이 연결 수 × 요청 수만큼 쌓입니다. 대부분의 서버에서는 문제가 되지 않지만, 연결이 수만 개이고 타임아웃을 자주 갱신한다면 여러 타이머를 버킷으로 묶는 타이머 휠이 대안이 됩니다. 또 SIGINT/SIGTERM으로 종료할 때 시그널 처리를 이벤트 루프에 통합하지 않으면, 시그널 핸들러 안에서 안전하지 않은 함수를 부르게 됩니다. 이 글은 커스텀 서비스, 타이머 재사용, signal_set, 완료 핸들러 래핑, strand와 코루틴을 다룹니다. 예제는 C++17(코루틴 절은 C++20)과 Boost.Asio 1.70 이상을 기준으로 합니다.


타이머·시그널·Strand에서 막히는 상황

타이머 폭발

타이머가 많아서 생기는 비용은 두 가지입니다. 등록·취소·만료마다 힙 연산(O(log n))이 일어나고, 같은 순간에 수천 개가 만료되면 그만큼 핸들러가 한꺼번에 실행됩니다. 타이머 휠은 만료 시각이 비슷한 타이머를 같은 버킷에 넣어 등록과 만료 처리를 O(1)로 만들고, 실제 Asio 타이머는 틱용 하나만 남깁니다.

시그널과 run() 충돌

// ❌ 문제: main 스레드에서 signal() 등록 → io_context와 분리
int main() {
    signal(SIGINT, [](int) { /* 뭘 해야 하지? */ });  // 블로킹 시그널 핸들러
    io_context io;
    // run() 중에 SIGINT 오면? 스레드 안전하지 않음
    io.run();
}

signal()로 등록한 핸들러는 시그널 컨텍스트에서 실행되므로 async-signal-safe 함수만 부를 수 있는데, io.stop()은 내부에서 락을 잡으므로 여기에 해당하지 않습니다. 그래서 asio::signal_set으로 시그널을 비동기 이벤트로 받아, io_context 스레드에서 처리.

비동기 연산 관측성 부족

// 모든 async_read/write에 수동 로깅?
asio::async_read(socket, buf, [](boost::system::error_code ec, size_t n) {
    log_duration(...);  // 매번 복붙
    if (!ec) process(n);
});

완료 핸들러를 감싸는 래퍼로 “완료 시 자동 로깅·메트릭”을 주입.

Strand 없이 멀티스레드에서 race condition

// ❌ 문제: 여러 스레드가 io.run() 실행 시, 같은 Session의 read/write 핸들러가 동시 실행
asio::io_context io;
// 4개 스레드에서 io.run()
// Session::do_read()와 do_write()가 서로 다른 스레드에서 동시에 buffer_ 접근 → 데이터 손상

asio::strand로 연결당 핸들러를 직렬화하여 락 없이 스레드 안전 보장.

work_guard 없이 run()이 즉시 반환

// ❌ 문제: 아직 아무 작업도 등록하지 않은 상태에서 워커 스레드가 run()을 호출
asio::io_context io;
std::thread worker([&] { io.run(); });  // 할 일이 없어 즉시 반환, 스레드 종료
acceptor_.async_accept(...);             // 이후 등록한 작업은 처리할 스레드가 없음

async_accept처럼 이미 걸려 있는 비동기 작업은 그 자체로 “할 일”로 세어지므로, 작업을 먼저 등록하고 run()을 부르면 반환하지 않습니다. 문제는 작업보다 run()이 먼저 실행되는 경우입니다. asio::make_work_guard(io)로 작업 가드를 잡아 두면 할 일이 없어도 run()이 대기합니다.


io_context 커스텀 서비스와 타이머 휠

io_context::service 상속

Asio의 io_context는 서비스를 등록해 확장할 수 있습니다. 서비스는 io_context의 수명에 묶여, io_context가 파괴될 때 함께 파괴됩니다.

#include <boost/asio.hpp>
#include <atomic>
#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는 서비스 타입마다 하나씩 두는 고유 식별자입니다. use_service는 서비스가 없으면 Service(io_context&) 생성자로 만들어 등록한 뒤 참조를 돌려주므로 대부분은 이것만 쓰면 됩니다. add_service는 직접 만든 객체를 등록할 때 쓰며, 같은 타입을 두 번 등록하면 예외가 납니다. shutdown()은 io_context가 파괴되기 전에 호출되어 서비스가 들고 있는 핸들러와 리소스를 정리하는 자리입니다.

타이머 휠 서비스 예시

#include <boost/asio.hpp>
#include <algorithm>
#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_);
        timer_.expires_after(std::chrono::milliseconds(0));
        start_tick();
    }
    // "delay_ms 후에 callback 호출" 등록
    // 이 단순한 휠은 한 바퀴(bucket_count_ ms) 미만의 지연만 표현할 수 있음
    void schedule(size_t delay_ms, callback_t cb) {
        delay_ms = std::clamp<size_t>(delay_ms, 1, bucket_count_ - 1);
        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() {
        // expires_after를 반복하면 오차가 누적되므로 이전 만료 시각 기준으로 다음 틱을 잡음
        timer_.expires_at(timer_.expiry() + std::chrono::milliseconds(1));
        timer_.async_wait([this](boost::system::error_code ec) {
            if (ec) return;
            // 콜백이 실행 중에 새 타이머를 등록할 수 있으므로 버킷을 먼저 꺼내 비움
            auto due = std::move(buckets_[current_bucket_]);
            buckets_[current_bucket_].clear();
            current_bucket_ = (current_bucket_ + 1) % bucket_count_;
            for (auto& cb : due) {
                cb(boost::system::error_code{});
            }
            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;

등록은 버킷 벡터에 넣는 O(1)이고, 매 틱에는 한 버킷만 처리하며, 실제 Asio 타이머는 틱용 하나뿐입니다. 대신 대가가 있습니다. 1ms 틱 타이머가 계속 돌기 때문에 이 서비스가 있는 한 run()은 반환하지 않고 CPU가 초당 1000번 깨어나며, 등록한 콜백을 취소하는 기능도 없습니다. 연결 타임아웃처럼 초 단위 정밀도면 충분한 용도라면 틱을 100ms~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, 최대 지연은 1023ms입니다.


steady_timer 선택과 재사용

steady_timer vs deadline_timer

항목steady_timerdeadline_timer
기준 시각monotonic (시스템 부팅 후)시스템 시계 UTC (Boost.Date_Time)
용도타임아웃, keepalive”오후 3시에 실행” 같은 절대 시각
시계 변경영향 없음영향 받음

절대 시각이 필요할 때 std::chrono::system_clock을 쓰는 asio::system_timer를 고르면 Boost.Date_Time에 의존하지 않아도 됩니다.

#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([](boost::system::error_code ec) {
        if (!ec) { /* 30초 타임아웃 */ }
    });
    // ⚠️ 절대 시각 필요 시에만 deadline_timer
    // deadline_timer는 UTC 기준이므로 local_time()이 아니라 universal_time()을 써야 함
    asio::deadline_timer deadline_timer(io);
    deadline_timer.expires_at(boost::posix_time::second_clock::universal_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개, 재사용
// 타이머 핸들러도 같은 strand에 묶어야 소켓 핸들러와 동시에 실행되지 않음
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(asio::bind_executor(strand_, [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();
    }
});

signal_set으로 시그널 처리

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([&work, &acceptor](auto ec, int signo) {
    if (ec) return;
    acceptor.close();  // 새 연결 수락 중단
    work.reset();      // 남은 작업이 끝나면 run()이 스스로 반환
});
// 서버 시작
io.run();

work.reset()만 하면 진행 중인 요청이 끝난 뒤 run()이 반환되는 graceful shutdown이 되고, io.stop()을 부르면 대기 중인 핸들러를 버리고 즉시 반환합니다. 진행 중인 작업이 끝나기를 기다려야 한다면 stop()은 부르지 않거나, 타임아웃 뒤의 강제 종료 용도로만 씁니다.


커스텀 완료 토큰

완료 토큰이란?

Asio 비동기 연산의 마지막 인자는 완료 토큰(Completion Token)입니다. 토큰 타입에 따라:

  • 콜백: void(error_code, size_t) 형태의 핸들러
  • use_awaitable: 코루틴에서 co_await
  • use_future: std::future 반환 완료 토큰 자체를 새로 정의하려면 async_result 특수화나 async_initiate를 이용한 꽤 복잡한 코드가 필요합니다. “완료 시 자동 로깅”이 목적이라면 콜백 핸들러를 감싸는 헬퍼가 훨씬 단순합니다.

로깅 핸들러 래퍼

#include <boost/asio.hpp>
#include <chrono>
namespace asio = boost::asio;
template <typename Handler>
auto with_metrics(const char* op_name, Handler&& h) {
    // 래퍼를 만드는 시점 = 비동기 연산을 시작하는 시점
    return [op_name, start = std::chrono::steady_clock::now(), h = std::forward<Handler>(h)](
               boost::system::error_code ec, std::size_t n) mutable {
        auto duration = std::chrono::steady_clock::now() - start;
        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);
    }));

주의할 점은 람다로 감싸면 안쪽 핸들러에 연결된 실행기(bind_executor로 묶은 strand)와 할당자 정보가 바깥에서 보이지 않는다는 것입니다. strand가 필요하면 asio::bind_executor(strand_, with_metrics(...))처럼 래퍼 바깥에 묶어야 합니다.


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() {
        socket_.async_read_some(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()은 처리할 작업이 없어도 반환하지 않습니다. 단, io.stop()은 작업 가드와 관계없이 run()을 즉시 반환시킵니다.

asio::io_context io;
auto work = asio::make_work_guard(io);
asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&work](auto ec, int signo) {
    if (ec) return;
    work.reset();   // 남은 작업을 마치면 run()이 반환
});
io.run();  // work.reset() 후 남은 작업이 모두 끝날 때까지 대기

work.reset()은 “남은 일을 마치고 끝내라”, io.stop()은 “지금 멈춰라”입니다. 열린 연결이 계속 읽기를 걸어 두는 서버라면 work.reset()만으로는 끝나지 않으므로, acceptor와 소켓을 닫아 대기 중인 작업이 완료되게 한 뒤 가드를 해제합니다.

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과 C++20 코루틴을 지원하는 Boost.Asio(또는 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를 다음 비동기 연산에 std::move(self)로 전달. 완료 시 self.complete(ec, result) 호출 필수.


service_already_exists, 시그널 처리 누락, self.complete() 누락: 문제 해결

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는 없으면 Service(io_context&) 생성자로 만들어 등록해 줌
auto& svc = asio::use_service<metrics_service>(io);
// 직접 만든 객체를 등록해야 한다면 존재 여부를 먼저 확인 (has_service는 자유 함수)
if (!asio::has_service<metrics_service>(io)) {
    asio::add_service<metrics_service>(io, new metrics_service(io));
}

work_guard를 해제하지 않아 run()이 끝나지 않음

시그널 핸들러에서 acceptor만 닫고 작업 가드를 그대로 두면, 남은 작업이 모두 끝나도 run()이 반환하지 않습니다. graceful shutdown에서는 가드를 reset()해야 합니다. 반대로 io.stop()은 작업 가드가 있어도 run()을 즉시 반환시키므로, 진행 중인 요청을 마치려는 의도라면 stop()부터 부르면 안 됩니다.

signals.async_wait([&guard, &acceptor](auto ec, int) {
    acceptor.close();
    guard.reset();  // 남은 작업이 끝나면 run() 반환
});

타이머와 소켓의 수명 불일치

// ❌ 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();
    });
}

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를 씁니다.

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가 소멸할 때 함께 정리합니다.

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() 호출이 일반적. 재등록은 “여러 시그널 처리” 시 필요.

co_spawn에서 예외가 코루틴 밖으로 전파되지 않음

// ❌ co_spawn에 detached 사용 시, 코루틴 내 예외가 무시됨
co_spawn(io, risky_operation(), asio::detached);
// risky_operation()에서 throw → 아무도 처리 안 함

해결법:

// ✅ 코루틴 안에서 try-catch로 처리하거나, detached 대신 예외를 받는 완료 핸들러를 넘김
// co_spawn(io, risky_operation(), [](std::exception_ptr e) { if (e) std::rethrow_exception(e); });
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);

composed 연산에서 self.complete() 누락

// ❌ state_ == 2일 때 self.complete() 호출 안 함 → 영원히 멈춤
case 2:
    // self.complete(ec, n);  // 누락!
    break;

모든 종료 경로에서 self.complete() 또는 self.complete(ec, result) 호출 필수.


타이머 휠·Strand·버퍼 재사용 효과

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() {
    socket_.async_read_some(asio::buffer(read_buf_), ...);
}

Graceful Shutdown·연결 제한·타임아웃 경쟁 처리

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;
    }
    error_code ep_ec;
    auto ep = socket.remote_endpoint(ep_ec);  // 이미 끊긴 소켓이면 예외 대신 에러 코드
    if (!ep_ec) spdlog::info("Connection from {}", ep.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()은 run()을 실행 중인 모든 스레드가 가능한 한 빨리 반환하게 만들고, 이후 restart()를 부르기 전까지는 run()을 다시 호출해도 즉시 반환합니다. 대기 중이던 핸들러는 실행되지 않습니다.

타임아웃과 읽기 경쟁

// 타이머와 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는 “의도적 취소”이므로 에러로 간주하지 않음.

권장·비권장 사용법 요약

항목권장비권장
타이머steady_timer + 세션당 1개 재사용deadline_timer (타임아웃용), 매번 새 타이머
시그널signal_set + io_context 통합signal() 직접 사용
동시성strand로 연결당 직렬화std::mutex로 핸들러 보호
수명shared_from_this()로 핸들러에 전달this 직접 포착
Shutdownacceptor·소켓 닫기 → work_guard.reset()진행 중 작업을 버리는 io.stop()을 먼저 호출
에러operation_aborted 별도 처리모든 ec를 동일하게 처리
코루틴try-catch로 예외 처리detached만 사용

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_(strand_)  // 타이머 핸들러도 같은 strand에서 실행
        , 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(std::string msg) {
        // 버퍼는 async_write가 끝날 때까지 살아 있어야 하므로 멤버에 보관
        write_buf_ = std::move(msg);
        asio::async_write(socket_, asio::buffer(write_buf_),
            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_;
    std::string write_buf_;
    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";
            acceptor_.close();
            work_.reset();
            // 열린 세션은 최대 30초 타임아웃 뒤 정리되며, 그 후 run()이 반환됨.
            // 즉시 끝내야 한다면 여기서 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;
}

Ctrl+C나 SIGTERM을 받으면 acceptor를 닫아 새 연결을 받지 않고, 작업 가드를 해제합니다. 열린 세션이 읽기 타임아웃이나 연결 종료로 모두 끝나면 run()이 반환합니다.

빌드 및 실행

# vcpkg로 Boost 설치
vcpkg install boost-asio
# 컴파일 (g++): Boost.System은 1.69부터 헤더 전용이라 대개 -lboost_system이 필요 없음
g++ -std=c++17 -O2 -o echo_server main.cpp -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

같이 보면 좋은 글


참고 자료


다음 글 / 이전 글

다음 글: C++에서 Redis 쓰기 #52-2 이전 글: [C++ 실전 가이드 #51-3] 멀티스레딩 튜닝과 최적화