[2026] C++ Asio Composed Operation | 비동기 함수 설계 [#7]
이 글의 핵심
C++ Asio Composed Operation: 비동기 함수 설계 [#7]. 실무에서 겪은 문제·Composed Operation이란.
들어가며: “헤더 읽고 → 바디 읽고”를 한 번에
왜 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바이트 + 바디) 예시
목차
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) 가 자신을 비동기 연산의 완료 핸들러로 넘기면서, 완료 시 “다음 단계를 시작”하거나 “최종 완료를 호출”하는 식으로 작성합니다.
의사 코드
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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바이트 빅엔디안 길이 필드(바디 바이트 수).
- 그 다음 바디를 정확히 그 길이만큼 읽는다.
콜백 스타일 연쇄 (핵심만)
다음은 cpp를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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>를 포함합니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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·프로토콜을 이어서 읽어 보시면 좋습니다.
아키텍처 다이어그램
아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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·이벤트 루프·코루틴