C++ Asio Composed Operation | 비동기 함수 설계 [#7]
이 글의 핵심
C++ Asio Composed Operation에 대한 실전 가이드입니다. 비동기 함수 설계 [#7] 등을 예제와 함께 설명합니다.
들어가며: “헤더 읽고 → 바디 읽고”를 한 번에
왜 Composed Operation인가
실제 프로토콜은 “먼저 N바이트 헤더를 읽고, 그 다음 헤더에 적힌 길이만큼 바디를 읽는다” 같은 여러 단계의 비동기 I/O로 이루어집니다. 이를 매번 콜백을 중첩해 작성하면 반복되고 지저분해집니다. Composed Operation은 이런 여러 개의 비동기 연산을 하나의 비동기 연산처럼 묶어서, async_read_header_then_body 같은 “나만의 비동기 함수”로 만드는 Asio의 설계 패턴입니다.
이렇게 만들면 호출 측에서는 한 번의 비동기 호출로 “헤더+바디 읽기 완료”를 기다릴 수 있고, 코루틴이면 한 번의 co_await로 처리할 수 있습니다.
언제 쓰면 좋을까요? Echo나 단순 라인 프로토콜은 async_read_until만으로 충분합니다. 고정 헤더 + 가변 바디, 프레임 단위 읽기처럼 “여러 번의 async_read를 한 단위로 묶고 싶을 때” Composed Operation을 만들면, 프로토콜 계층이 깔끔해지고 #6의 코루틴과도 co_await async_read_packet(…) 한 줄로 맞물리게 할 수 있습니다.
목표:
- Composed Operation의 개념 — 여러 비동기 단계를 하나로 묶기
- async_initiate와 async_compose (또는 수동 초기화)로 구현하는 흐름
- 실전: async_read_packet (헤더 4바이트 + 바디) 예시
목차
실무에서 겪은 문제
실제 프로젝트에서 이 개념을 적용하며 겪었던 경험을 공유합니다.
문제 상황과 해결
대규모 C++ 프로젝트를 진행하며 이 패턴/기법의 중요성을 체감했습니다. 책에서 배운 이론과 실제 코드는 많이 달랐습니다.
실전 경험:
- 문제: 처음에는 이 개념을 제대로 이해하지 못해 비효율적인 코드를 작성했습니다
- 해결: 코드 리뷰와 프로파일링을 통해 문제를 발견하고 개선했습니다
- 교훈: 이론만으로는 부족하고, 실제로 부딪혀보며 배워야 합니다
이 글이 여러분의 시행착오를 줄여주길 바랍니다.
1. Composed Operation이란
개념
- 단일 비동기 연산: async_read_some, async_write처럼 “한 번 시작하면 한 번의 완료 콜백”으로 끝나는 연산.
- Composed Operation: 내부적으로 여러 번의 비동기 연산을 순서대로 시작하고, 각 완료 시 다음 단계를 시작하는 상태 머신을 구현한 비동기 연산. 외부에서는 “하나의 비동기 연산”처럼 보입니다.
예: async_read_until은 내부적으로 “버퍼에 구분자가 올 때까지 async_read_some을 반복”하는 Composed Operation입니다. 우리도 async_read_packet처럼 “헤더 읽기 → 바디 읽기”를 한 번에 하는 연산을 만들 수 있습니다.
2. 설계 목표: async_read_packet
시그니처 (개념)
- async_read_packet(socket, header_buf, body_buf, token)
- 헤더: 고정 4바이트 (바디 길이).
- 바디: 헤더에 적힌 길이만큼 읽기.
- 완료 시: 에러 코드와 읽은 바이트 수(또는 헤더+바디 성공 여부). token이 콜백이면 (error_code, size_t) 형태의 완료 핸들러, use_awaitable이면 awaitable<std::size_t> 등으로 완료를 전달.
3. 구현 흐름: 상태 머신과 연쇄 호출
단계
- 1단계: async_read로 정확히 4바이트(헤더)를 읽는다. 완료 핸들러에서:
- 에러면 최종 완료 콜백/awaitable에 에러 전달.
- 성공이면 헤더에서 바디 길이를 파싱하고, 2단계로 진행.
- 2단계: async_read로 바디 길이만큼 읽는다. 완료 핸들러에서:
- 에러/성공을 최종 완료에 전달.
이 “1단계 → 2단계”를 한 번의 비동기 시작으로 감싸는 래퍼가 Composed Operation입니다. 구현 시에는 작업 객체(operation state) 가 자신을 비동기 연산의 완료 핸들러로 넘기면서, 완료 시 “다음 단계를 시작”하거나 “최종 완료를 호출”하는 식으로 작성합니다.
의사 코드
struct read_packet_op {
void start() {
async_read(socket, buffer(header), [this](ec, n) {
if (ec) { complete(ec, 0); return; }
size_t body_len = parse_header(header);
async_read(socket, buffer(body, body_len), [this](ec, n) {
complete(ec, 4 + n);
});
});
}
void complete(error_code ec, size_t total) {
// token에 따라 콜백 호출 또는 awaitable 재개
}
};
실제로는 완료 토큰(token) 에 따라 “콜백 호출” vs “awaitable 재개”를 async_initiate 등으로 통일해 처리합니다.
4. async_initiate와 완료 토큰
Asio의 완료 토큰
- 콜백:
void(error_code, size_t)형태의 핸들러. - use_awaitable: 코루틴에서 co_await할 때 쓰는 토큰. 완료 시 awaitable이 재개되도록 함.
async_initiate는 “어떤 토큰이든 받아서, 해당 토큰에 맞게 비동기 연산을 시작하고, 완료 시 그 토큰에 맞게 결과를 전달”하도록 도와줍니다. Composed Operation의 시작 함수에서 async_initiate를 호출하고, 내부에서 1단계 비동기를 시작할 때 “완료 시 이 작업 객체의 다음 단계를 호출”하는 식으로 바인딩하면, 콜백/awaitable 둘 다 지원하는 async_read_packet을 만들 수 있습니다.
문서 참고
구체적인 async_initiate 서명과 연산 상태 라이프타임 관리(작업 객체가 비동기 연산 완료 전까지 살아 있어야 함)는 Boost.Asio 문서 - Composed Operations와 예제를 참고하는 것이 좋습니다. C++20에서는 async_compose 템플릿으로 연산 상태를 감싸는 방식도 있습니다.
5. 정리
- Composed Operation은 여러 비동기 단계(헤더 읽기 → 바디 읽기)를 하나의 비동기 연산처럼 묶는 패턴.
- 내부는 상태 머신: 1단계 완료 핸들러에서 2단계를 시작하고, 최종 단계에서 완료 토큰(콜백 또는 awaitable)에 결과를 전달.
- async_initiate와 완료 토큰을 사용하면 콜백과 co_await 둘 다 지원하는 나만의 async_read_packet 같은 API를 우아하게 설계할 수 있습니다.
이렇게 만든 비동기 프로토콜 함수는 Echo나 채팅이 아닌 “헤더+바디” 프로토콜을 다루는 고성능 서버의 기본 단위가 됩니다.
보강: Composed Operation 실전 예제 — HTTP 스타일 헤더 + 바디
실제 HTTP는 더 복잡하지만, “먼저 고정 헤더(또는 헤더 블록)를 읽고, 그다음 Content-Length만큼 바디를 읽는다”는 흐름은 아래와 같이 모델링할 수 있습니다.
프로토콜 가정
- 4바이트 빅엔디안 길이 필드(바디 바이트 수).
- 그 다음 바디를 정확히 그 길이만큼 읽는다.
콜백 스타일 연쇄 (핵심만)
void read_length_then_body(
boost::asio::ip::tcp::socket& socket,
std::array<std::byte, 4>& len_buf,
std::vector<std::byte>& body_buf,
std::function<void(boost::system::error_code, std::size_t)> done)
{
boost::asio::async_read(socket, boost::asio::buffer(len_buf),
[&socket, &len_buf, &body_buf, done = std::move(done)]
(const boost::system::error_code& ec, std::size_t) {
if (ec) { done(ec, 0); return; }
std::uint32_t n = 0;
for (int i = 0; i < 4; ++i)
n = (n << 8) | static_cast<unsigned char>(len_buf[static_cast<std::size_t>(i)]);
if (n > 64 * 1024 * 1024) { // 예: 상한으로 DoS 완화
done(boost::asio::error::message_size, 0);
return;
}
body_buf.resize(n);
boost::asio::async_read(socket, boost::asio::buffer(body_buf),
[done = std::move(done)](const boost::system::error_code& ec2, std::size_t m) {
done(ec2, m);
});
});
}
코루틴과 결합
throw std::system_error를 쓰려면 <system_error>를 포함합니다.
boost::asio::awaitable<std::vector<std::byte>> read_packet(boost::asio::ip::tcp::socket& socket) {
std::array<std::byte, 4> len_buf{};
co_await boost::asio::async_read(socket, boost::asio::buffer(len_buf), boost::asio::use_awaitable);
std::uint32_t n = 0;
for (int i = 0; i < 4; ++i)
n = (n << 8u) | static_cast<unsigned char>(len_buf[static_cast<std::size_t>(i)]);
const std::size_t max_body = 64 * 1024 * 1024;
if (n > max_body)
throw std::system_error(std::make_error_code(std::errc::message_too_long));
std::vector<std::byte> body(n);
co_await boost::asio::async_read(socket, boost::asio::buffer(body), boost::asio::use_awaitable);
co_return body;
}
위 두 단계를 **하나의 async_read_packet**으로 묶으면, 호출부는 read_packet(socket, token) 한 번으로 끝나고, 내부 상태 머신·에러 전달은 Composed Operation으로 캡슐화할 수 있습니다.
보안·운영 체크
- 최대 바디 길이를 반드시 제한합니다(메모리 고갈 방지).
- 부분 헤더·부분 바디는
async_read가 “정확히 N바이트”를 채워 줄 때까지 반복하거나, 한 번의 Composed Operation 안에서 처리합니다.
보강: 디버깅 팁
- 단계별로 에러 코드를 로그에 남기고, 어느 단계에서 끊겼는지(헤더 / 바디)를 구분합니다.
- 타임아웃은 별도
steady_timer를 같은 Strand에 묶어, 헤더만 오고 바디가 안 오는 경우를 처리합니다.
보강: 성능 측정 방법
- 동일 크기 패킷을 초당 N개 전송할 때, Composed 전후로 처리량·CPU·할당 횟수를 비교합니다.
- 한 단계짜리
async_read_some루프와 비교해 프레임 경계가 맞는지 검증한 뒤, 최적화는 프로파일 기준으로 합니다.
보강: 흔한 실수와 해결책
| 실수 | 해결 |
|---|---|
async_read_some만으로 “헤더 4바이트”를 기대 | 버퍼에 쪼개 들어옴 → 정확히 4바이트는 async_read. |
| 길이 필드를 신뢰만 하고 상한 없음 | DoS·OOM → 최대 길이·연결당 버퍼 제한. |
| Composed 내부에서 소켓 생명주기 끊김 | shared_ptr로 세션 유지 또는 취소 토큰 사용. |
자주 묻는 질문
Q. Composed Operation은 언제 쓰는 게 좋나요?
A. Echo나 단순 라인 프로토콜은 async_read_until만으로 충분합니다. 고정 헤더 + 가변 바디, 프레임 단위 읽기처럼 여러 번의 async_read를 한 단위로 묶고 싶을 때 Composed Operation을 만들면, 호출부가 co_await async_read_packet(...) 한 줄로 정리됩니다.
Q. async_initiate 없이 콜백만 써도 되나요?
A. 콜백만 쓴다면 수동으로 연쇄 호출을 구현해도 됩니다. async_initiate와 완료 토큰을 쓰면 콜백과 use_awaitable(코루틴) 둘 다 같은 API로 지원할 수 있어, 나중에 코루틴으로 옮길 때 호출부를 바꿀 필요가 없습니다.
시리즈 마무리
C++ 고성능 네트워크 가이드 시리즈는 여기까지입니다.
- #1 — io_context, run/poll, Proactor, work_guard
- #2 — 멀티스레드 Asio, Data Race, Mutex 한계
- #3 — Strand, make_strand, bind_executor
- #4 — post, dispatch, defer
- #5 — 핸들러 메모리, 커스텀 할당자
- #6 — C++20 코루틴, awaitable
- #7 — Composed Operation
더 깊이 보고 싶다면 C++ 실전 가이드 #29: Asio와 #30: WebSocket·프로토콜을 이어서 읽어 보시면 좋습니다.
아키텍처 다이어그램
graph TD
A[시작] --> B{조건 확인}
B -->|예| C[처리 1]
B -->|아니오| D[처리 2]
C --> E[완료]
D --> E
설명: 위 다이어그램은 전체 흐름을 보여줍니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]
- C++ Asio 데드락 디버깅 | 비동기 콜백 실전 [#49-3]
- C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴
관련 글
- C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
- C++ Strand | 락(Lock) 없는 동시성 제어 [#3]
- C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]
- C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]
- C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]