C++ Boost.Asio io_context 이벤트 루프 | 동작 원리 정리 [#1]

C++ Boost.Asio io_context 이벤트 루프 | 동작 원리 정리 [#1]

이 글의 핵심

Boost.Asio io_context 이벤트 루프: run/poll, Proactor 심화(epoll·kqueue·IOCP), 실전 패턴(단일·멀티스레드, work_guard, graceful shutdown), 성능·디버깅, Echo 서버 예제.

들어가며: 이벤트 루프가 Asio의 심장이다

왜 io_context를 깊이 알아야 할까?

Boost.Asio(및 standalone Asio)로 고성능 네트워크 서버를 만들 때, io_context는 모든 비동기 연산이 몰려드는 단일 실행 허브입니다. run()poll()의 차이를 모르면 “왜 서버가 금방 끝나버리지?” 같은 현상을 겪고, Proactor 패턴(비동기 연산을 시작해 두고, 완료되면 핸들러가 호출되는 비동기 I/O 모델)을 이해하지 못하면 “완료 핸들러가 언제, 어느 스레드에서 호출되는지”를 예측할 수 없습니다. work_guard를 올바르게 쓰지 않으면 이벤트 루프가 작업이 없다고 판단해 곧바로 종료해 버립니다.

이 글에서는 io_context의 내부 동작을 해부해, 고성능 Asio 코드를 설계할 때 필요한 기초를 쌓습니다.

목표:

  • run()poll()의 결정적 차이 — 언제 무엇을 쓸지
  • Proactor 패턴 — 비동기 연산이 완료되면 핸들러가 어떻게 스케줄되는지
  • work_guard — 이벤트 루프가 꺼지지 않게 유지하는 방법
  • 심화: epoll/kqueue/IOCP, 단일·멀티스레드 모델, graceful shutdown, 성능·디버깅, Echo 서버 예제

선수 지식: C++ 실전 가이드 #29-1: Asio 라이브러리 입문에서 io_context와 async_* 기본 사용법을 알고 있으면 좋습니다.


실무에서 겪은 문제

실제 프로젝트에서 이 개념을 적용하며 겪었던 경험을 공유합니다.

문제 상황과 해결

대규모 C++ 프로젝트를 진행하며 이 패턴/기법의 중요성을 체감했습니다. 책에서 배운 이론과 실제 코드는 많이 달랐습니다.

실전 경험:

  • 문제: 처음에는 이 개념을 제대로 이해하지 못해 비효율적인 코드를 작성했습니다
  • 해결: 코드 리뷰와 프로파일링을 통해 문제를 발견하고 개선했습니다
  • 교훈: 이론만으로는 부족하고, 실제로 부딪혀보며 배워야 합니다

이 글이 여러분의 시행착오를 줄여주길 바랍니다.


목차

  1. io_context란 무엇인가
  2. run() vs poll() — 결정적 차이
  3. Proactor 패턴의 이해
  4. work_guard로 이벤트 루프 유지
  5. 실전 패턴 정리
  6. 이벤트 루프 동작 원리 심화
  7. 실전 패턴: 스레드 모델·work_guard·graceful shutdown
  8. 성능 최적화
  9. 디버깅 가이드
  10. 실전 서버 예제: Echo·타임아웃·연결 관리

1. io_context란 무엇인가

실행 컨텍스트와 작업 큐

io_context는 두 가지 역할을 합니다.

  1. I/O 이벤트와의 연동: OS의 I/O 멀티플렉싱(epoll, kqueue, IOCP 등)과 연동해 “어떤 소켓이 읽기/쓰기 가능한지”를 알려 받습니다.
  2. 핸들러 실행: 등록된 완료 핸들러(completion handler)한 스레드에서 순차적으로 실행하는 실행 큐를 제공합니다.

비동기 연산(async_read, async_write, async_accept 등)을 시작하면, Asio는 내부적으로 “이 연산이 완료되면 이 핸들러를 실행 큐에 넣는다”고 등록합니다. run()을 호출하는 스레드가 그 큐에서 핸들러를 꺼내 실행합니다.

io_context 이벤트 루프 동작을 한눈에 보면 아래와 같습니다.

flowchart LR
  A[비동기 연산 등록] --> B[완료 대기]
  B --> C[완료 시 핸들러 큐 적재]
  C --> D[run: 큐에서 꺼내 실행]
  D --> A

처음 Asio를 쓸 때 “run() 한 번 호출하면 서버가 영원히 돌 것”이라고 생각하기 쉽습니다. 실제로는 io_context가 “지금 처리할 작업이 있는지”를 세어 보고, 등록된 비동기 연산만 있고 아직 완료된 게 없으면 “할 일 없음”으로 간주해 run()이 곧바로 반환할 수 있습니다. 그래서 서버처럼 “연결을 기다리며 계속 돌아가게” 하려면 work_guard나 “핸들러 안에서 다음 비동기 연산을 다시 등록”하는 패턴이 필요합니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o io_run io_run.cpp -lboost_system -pthread && ./io_run
#include <boost/asio.hpp>
#include <iostream>

int main() {
    boost::asio::io_context io;
    boost::asio::steady_timer timer(io, std::chrono::seconds(1));
    timer.async_wait([&](const boost::system::error_code& ec) {
        if (!ec) std::cout << "1초 후 실행\n";
    });
    io.run();
    return 0;
}

실행 결과: 약 1초 후 1초 후 실행 이 한 줄 출력됩니다.

  • run()은 “할 일이 있을 때까지” 루프를 돌다가, 등록된 작업이 하나도 없고 완료 대기 중인 연산도 없으면 반환합니다.
  • 그래서 서버처럼 “영원히 대기”하려면 work_guard로 “아직 일이 있다”고 표시하거나, 완료 핸들러 안에서 다음 비동기 연산을 계속 등록해야 합니다.

2. run() vs poll() — 결정적 차이

run(): 블로킹 until work

run()은 다음을 반복합니다.

  1. 완료된 I/O에 대응하는 핸들러를 실행 큐에 넣고,
  2. 큐에서 핸들러를 꺼내 실행하고,
  3. 할 일이 없으면 OS의 이벤트 대기(epoll_wait 등)에 들어가 블로킹합니다. 새 이벤트나 post된 작업이 생기면 깨어나 다시 1–2를 반복합니다.
  4. 더 이상 등록된 작업도 없고, 완료 대기 중인 비동기 연산도 없으면 run()이 반환합니다.

즉, run() = “작업이 있으면 처리하고, 없으면 기다리다가, 영원히 할 일이 없으면 끝낸다.”

poll(): 대기 없이 준비된 것만

poll()한 번 다음만 수행합니다.

  • 이미 준비된(완료된) 핸들러만 실행 큐에 넣고 실행합니다.
  • 블로킹하지 않습니다. OS의 “이벤트 대기”에 들어가지 않습니다.
  • 한 번 호출하면 곧바로 반환합니다.
boost::asio::io_context io;

// poll(): 대기 없이, 지금 준비된 작업만 처리 후 반환
io.poll();

// run(): 할 일이 없으면 블로킹, 할 일이 없어지면 반환
io.run();

동작 시나리오 예: steady_timer로 1초 후에 한 번만 콜백을 등록하고 io.run()을 호출하면, run()은 “1초 후 타이머 완료 → 핸들러 실행 → 그 다음 등록된 작업 없음 → run() 반환”까지 블로킹합니다. 반면 같은 설정으로 io.poll()만 호출하면, 아직 1초가 안 지났으므로 “준비된 핸들러 없음 → 아무것도 안 하고 즉시 반환”합니다. 그래서 게임 루프처럼 “매 프레임 poll() 한 번씩 호출”하는 식으로 쓰면, 타이머·I/O는 그때그때 처리하면서도 메인 루프가 멈추지 않습니다.

언제 뭘 쓸까?

목적사용
서버/클라이언트 메인 루프 — 이벤트 올 때까지 대기run()
게임 루프 등 — 매 프레임 “Asio 작업만 처리하고 바로 반환”poll()
run_one()최대 한 개의 핸들러만 처리하고 반환 (테스트·디버깅에 유용)

고성능 네트워크 서버에서는 보통 run()을 여러 스레드에서 호출해 스레드 풀을 만들고, poll()은 “다른 메인 루프 안에 Asio를 끼워 넣을 때” 사용합니다.


3. Proactor 패턴의 이해

Reactor vs Proactor

  • Reactor: “이 소켓이 읽기 가능해지면 알려 달라” → 준비 이벤트를 받고, 그때 직접 read()를 호출합니다. (이벤트 루프가 알려 주고, 사용자 코드가 I/O를 수행)
  • Proactor: “이 버퍼에 읽기가 완료되면 이 핸들러를 호출해 달라” → 완료 이벤트를 받습니다. I/O는 라이브러리(Asio)가 이미 수행한 뒤, 완료 핸들러만 호출합니다.

Asio는 Proactor 스타일입니다. async_read를 호출하면:

  1. Asio가 내부적으로 “이 소켓에서 이 버퍼로 읽기”를 OS에 등록하고,
  2. OS가 읽기를 끝내면 Asio가 해당 완료 핸들러실행 큐에 넣고,
  3. run()을 실행 중인 스레드가 그 핸들러를 꺼내 실행합니다.

그래서 “완료 핸들러는 반드시 io_context의 실행 맥락(run() 호출 스레드) 에서 호출된다”는 점이 중요합니다. 여러 스레드가 같은 io_context의 run()을 돌리면, 어떤 스레드가 그 핸들러를 실행할지는 스케줄에 따라 달라지지만, 항상 run() 중인 스레드 중 하나입니다. 이걸 이해해야 Strand와 락 사용을 올바르게 설계할 수 있습니다.


4. work_guard로 이벤트 루프 유지

문제: run()이 금방 끝난다

boost::asio::io_context io;
boost::asio::ip::tcp::acceptor acceptor(io, endpoint);

acceptor.async_accept([&](const boost::system::error_code& ec, auto socket) {
    // 새 연결 처리
});
io.run();  // ⚠️ async_accept만 있고 "작업 카운트"가 없으면
           // 일부 구현에서는 run()이 바로 반환할 수 있음

io_context는 내부적으로 “현재 의미 있는 작업 수”를 추적합니다. 비동기 연산만 등록하고, 아직 완료되지 않았을 때 “작업이 없다”고 판단해 run()이 반환하는 경우가 있습니다. (구현·버전에 따라 동작이 다를 수 있음)

해결: work_guard

work_guard는 “이 io_context에는 아직 할 일이 있다”는 것을 나타내는 객체입니다. 이 객체가 살아 있는 한, io_context는 run()이 작업이 없어도 곧바로 끝나지 않도록 유지합니다.

#include <boost/asio.hpp>

boost::asio::io_context io;
auto work = boost::asio::make_work_guard(io);

boost::asio::ip::tcp::acceptor acceptor(io, endpoint);
acceptor.async_accept([&](const boost::system::error_code& ec, auto socket) {
    if (!ec) { /* 새 소켓 처리 */ }
    // 다시 async_accept 등록해 반복
    acceptor.async_accept(/* ... */);
});

io.run();  // work가 살아 있는 한 run()은 끝나지 않음
  • make_work_guard(io) 는 io_context의 “작업 카운트”를 올립니다.
  • work_guard를 파괴하면(스코프를 벗어나거나 reset) 카운트가 내려가고, 그때 다른 작업도 없으면 run()이 반환할 수 있습니다.
  • 서버를 “의도적으로 종료”할 때는 work_guard를 해제한 뒤 run()이 반환하도록 하는 패턴을 씁니다.

실무 팁: “서버가 시작하자마자 run()이 끝나버린다”면 work_guard를 아직 안 썼거나, 비동기 연산 등록 전에 run()이 호출된 경우가 많습니다. 먼저 acceptor.async_accept(…)로 “할 일”을 등록한 뒤 run()을 호출하고, 필요하면 make_work_guard(io)로 작업 카운트를 유지하는 순서를 지키면 됩니다.

주의점

  • work_guard를 너무 오래 들고 있으면 “서버를 종료하고 싶은데 run()이 안 끝난다”는 상황이 됩니다. 종료 시나리오에서는 work_guard를 먼저 해제하는 것을 명확히 설계하세요.

5. 실전 패턴 정리

항목요약
run()블로킹; 작업 처리 → 없으면 대기 → 영원히 없으면 반환
poll()비블로킹; 준비된 작업만 처리 후 즉시 반환
ProactorI/O 완료 후 핸들러가 실행 큐에 들어가 run() 맥락에서 실행됨
work_guardrun()이 “할 일 없음”으로 곧바로 끝나지 않게 유지

6. 이벤트 루프 동작 원리 심화

Proactor 패턴을 조금 더 정확히

앞에서 말한 Proactor는 “비동기 연산을 시작하고, 완료 시점에 핸들러가 호출된다”는 큰 그림입니다. 구현 관점에서 한 단계 더 나누면 다음과 같습니다.

  1. 연산 제출(Initiation)
    async_read, async_write, async_accept 등은 “이 소켓·이 버퍼에 대해 I/O를 수행하고, 끝나면 이 핸들러를 실행 큐에 넣어라”는 비동기 연산 요청을 Asio에 남깁니다.

  2. OS·런타임과의 연동
    Asio는 내부 reactor(준비 가능 여부를 알려 주는 계층)와 비동기 완료 처리 경로를 조합합니다. 플랫폼에 따라 “커널이 버퍼까지 채워 준 뒤 완료를 알려 주는” 경로와 “준비됐을 때 사용자 공간에서 read/write를 수행한 뒤 완료로 간주하는” 경로가 섞일 수 있습니다. 사용자 코드가 보는 API는 여전히 “완료 핸들러” 중심인 Proactor 스타일입니다.

  3. 완료 디스패치
    I/O가 끝나거나(또는 오류·취소), 타이머가 만료되면, 해당 completion handlerio_context의 실행 큐에 들어가고, run()/poll()/run_one()을 돌리는 스레드가 꺼내 실행합니다.

Proactor는 “이벤트가 오면 내가 read 한다(Reactor)”가 아니라, “읽기/쓰기 완료를 단위로 내 로직이 한 번 호출된다”에 가깝게 사고하는 패턴입니다. Asio에서 strand로 순서를 묶거나, executor를 바꿔 다른 큐로 보내는 것도 이 “완료 → 큐 적재 → 실행” 파이프라인 위에서 동작합니다.

epoll, kqueue, IOCP — 플랫폼별 차이 (개념)

Asio는 백엔드로 OS의 I/O 멀티플렉싱을 씁니다. 전부 “많은 소켓을 적은 스레드로 감시한다”는 목표는 같지만 모델이 다릅니다.

API주요 플랫폼특징
epoll (Linux)리눅스epoll_create / epoll_ctl / epoll_wait관심 있는 fd 집합에 대해 준비 이벤트를 한 번에 대기. 수평 확장에 유리한 편.
kqueueBSD, macOSkevent로 이벤트를 등록·대기. 필터 단위로 동작이 잘 정리되어 있음.
IOCP (Windows)Windows완료 포트에 연결·I/O를 걸고, I/O가 끝나면 완료 패킷이 큐에 쌓임. “완료 기반” 모델과 잘 맞음.

중요한 점: 애플리케이션 코드는 보통 이 API를 직접 부르지 않고 Asio가 추상화합니다. 다만 윈도우에서 IOCP 경로리눅스에서 epoll 경로는 커널이 알려 주는 단계(준비 vs 완료)가 달라, 버퍼 수명·scatter/gather·오버랩 I/O 같은 세부는 문서와 플랫폼별 동작을 함께 봐야 합니다. “모든 OS에서 완전히 동일한 커널 의미”를 가정하면 디버깅에서 헷갈리기 쉽습니다.

이벤트 큐 “내부 구조”를 개발자 관점에서

공식 구현 디테일은 버전마다 다르지만, 이해에 쓸 모델은 다음과 같습니다.

  • I/O 완료·타이머 만료·post()/dispatch()로 넣은 작업은 결국 실행할 핸들러의 큐로 모입니다.
  • run()을 호출한 스레드는 이 큐에서 꺼내 연속 실행합니다. 한 핸들러 실행 중에는 다른 핸들러가 끼어들지 않습니다(같은 스레드에서). 여러 스레드가 같은 io_context::run()을 돌리면 여러 핸들러가 동시에 실행될 수 있어 공유 상태에 대한 동기화가 필요해집니다(다음 편 멀티스레드·data race 참고).
  • “이벤트 큐”와 “OS의 준비/완료 큐”는 계층이 다릅니다. OS 쪽에서 깨어난 뒤, Asio가 어떤 핸들러를 사용자 큐에 넣을지 결정하고, 그다음이 run() 루프입니다.

이 모델을 머릿속에 두면 “왜 핸들러 안에서 오래 막히면 전체 처리량이 떨어지는지”, “왜 post한 작업 순서를 보장하려면 strand가 필요한지”가 자연스럽게 이어집니다.


7. 실전 패턴: 스레드 모델·work_guard·graceful shutdown

단일 스레드 vs 멀티 스레드 io_context

  • 단일 스레드에서 run() 하나
    모든 완료 핸들러가 순차 실행되므로 연결 상태를 mutex 없이 다루기 쉽습니다. 대신 한 핸들러가 CPU를 오래 쓰면 그동안 다른 연결 처리가 멈춥니다.

  • 하나의 io_context에 여러 스레드가 run()
    처리량은 늘 수 있지만, 같은 소켓·같은 세션 객체에 여러 핸들러가 동시에 닿을 수 있어 락·strand·뮤텍스 설계가 필요합니다.

  • io_context를 여러 개
    리스닝 소켓을 고정 분할(예: 포트별·CPU별)하거나, 무엇을 어느 컨텍스트에 묶을지 명확할 때 씁니다. 운영·디버깅은 단순해질 수 있지만 연결 간 부하 분산 설계가 따로 필요합니다.

실무에서는 **단일 스레드 + 필요 시 워커 스레드 풀에 CPU 작업만 post**가 가장 예측 가능한 경우가 많습니다.

work_guard 활용 패턴 (정리)

  • 서버 기동 직후: 아직 async_accept 체인이 안정적으로 돌기 전에 run()이 빠져나가지 않게 **make_work_guard(io)**를 두는 패턴이 흔합니다.
  • 유지보수: work_guard명시적 스코프(함수·클래스 멤버)에 두고, 종료 시점에만 reset()하거나 객체를 파괴해 “이제 진짜로 일이 없어도 된다”고 알립니다.
  • 테스트: 짧은 단위 테스트에서 이벤트 루프를 돌릴 때, 외부에서 stop()을 걸 타이밍을 맞추기 어렵다면 work_guard로 기대 수명을 제어합니다.

graceful shutdown 구현

정상 종료는 보통 다음 순서를 밟습니다.

  1. 새 연결 막기: acceptor.close() 또는 cancel()로 더 이상 accept하지 않음.
  2. 기존 연결 정리: 세션에 “종료 중” 플래그를 세우고, 읽기/쓰기 완료 후 소켓 close.
  3. work_guard 해제: 더 이상 “영구 작업”이 없다고 표시.
  4. io_context::stop() (필요 시): 대기 중인 run()을 깨워 남은 핸들러를 처리한 뒤 빠져나가게 할 수 있음. 재시작하려면 이후 restart() 후 다시 run().
  5. 워커 스레드 조인: run()을 돌린 스레드들이 반환될 때까지 대기.
// 개념 스케치: work_guard + stop으로 종료 유도
boost::asio::io_context io;
auto work = boost::asio::make_work_guard(io);
std::thread t([&]{ io.run(); });

// ... 종료 시그널 수신 후 ...
work.reset();           // 또는 스코프 종료
io.stop();              // run()이 반환되도록
t.join();

운영 환경에서는 시그널 핸들러관리 API에서 위 순서를 한곳에 모아 두는 것이 안전합니다. stop() 직후 아직 큐에 남은 핸들러가 있을 수 있으므로, “즉시 프로세스 exit”보다 짧은 드레인 정책을 문서화해 두면 좋습니다.


8. 성능 최적화

poll vs run 선택 기준 (다시 정리)

  • run(): “할 일이 없을 때 OS 이벤트 대기까지 포함” — 서버 메인 루프, 별도 스레드에서 이벤트 루프를 돌릴 때 기본값에 가깝습니다. CPU를 쓰지 않고 블로킹으로 대기합니다.
  • poll(): 한 틱만 처리하고 즉시 반환게임 루프·UI 스레드처럼 “내 루프 안에서 Asio를 한 번씩만 돌려야 할 때”. 빈 루프에서 poll()만 연속 호출하면 CPU를 불필요하게 쓸 수 있어, 보통 sleep/steady_timer와 조합하거나 다른 이벤트 소스와 통합합니다.
  • run_one() / poll_one(): 한 개의 핸들러만 처리 — 단계별 디버깅, 테스트, “이번 턴에 하나만” 같은 제어에 사용.

규칙: “백그라운드 스레드가 전담으로 I/O를 처리한다” → run(); “메인 스레드가 프레임 단위로 제어한다” → poll() 쪽을 검토.

이벤트 루프 병목 찾기

  • 프로파일러io_context 관련 스레드에서 시간이 어디로 가는지 확인합니다. 핸들러 본문이 상위에 오면 핸들러가 무겁다는 뜻입니다.
  • 로그에 타임스탬프를 찍어 같은 연결에서 핸들러 호출 간격이 벌어지는지 봅니다. 한 연결 처리에 수 ms 이상 걸리면 지연이 누적됩니다.
  • asio::buffer 크기·read 루프가 작아서 시스템 콜 횟수가 과도하지 않은지 확인합니다.
  • **멀티스레드 run()**에서 락 경합이 심하면 strand 분리·샤딩·큐 분리를 검토합니다.

CPU 바운드 작업 처리

절대 하지 말 것: 완료 핸들러 안에서 큰 행렬 연산·압축·정규식 폭탄긴 CPU 작업을 그대로 수행하는 것. 그동안 같은 io_context의 다른 I/O·타이머가 지연됩니다.

권장 패턴:

  • 별도 워커 스레드 풀에서 CPU 작업을 실행하고, 끝나면 io_context::post(또는 dispatch)로 결과만 io 스레드에 넘겨 소켓/상태 갱신을 하게 합니다.
  • C++20 이후에는 코루틴 + 실행자(executor) 로 네트워크와 계산 스레드를 분리하는 패턴도 많아졌습니다.

핵심은 “I/O 스레드는 얇게, CPU는 다른 곳에서” 입니다.


9. 디버깅 가이드

run()이 바로 끝나는 문제

  • 원인: 등록된 비동기 연산이 없고, work_guard도 없고, 완료 대기 중인 작업도 없음으로 판단되는 상태.
  • 조치: async_accept / 타이머 등을 run() 전에 등록했는지 확인. 서버라면 make_work_guard(io) 또는 지속적으로 다음 async_*를 재등록하는 구조인지 확인.
  • 참고: 예제·버전에 따라 “pending async만 있을 때” 동작 설명이 미묘할 수 있어, 실제로는 work_guard나 연속 async 체인으로 의도를 명확히 하는 편이 안전합니다.

핸들러가 실행되지 않는 경우

  • io_context::stop()이 이미 호출됨 → 남은 핸들러 정책을 확인(run() 재호출 전 restart() 필요 여부).
  • **잘못된 executor / 잘못된 io_context**에 연산을 걸었는지 확인(여러 io_context 혼용 시 흔한 실수).
  • 소켓이 닫힌 뒤에만 완료되는 경로에 콜백이 안 타는 경우 → 에러 코드를 로그로 남겼는지 확인.
  • 예외가 핸들러 밖으로 새 나가 run()이 예외로 종료한 뒤, 재시도 없이 프로세스가 멈춘 것처럼 보이는 경우 → 상위에서 예외를 잡거나 asio의 예외 처리 정책을 확인합니다.

데드락 디버깅

  • 핸들러 안에서 같은 스레드의 io_context에 대해 동기적으로 run()을 또 호출하는 패턴은 위험합니다(재진입·교착).
  • 락 순서: 콜백 A가 락1→락2, 콜백 B가 락2→락1이면 데드락 가능. strand로 순서를 고정하거나, 락 범위를 최소화하세요.
  • std::mutex를 길게 잡은 채 다른 비동기 연산 완료를 기다리는 형태(사실상 블로킹)도 이벤트 루프를 멈추게 만들 수 있습니다. 비동기 체인으로 풀거나, 조건을 post로 넘기세요.

스레드 덤프(각 스레드가 epoll_wait / run / 어떤 락에서 멈췄는지)를 보면 원인이 빨리 좁혀집니다.


10. 실전 서버 예제: Echo·타임아웃·연결 관리

아래는 교육용으로 읽기 쉬운 형태의 비동기 Echo 서버 스케치입니다. 실제 제품에서는 로깅, 한도, TLS, 시그널 처리 등을 더합니다.

Echo 세션 (읽기 → 쓰기 루프)

// g++ -std=c++17 echo_server.cpp -o echo_server -lboost_system -pthread
#include <boost/asio.hpp>
#include <iostream>
#include <memory>
#include <vector>

using boost::asio::ip::tcp;

class Session : public std::enable_shared_from_this<Session> {
public:
  Session(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(data_),
        [this, self](const boost::system::error_code& ec, std::size_t n) {
          if (ec) return;
          do_write(n);
        });
  }

  void do_write(std::size_t n) {
    auto self = shared_from_this();
    boost::asio::async_write(
        socket_, boost::asio::buffer(data_, n),
        [this, self](const boost::system::error_code& ec, std::size_t /*sent*/) {
          if (ec) return;
          do_read();
        });
  }

  tcp::socket socket_;
  std::array<char, 4096> data_{};
};

class Server {
public:
  Server(boost::asio::io_context& io, unsigned short port)
      : acceptor_(io, tcp::endpoint(tcp::v4(), port)) {
    do_accept();
  }

private:
  void do_accept() {
    acceptor_.async_accept(
        [this](const boost::system::error_code& ec, tcp::socket socket) {
          if (!ec) {
            std::make_shared<Session>(std::move(socket))->start();
          }
          do_accept();
        });
  }

  tcp::acceptor acceptor_;
};

int main() {
  try {
    boost::asio::io_context io;
    auto work = boost::asio::make_work_guard(io);
    Server srv(io, 4000);
    // work는 서버 수명 동안 유지; 종료 시 reset + io.stop() 패턴으로 확장
    io.run();
  } catch (const std::exception& e) {
    std::cerr << e.what() << "\n";
  }
  return 0;
}

연결 관리: Sessionshared_ptr로 보관하고, 소켓이 끊기면 핸들러가 반환되며 참조 카운트가 0이 되어 세션이 정리됩니다. **명시적 std::set<std::shared_ptr<Session>>에 넣어 “활성 연결 목록”을 유지하면, 종료 시 전부 close**하는 식으로 graceful shutdown과도 연결할 수 있습니다.

타임아웃 처리

읽기/쓰기 자체에 표준 타임아웃 API가 없다고 생각하면 구현이 단순해집니다. 관용적 방법은 다음과 같습니다.

  • **steady_timer**를 두고, 마지막 활동 시각을 갱신하며, 타이머 콜백에서 **idle 시간 초과 시 socket.cancel() 또는 close()**로 세션을 끊습니다.
  • 두 개의 비동기 대기(읽기 vs 타이머)가 경쟁할 수 있으므로, 세션 종료 플래그로 한쪽이 이기면 다른 쪽은 무시하도록 합니다.
// 개념: idle 30초면 소켓 취소 (실제 코드는 strand/플래그로 경쟁 조건 방지)
boost::asio::steady_timer idle_timer(io);
idle_timer.expires_after(std::chrono::seconds(30));
idle_timer.async_wait([&socket](auto ec) {
  if (!ec) socket.cancel();
});

연결 수·리소스 한도

  • listen 백로그동시 연결 수는 별개입니다. **너무 많은 Session**은 메모리·파일 디스크립터 한도에 걸립니다.
  • OS **ulimit -n**과 애플리케이션 std::set 크기·카운터를 함께 모니터링하세요.

이 예제는 단일 스레드 io_context 기준입니다. 멀티스레드로 올릴 때는 acceptor/세션 접근strand 등으로 정리해야 합니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. run()과 poll()의 차이, Proactor 패턴의 이해, work_guard로 이벤트 루프가 꺼지지 않게 유지하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

다음 글: [C++ 고성능 네트워크 가이드 #2] 멀티스레드 Asio의 딜레마: Data Race와 Mutex의 한계


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ Boost.Asio 입문 | io_context·async_read
  • C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
  • C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴

관련 글

  • C++ Boost.Asio 입문 | io_context·async_read
  • C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post
  • C++ 멀티스레드 네트워크 서버 완벽 가이드 | io_context 풀·strand·data race 방지
  • C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]
  • C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]