본문으로 건너뛰기
Previous
Next
C++ Boost.Asio 고급 패턴 | 커스텀 서비스·타이머·시그널 [#52-1]

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. 문제 시나리오 정리

시나리오 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()에서 메트릭 출력.

아키텍처 다이어그램

다음은 mermaid 예제 코드입니다.

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++ Boost.Asio 고급 패턴 | 커스텀 서비스·타이머·시그널 [#52-1]」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ Boost.Asio 고급 패턴 | 커스텀 서비스·타이머·시그널 [#52-1]」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.