C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴
이 글의 핵심
Berkeley 소켓·리액터/프로액터 맥락과 함께 Boost.Asio·Asio 7편(이벤트 루프→레이스→Strand→스케줄링→할당·코루틴·Composed) 목차를 안내합니다.
C++ 네트워크 프로그래밍 시리즈 안내
이 페이지는 C++로 네트워크 서버와 클라이언트를 설계할 때 필요한 지식의 지도와, 이 사이트에 연재 중인 Boost.Asio(및 standalone Asio) 기반 7편 시리즈의 읽는 순서를 한곳에 모은 인덱스입니다. 소켓 API에서 출발해 이벤트 루프, Strand, 코루틴까지 이어지는 흐름을 잡는 것이 목표이며, TCP/IP·OSI 같은 기초 이론은 아래 프로토콜 스택에서, 디버깅과 운영 패턴은 후반 절에서 다룹니다.
시리즈가 답하려는 질문은 세 가지입니다. 첫째, I/O를 블로킹으로 처리할지, select/poll로 기다릴지, epoll/IOCP/kqueue로 다중화할지, 아니면 Asio io_context에 맡길지를 어떤 기준으로 고르는가. 둘째, 멀티스레드와 비동기 콜백이 겹칠 때 데이터 레이스를 막는 패턴(Strand, 실행 큐)은 무엇인가. 셋째, 연결이 많고 메시지가 잦을 때 할당과 컨텍스트 스위칭 비용을 줄이려면 핸들러 메모리, 코루틴, Composed Operation을 어떻게 조합하는가.
왜 Asio인가: 소켓에서 이벤트 루프까지
전통적인 Berkeley 소켓 API는 select/poll/epoll 등으로 준비된 fd를 기다렸다가 읽고 쓰는 Reactor 계열 패턴으로 잘 동작합니다. 그러나 연결이 많아지면 콜백, 타이머, 비동기 I/O 완료를 한곳에서 스케줄해야 하고, 멀티스레드와 섞이면 데이터 레이스가 쉽게 생깁니다. Asio는 이런 비동기 작업을 io_context에 모아, 완료 시점에 핸들러(콜백 또는 코루틴)를 실행하는 모델을 제공합니다. 문서에서는 이를 Proactor 패턴으로 설명하며, Linux처럼 OS가 준비(readiness) 통지만 주는 플랫폼에서는 Asio가 내부적으로 Reactor 위에 완료 모델을 흉내 냅니다.
그래서 시리즈는 먼저 한 스레드에서 run()이 무엇을 도는지(#1)를 잡고, 여러 스레드가 같은 io_context를 돌릴 때(#2) 무엇이 깨지는지 본 뒤, Strand로 핸들러를 직렬화(#3)하고, post/dispatch/defer로 실행 시점을 조정(#4)합니다. 여기까지가 “망가지지 않게 많이 처리하기”의 기반이고, 이어지는 할당 최적화(#5), 코루틴(#6), Composed Operation(#7)은 “고빈도·복잡한 프로토콜을 유지보수 가능하게” 만드는 단계입니다.
학습 로드맵
C++ 네트워크 프로그래밍은 (1) 전송·주소·연결의 의미를 이해하고, (2) 한 프로세스가 수천~수십만 연결을 감당하는 구조(다중화·비동기·스레드 모델)를 고른 뒤, (3) 응용 프로토콜(HTTP, WebSocket, 커스텀 바이너리)을 그 위에 올리는 순서로 익히는 것이 효율적입니다. 기초 단계에서는 IP, TCP/UDP, 포트와 bind/listen/accept, 논블로킹을 다루고, 다중화 단계에서 select/poll/epoll, Windows IOCP, BSD kqueue를 정리합니다. 그다음 프레이밍 단계에서 고정 길이·구분자·길이 프리픽스로 응용 메시지 경계를 세우고, 동시성 단계에서 스레드 풀, 여러 스레드가 도는 io_context, Strand, 락 설계로 “누가 언제 실행하는가”를 맞춥니다. 마지막으로 로그·트레이스·소켓 옵션·커널 버퍼·로드 밸런서까지 이어지면 큰 그림이 완성됩니다.
읽는 순서(본 7편): #1 io_context·이벤트 루프부터 차례로 읽으면 run/poll → 멀티스레드와 데이터 레이스 → Strand → post/dispatch/defer → 핸들러 메모리 → 코루틴 → Composed Operation 순으로 이어집니다. 선수 지식으로 C++ 실전 가이드 #29-1 Asio 입문에서 io_context와 async_* 기본 사용법을 알고 있으면 좋습니다.
요구 환경: 시리즈 전체가 Boost.Asio 또는 standalone Asio 기준이며, 예제는 C++14 이상(코루틴 편은 C++20)을 가정합니다. Linux/macOS 또는 Windows + WSL에서 빌드·실행하는 것을 권장합니다.
flowchart LR A["#1 이벤트 루프"] --> B["#2 Data Race"] B --> C["#3 Strand"] C --> D["#4 post/dispatch/defer"] D --> E["#5~7 할당·코루틴·Composed"]
목적별 추천 경로
- 처음 Asio를 깊게 볼 때: #1 이벤트 루프 → #2 Data Race → #3 Strand → #4 post/dispatch/defer
- 성능·코루틴만 볼 때: #5 핸들러 메모리 → #6 코루틴·awaitable
- 선수 학습: C++ 실전 가이드 #29 Asio 입문
- 프로토콜·암호화 응용: C++ 실전 가이드 #30: WebSocket·SSL
한 번에 읽을 분량
1일차에는 #29-1 Asio 입문과 #1 이벤트 루프를 읽으며 run/poll, work_guard, 루프가 일감이 없어 끝나는 조건을 익힙니다. 2일차에는 #2와 #3으로 콜백 안의 공유 상태, 락의 한계, Strand 직렬화를 보고, 3일차에는 #4에서 post·dispatch·defer의 차이를 정리합니다. 45일차에는 #5#7의 할당기, co_await, 합성 비동기 연산을 다룹니다.
주제별 분류
TCP/UDP 기초
신뢰성과 순서가 필요한 통신은 TCP를, 지연이 중요하고 일부 손실을 감수하거나 브로드캐스트·멀티캐스트가 필요하면 UDP를 씁니다. 주소와 바인딩에서는 INADDR_ANY, 듀얼스택(IPv4-mapped IPv6), 임시 포트, TIME_WAIT와 SO_REUSEADDR의 의미가 OS마다 조금씩 다르므로 맨 페이지로 확인해야 합니다. 이 사이트에서는 #29-1 Asio 입문에서 async_accept·async_read로 연결합니다. TCP 바이트 스트림에는 메시지 경계가 없다는 점이 이후 Composed Operation 설계의 전제가 됩니다.
고급 소켓 프로그래밍
논블로킹 소켓과 여러 fd를 다루는 플랫폼 API(fcntl/ioctl, WSAEventSelect, epoll)는 Asio가 내부적으로 추상화하지만, native_handle()로 소켓 옵션을 직접 조정할 수도 있습니다. send가 EAGAIN/WSAEWOULDBLOCK을 돌려줄 때는 쓰기 큐와 워터마크로 백프레셔를 걸어야 하고, 읽기 쪽에서는 상위 파서의 처리 속도와 맞춰야 합니다. 최대 메시지 길이, rate limit, half-open 연결, slowloris 같은 공격은 응용 설계와 커널·라이브러리 옵션을 함께 봐야 막을 수 있습니다.
비동기 I/O: epoll, IOCP, kqueue
Linux epoll은 레벨 트리거와 엣지 트리거, EPOLLONESHOT 같은 옵션이 이벤트 루프 설계와 직결됩니다. Windows IOCP는 작업 완료를 큐로 받는 완료 기반 모델로, Asio가 설명하는 Proactor와 가장 잘 맞습니다. macOS/BSD의 kqueue는 kevent 하나로 읽기·쓰기·타이머·시그널 이벤트를 다룹니다. 시리즈의 #1~#4는 개별 API 대신 “완료를 누가, 언제, 어느 스레드에서 꺼내 처리하는가”에 집중합니다. 준비 기반(epoll)과 완료 기반(IOCP) 모델은 같은 서버를 이식할 때 생각보다 크게 어긋나므로, Asio 추상 위에서 같은 실행 모델로 코드를 쓰고 OS API는 튜닝·추적할 때만 들여다보는 편이 유지보수에 유리합니다.
HTTP / WebSocket
HTTP/1.1은 Keep-Alive, 청크 전송, Host 헤더를 다뤄야 하며 파이프라이닝은 실무에서 거의 쓰이지 않습니다. HTTP/2와 HTTP/3는 멀티플렉싱, HPACK/QPACK, QUIC(UDP 기반)까지 얽혀 있어 C++로 직접 구현하기보다 nghttp2, quiche 같은 라이브러리를 쓰는 것이 일반적입니다. WebSocket은 HTTP 업그레이드 후 프레임 단위로 동작하며 C++ 실전 가이드 #30에서 다룹니다. Asio 7편은 HTTP 파서 자체보다 읽기/쓰기·타이머·Strand로 응용 프로토콜을 쌓는 토대에 초점을 둡니다.
성능 최적화
시스템 콜 횟수, 버퍼 복사, 할당 횟수가 주요 비용입니다. 버퍼 풀, iovec/sendmsg를 이용한 scatter-gather, 작은 버퍼 최적화로 줄일 수 있고, 락과 캐시 라인 측면에서는 false sharing을 피하고 Strand로 꼭 필요한 직렬화만 거는 것이 중요합니다. #5 핸들러 메모리가 할당 병목을, #6·#7이 로직 복잡도를 다룹니다.
프로토콜 스택: OSI 7계층과 TCP/IP
OSI 모델에서 7계층(응용)에는 HTTP, WebSocket, DNS, gRPC가 올라가고, TLS는 문헌에 따라 6계층(표현)이나 전송과 응용 사이에 걸쳐 설명됩니다. 4계층(전송)은 TCP의 신뢰·순서, UDP 데이터그램, 포트가 핵심이고, 3계층(네트워크)은 IP·라우팅·ICMP, 2계층(데이터 링크)은 Ethernet·ARP, 1계층(물리)은 케이블과 NIC입니다. 일반적인 C++ 응용 코드는 전송 계층 아래로 내려가지 않습니다.
OSI는 교육용 개념 모델이고, 실제 인터넷은 링크·인터넷·전송·응용의 TCP/IP 4계층으로 설명하는 경우가 많습니다. 소켓 API와 대응시키면 응용 계층은 send/recv에 넣는 바이트, 전송 계층은 TCP 스트림과 UDP 데이터그램(connect/accept는 TCP 개념), 인터넷 계층은 sockaddr_in/sockaddr_in6의 주소, 링크 계층은 드라이버와 NIC입니다. TLS는 전송 위에 올라가는 보호 계층으로 이해하면 Asio와 OpenSSL, Beast를 함께 쓸 때 흐름이 선명해집니다.
편별로 다루는 내용
Asio는 비동기 작업이 끝나면 등록해 둔 핸들러를 io_context가 실행하는 구조입니다. #1은 run/poll, Proactor, work_guard를 통해 “왜 run()이 바로 끝나는가”, “이벤트 루프를 어떻게 유지하는가”에 답합니다. #2는 여러 스레드가 같은 io_context에서 run()을 돌릴 때 콜백 실행 순서가 비결정적이 되는 문제와, mutex만으로는 부족한 재진입·순서 문제를 다룹니다.
#3은 Strand로 핸들러를 직렬화해 락 없이 동시 실행을 막는 방법과 make_strand·bind_executor 사용법을 봅니다. #4는 post/dispatch/defer로 지금 스레드에서 바로 실행할지 큐에 넣을지를 정하는 방법과 기아(starvation)·재진입 영향을 설명합니다. #5는 고빈도 연결에서 작은 핸들러 객체 할당을 재사용 가능한 메모리로 줄여 꼬리 지연을 낮추는 방법을, #6은 C++20 코루틴과 awaitable로 콜백 중첩을 줄이면서 executor 맥락을 잃지 않는 패턴을, #7은 여러 async_* 호출을 하나의 비동기 연산으로 묶는 Composed Operation(헤더+바디 읽기, 핸드셰이크+전송 등)을 다룹니다.
Phase 1. 이벤트 루프 해부
- C++ Boost.Asio io_context 이벤트 루프 | 동작 원리 정리 (#1):
run()과poll()의 차이, Proactor 패턴,work_guard로 이벤트 루프 유지 - C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]: 여러 스레드가 하나의
io_context를 공유할 때의 문제, 비동기 콜백에서 Mutex의 한계와 데드락
Phase 2. 동시성 제어와 Strand
- C++ Strand | 락(Lock) 없는 동시성 제어 (#3): 콜백 직렬화,
make_strand와bind_executor활용 - C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]: 당장 실행할지, 큐 뒤로 보낼지 결정하는 스케줄링
Phase 3. 성능과 코루틴
- C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]: 커스텀 할당자로 핸들러/람다 할당 비용 줄이기
- C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]:
co_await,boost::asio::awaitable로 읽기 쉬운 비동기 코드 - C++ Asio Composed Operation | 비동기 함수 설계 [#7]: 헤더+바디 등 프로토콜 단위 비동기 함수 설계
관련 시리즈
시리즈와 연결되는 프로젝트 예
구현 세부는 각 편과 #29-1, #30에 있으며, 여기서는 구조만 스케치합니다.
멀티룸 채팅 서버는 async_accept 루프에서 연결마다 shared_ptr<session>을 만들고, 세션 상태는 #2·#3의 방식으로 보호합니다. 브로드캐스트는 룸 단위 Strand나 메시지 큐로 처리하되, 느린 클라이언트 때문에 async_write가 쌓이지 않도록 송신 큐 상한(백프레셔)을 둡니다. 프로토콜은 줄 단위 텍스트나 길이 프리픽스+바디 바이너리 중에서 고르며, 메시지 단위 읽기에는 #7 Composed Operation이 잘 맞습니다.
학습용 최소 HTTP/1.1 서버는 async_read_until로 헤더 끝(\r\n\r\n)까지 읽고, Content-Length가 있으면 고정 길이 바디를, 청크 인코딩이면 상태 기계로 바디를 읽습니다. Keep-Alive에서는 한 연결에 여러 요청이 오므로, async_read_until이 구분자 뒤까지 미리 읽어 둔 잔여 바이트를 다음 요청의 파서로 넘겨야 합니다. TLS는 asio::ssl로 직접 처리하거나 앞단 리버스 프록시(Nginx 등)에 맡깁니다.
핵심 코드 미리보기
아래는 개념 예시입니다. 실제 코드는 오류 처리, 로깅, 객체 수명 관리가 더 필요하며 각 편에서 자세히 다룹니다.
io_context와 work_guard
#include <boost/asio.hpp>
namespace net = boost::asio;
int main() {
net::io_context ioc;
auto guard = net::make_work_guard(ioc);
// 비동기 작업 등록 ...
// guard.reset(); // "일감이 더 없다"는 걸 알려 루프 종료 허용
ioc.run();
}
run()이 즉시 반환하는 흔한 이유는 대기 중인 비동기 작업이 하나도 없기 때문입니다. work_guard는 아직 작업을 등록하기 전이나 작업 사이의 공백에 루프가 끝나지 않게 막습니다(#1).
Strand로 핸들러 직렬화
auto strand = net::make_strand(ioc.get_executor());
net::post(strand, [] {
// 같은 strand를 거친 핸들러는 동시에 실행되지 않고,
// post한 순서대로 하나씩 실행됨
});
소켓이나 타이머의 완료 핸들러를 특정 Strand에서 실행하려면 소켓을 strand executor로 생성하거나 bind_executor로 핸들러를 감쌉니다(#3).
co_await (C++20 + Asio)
#include <boost/asio.hpp>
using tcp = boost::asio::ip::tcp;
namespace net = boost::asio;
net::awaitable<void> echo_once(tcp::socket s) {
char buf[1024];
// async_read_some: 도착한 만큼만 읽고 반환
std::size_t n = co_await s.async_read_some(
net::buffer(buf), net::use_awaitable);
co_await net::async_write(
s, net::buffer(buf, n), net::use_awaitable);
}
자유 함수 async_read는 버퍼(여기서는 1024바이트)를 가득 채울 때까지 완료되지 않으므로, 도착한 데이터를 바로 돌려보내는 에코에는 async_read_some이 맞습니다. 코루틴은 콜백 체인을 한 함수 흐름으로 바꿔 주지만, 어느 executor에서 재개되는지 이해하지 못하면 여전히 레이스가 남을 수 있습니다(#2, #6).
라이브러리 선택: Boost.Asio vs libuv
Boost.Asio와 standalone Asio는 executor와 완료 토큰(completion token) 모델을 기반으로 하며, HTTP·WebSocket용 Boost.Beast와 asio::ssl을 붙이기 쉽고 문서와 예제도 풍부합니다. libuv는 Node.js의 기반이 되는 C 이벤트 루프로, 크로스플랫폼이고 uv_* 핸들과 콜백 모델이 단순하지만 C++에서는 C API를 감싸 쓰게 됩니다. C++ 서버를 오래 유지보수할 계획이라면 Strand, 코루틴, SSL까지 같은 모델로 이어지는 Asio가 학습 비용에 비해 얻는 것이 많은 편입니다. 반대로 다른 런타임과 같은 이벤트 루프를 공유해야 하거나 C FFI 중심인 팀이라면 libuv가 자연스러울 수 있습니다. 결국 팀이 읽을 수 있는 코드, 빌드 환경, 기존 자산이 선택을 가장 크게 좌우합니다.
디버깅 도구: Wireshark, tcpdump
Wireshark는 필터, Follow TCP Stream, (키가 있을 때) TLS 복호화를 지원해 내 send가 실제로 언제 나갔는지, Nagle 알고리즘·윈도 크기·재전송이 어떻게 작동했는지 눈으로 확인하기 좋습니다. tcpdump는 SSH로 접속한 서버에서 화면 없이 pcap을 뜰 수 있어, 재현 가능한 요청을 고정한 뒤 tcpdump -i any -w out.pcap port 12345로 캡처하고 나중에 Wireshark로 여는 방식이 흔합니다. RTT, 재전송, zero window를 애플리케이션 로그의 타임스탬프와 맞춰 보면, Asio에서 “async_write가 끝나지 않는다”는 증상이 상대방의 수신 윈도가 0이어서인지, 우리 쪽 Strand·스케줄링 문제인지 가를 수 있습니다.
운영 패턴: 멀티스레드, 로드 밸런싱
멀티스레드 구성은 크게 두 가지입니다. 하나의 io_context에서 N개 스레드가 run()을 돌리면 멀티코어를 쓰면서 완료 핸들러를 여러 스레드에 나눠 실행할 수 있지만, 공유 상태 보호가 필요합니다(#2·#3). 반대로 연결을 스레드마다 하나씩 둔 서로 다른 io_context에 나누면(셔딩) 락 경합과 캐시 이동이 줄지만, 세션 사이의 메시지 전달은 별도 큐로 처리해야 합니다. 어느 쪽이든 CPU를 오래 쓰는 작업은 asio::thread_pool이나 별도 워커로 넘기고, 결과만 post로 I/O 스레드에 돌려보냅니다.
로드 밸런싱은 L4(LVS, Maglev 등)에서 연결 단위로 백엔드에 나누는 방식과, L7(HTTP 라우터, gRPC 프록시)에서 요청 단위로 라우팅·재시도·TLS 종료를 하는 방식이 있습니다. 상태를 가진 서비스는 일관성 해시로 같은 사용자를 같은 백엔드에 보내기도 합니다. HTTP API 서버는 보통 Nginx나 Envoy 뒤에 두고, 실시간 게임 서버는 UDP 기반 자체 분배를 쓰는 경우도 있습니다.
flowchart LR C[Client] --> L["Reverse proxy (TLS/L7)"] L --> S["C++ + Asio"] S --> M["DB / Cache / Queue"] S --> O[Internal APIs]
이 구조에서는 TLS와 HTTP/2·HTTP/3를 프록시가 맡는 경우가 많고, C++ 서비스는 커스텀 프로토콜이나 지연에 민감한 경로를 담당합니다. 시리즈의 Strand는 한 프로세스 안의 직렬화 도구이므로, 여러 프로세스에 걸친 일관성은 로드 밸런서와 메시지 큐 설계로 따로 풀어야 합니다.
문제 해결의 출발점
run()이 바로 끝나면 대기 중인 비동기 작업이 없거나 work_guard가 빠진 경우이므로 #1을 기준으로 확인합니다. async_write가 멈춘 것처럼 보이면 패킷 캡처로 상대 수신 윈도가 0인지 먼저 보고, 아니라면 Strand나 스케줄링 교착을 #3·#4와 연결해 의심합니다. 메모리가 계속 늘면 살아 있는 핸들러·타이머가 세션 객체를 붙잡고 있는지, 송신 큐가 무한히 쌓이는지 확인하고 #5를 함께 봅니다.
보안·신뢰성: 구현 직전에 읽는 메모
네트워크 코드는 기능이 맞아도 악의적 입력과 자연스러운 네트워크 실패(타임아웃, 반쪽 연결, 재전송)에 무너질 수 있습니다. 헤더·바디·프레임마다 최대 길이를 정해, 상대가 보내는 대로 끝없이 읽어 메모리를 소진하거나 slowloris 같은 공격에 연결을 붙잡히지 않게 해야 합니다. 공개 HTTP 서비스에서 TLS를 프록시에서 끊는다면, 백엔드가 X-Forwarded-For 같은 헤더를 어디까지 신뢰할지 문서로 정해 둡니다. 외부 API 호출이나 DB 커밋이 섞인 요청은 클라이언트 재시도로 두 번 올 수 있으므로 멱등하게 설계하고, 접속·요청 단위 rate limit은 asio::steady_timer 기반의 간단한 구현이나 L7 프록시 기능으로 겁니다.
TCP vs UDP
TCP는 OS가 재전송과 순서 정렬을 맡는 바이트 스트림이므로, 응용에서 메시지 경계(프레이밍)를 직접 만들어야 합니다. 혼잡 제어와 Nagle 알고리즘 때문에 지연이 들쭉날쭉할 수 있고, Asio에서는 tcp::socket과 async_read, #7의 Composed Operation으로 이어집니다. UDP는 데이터그램 하나 단위의 전달만 보장하므로 손실과 순서 뒤바뀜을 응용이 감수하거나 복구해야 합니다. 지연이 낮아 보일 수 있지만 그 대가로 손실 처리 비용을 응용이 떠안습니다. Asio에서는 udp::socket과 async_receive_from을 쓰고, 멀티캐스트·브로드캐스트는 별도 소켓 옵션이 필요합니다. 게임, 실시간 음성, 일부 측정 데이터는 UDP나 QUIC으로, 대부분의 API와 웹 서비스는 TCP/TLS 위의 HTTP로 갑니다.
성능 측정: 무엇을 숫자로 둘 것인가
지연은 같은 부하에서 p50·p95·p99를 함께 보고, 꼬리 지연이 할당(#5)에서 오는지 커널 큐에서 오는지 구분합니다. 처리량은 HTTP라면 RPS, 채팅이라면 동시 연결 수와 초당 메시지 수로 보고, 프로파일러와 iftop/nload 같은 도구로 CPU 한계인지 네트워크 한계인지 가립니다. 에러율에는 타임아웃, 연결 리셋, half-open, 클라이언트 재시도로 인한 중복을 포함하고, 상관 ID로 한 요청을 끝까지 추적합니다. 릴리스마다 같은 시나리오(wrk, h2load 등)를 돌려 이전 결과와 비교하면 회귀를 일찍 잡을 수 있습니다. 할당 최적화 같은 작업은 프로파일로 병목을 확인한 뒤에 하는 것이 순서입니다.
참고 RFC와 문서
- TCP: RFC 9293 (RFC 793을 대체)
- HTTP/1.1: RFC 9112, 메시지 형식·청크·연결 관리
- WebSocket: RFC 6455
- TLS 1.3: RFC 8446
- Boost.Asio 문서, standalone Asio, Boost.Beast
RFC는 처음부터 정독하기보다, 이상한 현상을 만났을 때 해당 개념이 정의된 절을 찾아보는 용도로 쓰는 경우가 많습니다.