Asio 이벤트 루프 동작 원리: run·poll 차이, post vs dispatch, work_guard, strand

들어가며: “서버가 바로 종료돼요. run()이 끝나지 않게 하려면?”

// ❌ 문제: 서버가 바로 종료됨
boost::asio::io_context io;
tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
// async_accept 한 번만 등록
acceptor.async_accept([](boost::system::error_code, tcp::socket) {
    std::cout << "Connected!\n";
    // 여기서 끝
});
io.run();  // 💥 연결 하나 받고 바로 종료!
std::cout << "Server stopped\n";  // 즉시 출력됨

왜 이런 일이 발생할까요? io_context::run()은 등록된 비동기 작업이 모두 완료되면 반환합니다. 위 코드는 async_accept 하나만 등록했으므로, 연결 하나를 받으면 더 이상 할 일이 없어서 run()이 종료됩니다.

추가 문제 시나리오

run()이 끝나지 않아요

work_guard를 사용했는데 reset()을 호출하지 않아 서버를 종료할 수 없는 경우입니다. 그런데 reset()만으로는 부족한 경우가 많습니다. 대기 중인 async_accept나 읽기 작업이 남아 있으면 그것들도 “할 일”이라 run()이 계속 돌기 때문입니다. 뒤의 graceful shutdown 절에서 signal_set과 acceptor.close()로 이 문제를 푸는 방법을 다룹니다.

멀티스레드에서 데이터 레이스

여러 스레드가 같은 io_context::run()을 호출할 때 공유 변수에 mutex 없이 접근하면 undefined behavior가 발생합니다. strand로 순차 실행을 보장해야 합니다.

핸들러 실행 순서 혼란

post와 dispatch의 차이를 모르고 사용하면 핸들러가 예상과 다른 순서로 실행될 수 있습니다. dispatch는 현재 핸들러 내부에서 즉시 실행되므로 재귀 깊이에 주의해야 합니다.

run() 호출 후 io_context 재사용

io.run()이 반환된 io_context는 “stopped” 상태입니다. io.restart()를 호출하지 않고 다시 run()을 호출하면 아무 작업도 실행되지 않습니다.

해결책:

  1. work_guard: “아직 할 일이 있다”고 표시
  2. 완료 핸들러에서 재등록: async_accept 완료 시 다시 async_accept 등록
  3. 멀티스레드 run(): 여러 스레드가 동시에 이벤트 처리 목표:
  • run() / run_one() / poll() 동작 이해
  • post / dispatch로 작업 큐 관리
  • work_guard로 서버 유지
  • 멀티스레드 이벤트 루프 구현
  • 완료 핸들러 체이닝 패턴 요구 환경: Boost.Asio 1.70 이상 (코루틴의 as_tuple은 더 최신 버전 필요). 독립형 Asio도 네임스페이스만 asio::로 바꾸면 같습니다.

이벤트 루프 동작 원리

이벤트 루프란?

flowchart TB
    Start["io_context run 시작"]
    Check{등록된\n작업 있음?}
    Wait[I/O 이벤트 대기]
    Execute[완료 핸들러 실행]
    Done[run 종료]
    
    Start --> Check
    Check -->|Yes| Wait
    Wait --> Execute
    Execute --> Check
    Check -->|No| Done
    
    style Wait fill:#ffeb3b
    style Execute fill:#4caf50
    style Done fill:#f44336

이벤트 루프의 핵심:

  1. 등록된 비동기 작업을 확인
  2. I/O 이벤트 발생 대기 (epoll/kqueue/IOCP)
  3. 이벤트 발생 시 완료 핸들러 실행
  4. 1번으로 돌아가서 반복
  5. 더 이상 작업이 없으면 종료

시퀀스 다이어그램: run() 동작

sequenceDiagram
    participant Main as 메인 스레드
    participant IO as io_context
    participant Kernel as OS (epoll/kqueue)
    
    Main->>IO: post(핸들러1), post(핸들러2)
    Main->>IO: run() 호출
    IO->>IO: 작업 큐 확인 (2개)
    loop 이벤트 루프
        IO->>Kernel: 이벤트 대기 (또는 즉시 실행)
        Kernel-->>IO: 준비된 작업
        IO->>IO: 핸들러1 실행
        IO->>IO: 핸들러2 실행
        IO->>IO: 작업 없음?
    end
    IO-->>Main: run() 반환

내부 동작 이해

#include <boost/asio.hpp>
#include <iostream>
#include <thread>
using boost::asio::ip::tcp;
using boost::system::error_code;
void demonstrate_event_loop() {
    boost::asio::io_context io;
    
    std::cout << "1. run() 호출 전\n";
    
    // 비동기 작업 등록
    boost::asio::post(io, [] {
        std::cout << "2. 첫 번째 핸들러 실행\n";
    });
    
    boost::asio::post(io, [] {
        std::cout << "3. 두 번째 핸들러 실행\n";
    });
    
    std::cout << "4. run() 호출\n";
    io.run();  // 여기서 2, 3번 핸들러 실행
    
    std::cout << "5. run() 종료 (더 이상 작업 없음)\n";
}
// 출력:
// 1. run() 호출 전
// 4. run() 호출
// 2. 첫 번째 핸들러 실행
// 3. 두 번째 핸들러 실행
// 5. run() 종료 (더 이상 작업 없음)

여기서 “할 일”의 정의를 정확히 알아 두면 이후의 거의 모든 문제가 설명됩니다. io_context는 내부에 미완료 작업 카운트(outstanding work)를 들고 있습니다. post한 핸들러, 완료를 기다리는 async_read/async_accept, 만료를 기다리는 타이머가 각각 이 카운트를 하나씩 올리고, 핸들러가 실행되면 내려갑니다. run()은 이 카운트가 0이 되는 순간 반환하고 io_context를 stopped 상태로 만듭니다. 소켓을 열어 두기만 하고 비동기 작업을 걸지 않았다면 그것은 “할 일”이 아니므로, 연결이 살아 있어도 run()은 끝납니다. 반대로 서버가 “종료되지 않는” 버그는 대개 어딘가에 아직 완료되지 않은 타이머나 읽기 작업이 남아 있는 경우입니다.


run/run_one/poll 비교

세 가지 실행 방식

#include <boost/asio.hpp>
#include <iostream>
void compare_run_methods() {
    boost::asio::io_context io;
    
    // 작업 3개 등록
    for (int i = 1; i <= 3; ++i) {
        boost::asio::post(io, [i]() {
            std::cout << "Task " << i << "\n";
        });
    }
    
    // 1. run(): 모든 작업 실행
    {
        boost::asio::io_context io1;
        for (int i = 1; i <= 3; ++i) {
            boost::asio::post(io1, [i]() {
                std::cout << "run: Task " << i << "\n";
            });
        }
        io1.run();  // Task 1, 2, 3 모두 실행
        std::cout << "run() completed\n";
    }
    
    // 2. run_one(): 한 번에 하나씩
    {
        boost::asio::io_context io2;
        for (int i = 1; i <= 3; ++i) {
            boost::asio::post(io2, [i]() {
                std::cout << "run_one: Task " << i << "\n";
            });
        }
        
        io2.run_one();  // Task 1만 실행
        std::cout << "First run_one() completed\n";
        
        io2.run_one();  // Task 2만 실행
        std::cout << "Second run_one() completed\n";
        
        io2.run();  // Task 3 실행
    }
    
    // 3. poll(): 대기 없이 준비된 작업만
    {
        boost::asio::io_context io3;
        
        // 즉시 실행 가능한 작업
        boost::asio::post(io3, [] {
            std::cout << "poll: Immediate task\n";
        });
        
        // I/O 대기가 필요한 작업 (타이머)
        boost::asio::steady_timer timer(io3, std::chrono::seconds(1));
        timer.async_wait([](const boost::system::error_code&) {
            std::cout << "poll: Timer expired\n";
        });
        
        io3.poll();  // Immediate task만 실행 (타이머는 실행 안 됨)
        std::cout << "poll() completed (no blocking)\n";
        
        // 타이머 만료까지 대기하려면 run() 필요
        io3.run();  // Timer expired 실행
    }
}

비교표

메서드동작사용 사례
run()모든 작업 완료까지 블로킹서버 메인 루프
run_one()한 작업만 실행 후 반환작업 단위 제어
poll()대기 없이 준비된 작업만게임 루프, UI 업데이트
run_for(duration)시간 제한 실행타임아웃 필요 시
run_until(time_point)특정 시각까지 실행스케줄링

poll()을 게임 루프에 쓸 때 주의할 점은 “준비된 작업”의 범위입니다. poll()은 이미 완료된 I/O의 핸들러와 큐에 들어 있는 핸들러를 실행하지만, 그 핸들러가 새로 post한 핸들러까지 같은 호출에서 실행할 수 있어서, 핸들러가 계속 자신을 다시 post하면 poll()이 한 프레임 안에서 빠져나오지 못합니다. 프레임 시간을 엄격히 지켜야 한다면 poll_one()을 정해진 횟수만큼 돌리거나 run_for()로 시간 예산을 주는 편이 안전합니다. 또 poll()도 작업이 모두 끝나면 io_context를 stopped 상태로 만들기 때문에, 매 프레임 호출하는 구조라면 work guard를 두거나 restart()를 불러야 다음 프레임에 새 작업이 실행됩니다.


work_guard로 서버 유지

문제: 서버가 바로 종료됨

// ❌ 잘못된 서버 코드
void broken_server() {
    boost::asio::io_context io;
    tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
    
    acceptor.async_accept([](boost::system::error_code, tcp::socket) {
        std::cout << "Client connected\n";
        // 한 번만 실행되고 끝
    });
    
    io.run();  // 연결 하나 받고 종료!
    std::cout << "Server stopped\n";
}

해결책 1: work_guard 사용

#include <boost/asio.hpp>
#include <iostream>
void server_with_work_guard() {
    boost::asio::io_context io;
    
    // work_guard 생성: "아직 할 일이 있다"고 표시
    auto work = boost::asio::make_work_guard(io);
    
    tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
    
    acceptor.async_accept([](boost::system::error_code, tcp::socket) {
        std::cout << "Client connected\n";
    });
    
    std::cout << "Server started on port 8080\n";
    
    // work_guard가 있으므로 run()이 종료되지 않음
    std::thread server_thread([&io]() {
        io.run();
    });
    
    // 5초 후 종료
    std::this_thread::sleep_for(std::chrono::seconds(5));
    
    // work_guard 해제 → run() 종료
    work.reset();
    
    server_thread.join();
    std::cout << "Server stopped\n";
}

이 예제에는 처음 work guard를 쓸 때 거의 모두가 한 번씩 당하는 함정이 들어 있습니다. work.reset()은 “work guard가 올린 카운트 하나”만 내릴 뿐이라, async_accept가 아직 연결을 기다리고 있으면 그 작업이 카운트를 붙잡고 있어 run()은 여전히 반환하지 않습니다. 5초 뒤 join()에서 프로그램이 멈춘 것처럼 보이는 이유가 이것입니다. 즉시 종료하려면 acceptor.close()로 대기 중인 accept를 operation_aborted로 완료시키거나, 모든 작업을 버리고 멈추는 io.stop()을 호출해야 합니다. work guard는 “아직 작업을 등록하기 전인데 스레드 풀의 run()이 먼저 끝나 버리는” 상황을 막는 도구이지, 서버를 켜고 끄는 스위치가 아닙니다.

해결책 2: 완료 핸들러에서 재등록 (권장)

class Server {
    boost::asio::io_context& io_;
    tcp::acceptor acceptor_;
    
public:
    Server(boost::asio::io_context& io, uint16_t port)
        : io_(io), acceptor_(io, tcp::endpoint(tcp::v4(), port)) {
        start_accept();
    }
    
private:
    void start_accept() {
        // ✅ 핵심: 완료 핸들러에서 다시 start_accept() 호출
        acceptor_.async_accept(
            [this](error_code ec, tcp::socket socket) {
                if (!ec) {
                    std::cout << "Client connected from "
                              << socket.remote_endpoint() << "\n";
                    
                    // 클라이언트 처리 (세션 시작)
                    handle_client(std::move(socket));
                }
                
                // 다음 연결 대기 (재귀적 등록)
                start_accept();
            }
        );
    }
    
    void handle_client(tcp::socket socket) {
        // 클라이언트와 통신: 소켓과 버퍼를 shared_ptr로 두고 모든 핸들러가 공유
        auto sock = std::make_shared<tcp::socket>(std::move(socket));
        auto buffer = std::make_shared<std::array<char, 1024>>();
        
        sock->async_read_some(
            boost::asio::buffer(*buffer),
            [sock, buffer](error_code ec, size_t bytes) {
                if (!ec) {
                    std::cout << "Received " << bytes << " bytes\n";
                    
                    // Echo back (쓰기가 끝날 때까지 sock·buffer가 살아 있도록 캡처)
                    boost::asio::async_write(
                        *sock,
                        boost::asio::buffer(*buffer, bytes),
                        [sock, buffer](error_code, size_t) {}
                    );
                }
            }
        );
    }
};
void run_server() {
    boost::asio::io_context io;
    Server server(io, 8080);
    
    std::cout << "Server started on port 8080\n";
    io.run();  // 계속 실행됨 (async_accept가 계속 등록되므로)
}

handle_client에서 흔히 보는 잘못된 형태는 socket.async_read_some(buf, [socket = std::move(socket)](...) {...})처럼 같은 소켓에 대해 멤버 함수를 호출하면서 동시에 핸들러로 옮기는 코드입니다. C++17 규칙상 socket.async_read_some이라는 호출 대상은 먼저 정해지지만, 인자인 람다가 만들어지면서 소켓이 이동되고 그 다음에 함수 본문이 실행되므로, 비동기 읽기는 이미 비어 버린(moved-from) 소켓에 걸립니다. 결과는 핸들러가 곧바로 Bad file descriptor 에러로 호출되는 것입니다. 또 쓰기 핸들러가 소켓과 버퍼를 캡처하지 않으면 바깥 람다가 끝나는 순간 둘 다 소멸해 쓰기가 취소되거나 해제된 메모리를 보내게 됩니다. 위 코드는 shared_ptr로 두 객체를 모든 핸들러가 공유하게 해서 이 두 문제를 함께 피합니다. 실무에서는 뒤에서 볼 세션 클래스 + shared_from_this 패턴이 같은 목적을 더 깔끔하게 달성합니다.


post와 dispatch

post: 항상 큐에 넣기

void demonstrate_post() {
    boost::asio::io_context io;
    
    std::cout << "Main thread: " << std::this_thread::get_id() << "\n";
    
    // post: 항상 나중에 실행
    boost::asio::post(io, [] {
        std::cout << "Handler thread: " << std::this_thread::get_id() << "\n";
        std::cout << "This runs later\n";
    });
    
    std::cout << "Before run()\n";
    io.run();
    std::cout << "After run()\n";
}
// 출력:
// Main thread: 123456
// Before run()
// Handler thread: 123456
// This runs later
// After run()

dispatch: 가능하면 즉시 실행

void demonstrate_dispatch() {
    boost::asio::io_context io;
    
    // dispatch: run() 실행 중이면 즉시 실행 가능
    boost::asio::dispatch(io, [&io] {
        std::cout << "Dispatch 1\n";
        
        // 핸들러 내부에서 dispatch → 즉시 실행
        boost::asio::dispatch(io, [] {
            std::cout << "Dispatch 2 (immediate)\n";
        });
        
        // 핸들러 내부에서 post → 큐에 넣음
        boost::asio::post(io, [] {
            std::cout << "Post (queued)\n";
        });
        
        std::cout << "Dispatch 1 end\n";
    });
    
    io.run();
}
// 출력:
// Dispatch 1
// Dispatch 2 (immediate)
// Dispatch 1 end
// Post (queued)

언제 무엇을 사용할까?

상황사용이유
다른 스레드에서 작업 등록post스레드 안전
핸들러 내부에서 작업 등록dispatch오버헤드 감소
순서 보장 필요post큐 순서 보장
즉시 실행 가능dispatch성능 최적화

표의 “스레드 안전”은 조금 풀어서 볼 필요가 있습니다. post와 dispatch 모두 다른 스레드에서 호출해도 안전합니다. 차이는 핸들러가 어느 시점에 실행되느냐입니다. dispatch는 호출한 스레드가 지금 그 실행 컨텍스트 안에서 돌고 있으면(io_context::run() 안이거나 해당 strand 안) 핸들러를 호출 스택 위에서 바로 실행합니다. 그래서 락을 잡은 상태에서 dispatch를 부르면, 핸들러가 같은 락을 다시 잡으려다 교착 상태가 될 수 있습니다. 호출자의 상태가 핸들러 실행 전후로 바뀌면 안 되는 코드라면 post가 맞습니다. dispatch가 이득인 대표적인 경우는 라이브러리 내부에서 “이미 strand 안이면 바로 이어서 처리”하는 composed operation입니다.


멀티스레드 이벤트 루프

단일 스레드 vs 멀티스레드

// 단일 스레드: 한 번에 하나씩 처리
void single_threaded_server() {
    boost::asio::io_context io;
    // ... acceptor 설정 ...
    
    io.run();  // 메인 스레드에서만 실행
}
// 멀티스레드: 여러 핸들러 동시 처리
void multi_threaded_server() {
    boost::asio::io_context io;
    // ... acceptor 설정 ...
    
    // 4개 스레드가 같은 io_context 처리
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back([&io]() {
            io.run();
        });
    }
    
    for (auto& t : threads) {
        t.join();
    }
}

스레드 풀 구현

class ThreadPool {
    boost::asio::io_context io_;
    boost::asio::executor_work_guard<boost::asio::io_context::executor_type> work_;
    std::vector<std::thread> threads_;
    
public:
    ThreadPool(size_t num_threads)
        : work_(boost::asio::make_work_guard(io_)) {
        
        for (size_t i = 0; i < num_threads; ++i) {
            threads_.emplace_back([this]() {
                io_.run();
            });
        }
    }
    
    ~ThreadPool() {
        work_.reset();  // work_guard 해제
        
        for (auto& t : threads_) {
            t.join();
        }
    }
    
    // 작업 추가
    template<typename F>
    void post(F&& f) {
        boost::asio::post(io_, std::forward<F>(f));
    }
    
    boost::asio::io_context& get_io_context() {
        return io_;
    }
};
// 사용 예시
void use_thread_pool() {
    ThreadPool pool(4);  // 4개 스레드
    
    // 작업 추가
    for (int i = 0; i < 10; ++i) {
        pool.post([i]() {
            std::cout << "Task " << i 
                      << " on thread " << std::this_thread::get_id() << "\n";
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        });
    }
    
    std::this_thread::sleep_for(std::chrono::seconds(2));
}

동기화 주의사항

class Counter {
    int count_ = 0;
    std::mutex mutex_;  // ❌ 멀티스레드에서 필요
    
public:
    void increment() {
        std::lock_guard<std::mutex> lock(mutex_);
        ++count_;
    }
    
    int get() {
        std::lock_guard<std::mutex> lock(mutex_);
        return count_;
    }
};
// ✅ strand 사용 (Asio의 동기화 메커니즘)
class StrandCounter {
    boost::asio::io_context::strand strand_;
    int count_ = 0;  // strand로 보호되므로 mutex 불필요
    
public:
    StrandCounter(boost::asio::io_context& io)
        : strand_(io) {}
    
    void increment() {
        boost::asio::post(strand_, [this]() {
            ++count_;  // strand 내에서 실행 → 순차 보장
        });
    }
    
    void get(std::function<void(int)> callback) {
        boost::asio::post(strand_, [this, callback]() {
            callback(count_);
        });
    }
};

strand로 세션 상태 보호하기

strand는 같은 io_context에서 순차 실행을 보장하는 실행 컨텍스트입니다. mutex 없이 공유 자원을 안전하게 접근할 수 있습니다.

strand로 보호된 Echo 세션

#include <boost/asio.hpp>
#include <memory>
using boost::asio::ip::tcp;
using boost::system::error_code;
class StrandEchoSession : public std::enable_shared_from_this<StrandEchoSession> {
    tcp::socket socket_;
    boost::asio::strand<tcp::socket::executor_type> strand_;  // socket_ 뒤에 선언 (초기화 순서)
    std::array<char, 1024> buffer_;
    
public:
    StrandEchoSession(tcp::socket socket)
        : socket_(std::move(socket)),
          strand_(boost::asio::make_strand(socket_.get_executor())) {}
    
    void start() {
        boost::asio::dispatch(strand_, [self = shared_from_this()]() {
            self->do_read();
        });
    }
    
private:
    void do_read() {
        auto self = shared_from_this();
        socket_.async_read_some(
            boost::asio::buffer(buffer_),
            boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t bytes) {
                if (!ec) do_write(bytes);
            })
        );
    }
    
    void do_write(size_t bytes) {
        auto self = shared_from_this();
        boost::asio::async_write(
            socket_, boost::asio::buffer(buffer_, bytes),
            boost::asio::bind_executor(strand_, [this, self](error_code ec, size_t) {
                if (!ec) do_read();
            })
        );
    }
};

io_context::strand는 io_context&를 받는 예전 클래스이고 Boost 1.70 이후 사용 중단(deprecated) 대상이라, 소켓의 executor로부터 만들 때는 boost::asio::make_strand로 strand<Executor>를 만드는 것이 맞습니다. 더 간단한 방법도 있습니다. acceptor에서 acceptor.async_accept(boost::asio::make_strand(io), handler)처럼 strand executor를 넘기면, 받아진 소켓 자체가 strand에 묶여서 그 소켓의 모든 완료 핸들러가 자동으로 strand에서 실행됩니다. 그러면 위처럼 핸들러마다 bind_executor를 붙일 필요가 없고, 하나를 빠뜨려 그 핸들러만 strand 밖에서 도는 실수도 사라집니다.

strand는 “같은 strand에 속한 핸들러끼리는 절대 동시에 실행되지 않는다”는 것만 보장합니다. 세션 A와 세션 B가 각자의 strand를 갖고 둘 다 전역 통계 맵을 건드린다면 여전히 데이터 경쟁이므로, 공유 자원에는 그 자원 전용 strand를 두거나 mutex가 필요합니다.

strand vs mutex

방식장점단점
strand데드락 없음, Asio 네이티브strand 범위 설계 필요
mutex기존 코드와 호환데드락 위험, 성능 오버헤드

C++20 코루틴

Boost.Asio는 C++20 코루틴을 지원합니다. co_await로 콜백 지옥을 피하고 동기 코드처럼 작성할 수 있습니다.

Echo 서버 (코루틴)

#if __cplusplus >= 202002L
namespace asio = boost::asio;
using asio::ip::tcp;
asio::awaitable<void> echo_session(tcp::socket socket) {
    try {
        char data[1024];
        for (;;) {
            std::size_t n = co_await socket.async_read_some(
                asio::buffer(data), asio::use_awaitable);
            co_await asio::async_write(
                socket, asio::buffer(data, n), asio::use_awaitable);
        }
    } catch (const std::exception& e) {
        std::printf("Echo exception: %s\n", e.what());
    }
}
asio::awaitable<void> listen(tcp::acceptor& acceptor) {
    for (;;) {
        tcp::socket socket = co_await acceptor.async_accept(asio::use_awaitable);
        asio::co_spawn(
            acceptor.get_executor(),
            echo_session(std::move(socket)),
            asio::detached);
    }
}
void run_coroutine_server() {
    asio::io_context io;
    tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));
    asio::co_spawn(io, listen(acceptor), asio::detached);
    io.run();
}
#endif  // C++20

에러 처리: as_tuple

auto [ec, n] = co_await socket.async_read_some(
    asio::buffer(data), asio::as_tuple(asio::use_awaitable));
if (ec) {
    std::cerr << "Read error: " << ec.message() << "\n";
    co_return;
}

코루틴 버전의 echo_session은 콜백 버전과 달리 shared_from_this가 없는데도 안전합니다. 소켓과 data 배열이 코루틴 프레임 안에 있고, 프레임은 코루틴이 끝날 때까지 힙에 유지되기 때문입니다. 대신 listen(tcp::acceptor& acceptor)처럼 참조로 받는 인자는 여전히 수명 문제가 있습니다. 코루틴이 중단된 동안 원래 객체가 소멸하면 프레임 안의 참조가 댕글링이 되므로, 코루틴의 인자는 가능하면 값으로 받는 것이 원칙입니다. 또 use_awaitable만 쓰면 연결이 끊길 때마다 boost::system::system_error 예외(End of file 등)가 던져지는데, 정상 종료를 예외로 처리하는 것이 부담스럽다면 아래의 as_tuple로 에러 코드를 값으로 받는 편이 깔끔합니다.


완료 핸들러 체이닝

패턴: 완료 시 다음 작업 등록

class EchoSession : public std::enable_shared_from_this<EchoSession> {
    tcp::socket socket_;
    std::array<char, 1024> buffer_;
    
public:
    EchoSession(tcp::socket socket)
        : socket_(std::move(socket)) {}
    
    void start() {
        do_read();
    }
    
private:
    void do_read() {
        auto self = shared_from_this();
        
        socket_.async_read_some(
            boost::asio::buffer(buffer_),
            [this, self](error_code ec, size_t bytes) {
                if (!ec) {
                    do_write(bytes);  // ✅ 읽기 완료 → 쓰기 시작
                }
            }
        );
    }
    
    void do_write(size_t bytes) {
        auto self = shared_from_this();
        
        boost::asio::async_write(
            socket_,
            boost::asio::buffer(buffer_, bytes),
            [this, self](error_code ec, size_t) {
                if (!ec) {
                    do_read();  // ✅ 쓰기 완료 → 다시 읽기
                }
            }
        );
    }
};

타이머 체이닝

class PeriodicTimer {
    boost::asio::steady_timer timer_;
    std::function<void()> callback_;
    std::chrono::milliseconds interval_;
    
public:
    PeriodicTimer(
        boost::asio::io_context& io,
        std::chrono::milliseconds interval,
        std::function<void()> callback
    ) : timer_(io), interval_(interval), callback_(callback) {}
    
    void start() {
        schedule_next();
    }
    
private:
    void schedule_next() {
        timer_.expires_after(interval_);
        
        timer_.async_wait([this](error_code ec) {
            if (!ec) {
                callback_();
                schedule_next();  // ✅ 타이머 완료 → 다시 등록
            }
        });
    }
};
// 사용
void use_periodic_timer() {
    boost::asio::io_context io;
    
    PeriodicTimer timer(io, std::chrono::seconds(1), [] {
        std::cout << "Tick: " << std::time(nullptr) << "\n";
    });
    
    timer.start();
    io.run();
}

이 타이머에는 두 가지 미묘한 점이 있습니다. 첫째, expires_after(interval_)는 “지금부터 1초 뒤”이므로 콜백 실행 시간과 스케줄링 지연만큼 주기가 조금씩 밀립니다(drift). 정확한 주기가 필요하면 timer_.expires_at(timer_.expiry() + interval_)처럼 이전 만료 시각을 기준으로 다음 시각을 정해야 합니다. 둘째, 핸들러가 this를 캡처하므로 PeriodicTimer 객체가 먼저 소멸하면 댕글링입니다. 소멸자에서 timer_.cancel()을 불러도 핸들러는 operation_aborted로 나중에 실행되기 때문에, 그때 this는 이미 사라져 있습니다. 수명이 io_context보다 짧을 수 있는 객체라면 세션처럼 shared_from_this를 캡처해야 합니다.


간단한 HTTP 서버, 타임아웃, 클라이언트 연결 풀

예시 1: HTTP 서버 (간단한 버전)

class SimpleHttpServer {
    boost::asio::io_context& io_;
    tcp::acceptor acceptor_;
    
public:
    SimpleHttpServer(boost::asio::io_context& io, uint16_t port)
        : io_(io), acceptor_(io, tcp::endpoint(tcp::v4(), port)) {
        start_accept();
    }
    
private:
    void start_accept() {
        acceptor_.async_accept([this](error_code ec, tcp::socket socket) {
            if (!ec) {
                handle_request(std::move(socket));
            }
            start_accept();  // 다음 연결 대기
        });
    }
    
    void handle_request(tcp::socket socket) {
        auto sock = std::make_shared<tcp::socket>(std::move(socket));
        auto buffer = std::make_shared<boost::asio::streambuf>();
        
        boost::asio::async_read_until(
            *sock,
            *buffer,
            "\r\n\r\n",
            [sock, buffer](error_code ec, size_t) {
                if (!ec) {
                    // 응답 문자열도 쓰기가 끝날 때까지 살아 있어야 함
                    auto response = std::make_shared<std::string>(
                        "HTTP/1.1 200 OK\r\n"
                        "Content-Length: 13\r\n"
                        "\r\n"
                        "Hello, World!");
                    
                    boost::asio::async_write(
                        *sock,
                        boost::asio::buffer(*response),
                        [sock, response](error_code, size_t) {}
                    );
                }
            }
        );
    }
};

타임아웃 처리 패턴

// 타이머로 읽기 타임아웃 구현 (콜백 방식)
void read_with_timeout(std::shared_ptr<tcp::socket> socket,
    boost::asio::mutable_buffer buffer,
    std::chrono::seconds timeout,
    std::function<void(error_code, size_t)> handler) {
    
    auto timer = std::make_shared<boost::asio::steady_timer>(
        socket->get_executor(), timeout);
    auto buf = std::make_shared<std::vector<char>>(buffer.size());
    
    timer->async_wait([socket, handler](error_code ec) {
        if (!ec) socket->cancel();  // 타임아웃 시 읽기 취소
    });
    
    socket->async_read_some(boost::asio::buffer(*buf),
        [timer, buf, handler](error_code ec, size_t n) mutable {
            timer->cancel();  // 읽기 완료 시 타이머 취소
            handler(ec, n);
        });
}

이 패턴에서 타임아웃이 나면 읽기 핸들러는 boost::asio::error::operation_aborted로 호출되므로, 호출자는 그것을 “타임아웃”으로 해석해야 합니다(원인을 구분하려면 타이머 쪽에서 플래그를 세워 둡니다). 여러 스레드가 run()을 돌린다면 타이머 핸들러와 읽기 핸들러가 동시에 실행되어 socket->cancel()과 읽기 완료가 경쟁할 수 있으므로, 두 핸들러를 같은 strand에 묶어야 합니다. 또 이 예제는 설명을 위해 내부 버퍼 buf에 읽기 때문에 호출자가 넘긴 buffer에는 데이터가 들어가지 않습니다. 실제로는 호출자의 버퍼를 그대로 쓰고 그 수명을 호출자가 보장하게 하십시오. Boost 1.70 이상의 Beast를 쓴다면 beast::tcp_stream::expires_after()가 이 전체 패턴을 대신해 줍니다.

연결 풀 (클라이언트 측)

class ConnectionPool {
    boost::asio::io_context& io_;
    std::queue<std::shared_ptr<tcp::socket>> pool_;
    std::string host_, port_;
    size_t max_size_;
    
public:
    void acquire(std::function<void(error_code, std::shared_ptr<tcp::socket>)> cb) {
        if (!pool_.empty()) {
            auto sock = std::move(pool_.front());
            pool_.pop();
            cb(error_code{}, std::move(sock));
            return;
        }
        // resolver로 새 연결 생성 후 cb 호출
    }
    
    void release(std::shared_ptr<tcp::socket> sock) {
        if (pool_.size() < max_size_ && sock->is_open())
            pool_.push(std::move(sock));
    }
};

bad_weak_ptr, 멈춘 io_context 재사용, dispatch 재귀 같은 에러

에러 1: shared_from_this() 호출 시 bad_weak_ptr

원인: 객체가 아직 shared_ptr로 관리되지 않은 상태에서 shared_from_this()를 호출했기 때문입니다.

// ❌ 잘못된 코드
new Session(socket)->start();  // shared_ptr 아님 → bad_weak_ptr

해결법:

// ✅ 올바른 코드
auto session = std::make_shared<Session>(std::move(socket));
session->start();

에러 2: run() 후 io_context 재사용

원인: run()이 반환된 io_context는 stopped 상태이기 때문입니다.

// ❌ 잘못된 코드
io.run();  // 완료 후
boost::asio::post(io, [] {});
io.run();  // 💥 아무것도 실행 안 됨

해결법:

// ✅ 올바른 코드
io.restart();
io.run();

에러 3: 소켓/버퍼 수명 관리

원인: 비동기 작업 완료 전에 소켓이나 버퍼가 소멸됩니다.

// ❌ 잘못된 코드
std::array<char, 1024> buffer;  // 스택
socket.async_read_some(boost::asio::buffer(buffer), [](error_code, size_t) {});
// 함수 종료 → buffer 소멸

해결법:

// ✅ 올바른 코드: 버퍼와 소켓을 핸들러가 공유 소유
auto buffer = std::make_shared<std::array<char, 1024>>();
auto sock = std::make_shared<tcp::socket>(std::move(socket));
sock->async_read_some(
    boost::asio::buffer(*buffer),
    [buffer, sock](error_code ec, size_t n) {});
// (주의: socket.async_read_some(..., [socket = std::move(socket)]...)처럼 쓰면
//  읽기가 이미 이동된 소켓에 걸려 Bad file descriptor 에러가 남)

에러 4: 멀티스레드에서 공유 변수 접근

원인: 여러 스레드가 io.run() 실행 시, 핸들러가 서로 다른 스레드에서 실행됩니다. 해결법: strand나 std::mutex를 사용합니다.

// ✅ strand 사용
boost::asio::io_context::strand strand(io);
boost::asio::post(strand, [&]() { ++counter; });

에러 5: dispatch 재귀 깊이

원인: 핸들러 내부에서 dispatch로 자기 자신을 호출해 스택 오버플로우가 발생합니다. 해결법: 재귀가 깊어지면 post를 사용합니다(큐에 넣어 스택이 풀린 뒤 실행됩니다).

비슷한 증상은 비동기 읽기가 즉시 완료되는 경우에도 생깁니다. 소켓 버퍼에 데이터가 이미 쌓여 있으면 읽기가 곧바로 끝나는데, Asio는 이런 경우에도 완료 핸들러를 시작 함수 안에서 바로 부르지 않고 큐를 거쳐 실행하도록 보장합니다. 그래서 do_read → do_write → do_read 체인 자체는 스택을 쌓지 않습니다. 직접 만든 비동기 함수에서 완료 콜백을 그 자리에서 호출하면 이 보장이 깨지므로, 직접 구현할 때도 “완료 핸들러는 시작 함수가 반환한 뒤에 실행된다”는 규칙을 지키기 위해 post를 사용해야 합니다.


세션 수명·strand·post/dispatch 원칙

  1. shared_from_this: 세션 클래스는 enable_shared_from_this 상속, make_shared로 생성
  2. strand: 멀티스레드 run() 사용 시 공유 상태는 전용 strand로 보호
  3. 에러 코드: 모든 비동기 핸들러에서 error_code 확인
  4. post vs dispatch: 다른 스레드 → post, 핸들러 내부 → dispatch (재귀 주의)
  5. work_guard: 작업 등록 전 스레드 풀이 끝나지 않게 유지하는 용도. 종료는 signal_set + acceptor.close()로
  6. accept 루프: async_accept 완료 핸들러에서 다음 accept를 다시 등록
  7. 소켓/버퍼 수명: shared_ptr 또는 람다 캡처로 비동기 작업이 끝날 때까지 유지

SIGINT로 accept 루프를 멈추는 graceful shutdown

std::signal로 전역 플래그를 세우고 accept 루프 안에서 확인하는 방식은 흔히 보이지만 문제가 있습니다. 다음 연결이 들어와 async_accept 핸들러가 실행되기 전까지는 플래그를 아무도 확인하지 않으므로, 트래픽이 없는 서버는 Ctrl+C를 눌러도 끝나지 않습니다. 또 시그널 핸들러 안에서 할 수 있는 일은 극히 제한적이라(async-signal-safe 함수만 호출 가능) Asio 객체를 직접 건드릴 수도 없습니다. Asio에는 이 문제를 위한 signal_set이 있어서, 시그널을 일반 완료 핸들러로 이벤트 루프 안에서 받을 수 있습니다.

boost::asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&](const error_code&, int /*signo*/) {
    acceptor.close();   // 대기 중인 async_accept를 operation_aborted로 완료시킴
    // 각 세션에 종료를 알리거나(소켓 shutdown), 정 급하면 io.stop()
});
io.run();               // 모든 작업이 정리되면 자연스럽게 반환

acceptor.close() 후 accept 핸들러는 operation_aborted를 받으므로, 그때는 start_accept()를 다시 부르지 않도록 에러 코드를 확인해야 합니다. 진행 중인 세션까지 정리되면 미완료 작업이 0이 되어 run()이 스스로 반환하는데, 이것이 요청을 중간에 끊지 않는 graceful shutdown입니다.


스레드 수는 어떻게 정할까

같은 io_context에 스레드를 늘렸을 때 처리량이 얼마나 오르는지는 핸들러가 하는 일에 따라 완전히 달라집니다. 핸들러가 짧고 대부분의 시간이 커널의 I/O 대기라면 스레드 하나로도 많은 연결을 처리할 수 있고, 스레드를 늘려도 이득이 작습니다. 핸들러 안에서 JSON 파싱, 압축, 암호화처럼 CPU를 쓰는 작업이 많을수록 코어 수까지는 거의 비례해서 좋아집니다. 코어 수를 넘기면 문맥 전환만 늘어나고, 여러 스레드가 하나의 io_context 내부 큐를 두고 경쟁하는 비용이 드러나기 시작합니다. 그래서 고부하 서버에서는 “io_context 하나 + 스레드 N개” 대신 “스레드마다 io_context 하나”(io_context-per-thread) 구조를 쓰기도 합니다. 락 경쟁이 없고 세션이 한 스레드에 고정되어 strand도 필요 없어지는 대신, 연결을 스레드 간에 고르게 분배하는 로직이 필요합니다. 이 구조는 #29-3 멀티스레드 서버에서 다룹니다.

결론: 코어 수(std::thread::hardware_concurrency())에서 시작하고, 실제 부하로 측정하며 조정합니다. 핸들러 안에서 블로킹 호출(동기 DB 쿼리, sleep)을 한다면 스레드 수를 늘리기 전에 그 작업을 별도 스레드 풀로 옮기는 것이 먼저입니다.


자주 묻는 질문 (FAQ)

Q. post와 dispatch는 언제 구분해서 쓰나요?

A. post는 핸들러를 항상 큐에 넣고 바로 반환하므로, 호출자가 락을 잡고 있거나 호출 순서가 중요한 상황에서도 재진입 걱정이 없습니다. dispatch는 현재 스레드가 이미 그 io_context나 strand에서 실행 중이면 핸들러를 즉시 호출해 큐를 거치는 지연을 줄입니다. 대신 즉시 실행되면서 호출 스택이 깊어지거나 예상치 못한 재진입이 생길 수 있으므로, 기본은 post로 두고 지연이 중요한 경로에서만 dispatch를 검토합니다.

Q. run()과 poll()의 차이는?

A. run()은 작업이 완료될 때까지 블로킹하지만, poll()은 즉시 준비된 작업만 처리하고 반환합니다. 게임 루프처럼 매 프레임마다 이벤트를 처리해야 하는 경우 poll()을 사용합니다.

Q. 스레드를 몇 개 만들어야 하나요?

A. 일반적으로 CPU 코어 수만큼 만드는 것이 최적입니다. std::thread::hardware_concurrency()로 확인할 수 있습니다. I/O 대기가 많으면 코어 수보다 약간 더 많이 만들 수 있습니다.

Q. 코루틴 vs 콜백, 어떤 것을 써야 하나요?

A. C++20을 사용할 수 있다면 코루틴이 가독성과 유지보수에 유리합니다. 레거시 환경이거나 팀이 코루틴에 익숙하지 않다면 콜백 + shared_from_this 패턴이 안정적입니다. run·post·work_guard·strand·코루틴으로 고성능 비동기 이벤트 루프를 구현할 수 있습니다. 다음 글: [C++ 실전 가이드 #29-3] 멀티스레드 네트워크 서버: io_context 풀과 strand 이전 글: [C++ 실전 가이드 #29-1] Asio 입문: 비동기 I/O의 시작


같이 보면 좋은 글