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

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++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

목차

  1. 문제 시나리오 정리
  2. 커스텀 서비스
  3. 타이머 최적화
  4. 시그널 처리
  5. 커스텀 완료 토큰
  6. Strand·work_guard·co_spawn·awaitable·composed 연산
  7. 자주 발생하는 문제
  8. 성능 최적화
  9. 프로덕션 패턴
  10. 실전 예제: 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_contextrun()동기화되지 않음

해결 방향: 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_timerdeadline_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_guardio_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 sessionStrand (락 없음)
버퍼 할당매 읽기마다 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 직접 포착
Shutdownwork_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;
}

동작:

  1. Echo 서버가 8080 포트에서 대기
  2. Ctrl+C 또는 SIGTERM 수신 시 signal_set 완료
  3. work_guard 해제 → acceptor 닫기 → io.stop()
  4. 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 Shutdownsignal_set + work_guard 해제 + acceptor 닫기

핵심 원칙:

  1. 타이머는 steady_timer + 세션당 1개 재사용
  2. 시그널은 signal_set으로 io_context에 통합
  3. Strand로 락 없이 동시성 제어
  4. 커스텀 서비스로 타이머 휠 등 확장

구현 체크리스트

  • steady_timer vs deadline_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, 타이머 휠, 완료 토큰 등으로 검색하시면 이 글이 도움이 됩니다.


참고 자료


다음 글 / 이전 글

다음 글: (시리즈 #52-2에서 이어서)

이전 글: [C++ 실전 가이드 #51-3] 멀티스레딩 튜닝과 최적화


관련 글

  • C++ 네트워크 성능 최적화 | TCP 튜닝·제로카피·커널 바이패스 [#51-7]
  • C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴
  • C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3