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) 가 자신을 비동기 연산의 완료 핸들러로 넘기면서, 완료 시 “다음 단계를 시작”하거나 “최종 완료를 호출”하는 식으로 작성합니다.
의사 코드
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 Composed Operation | 비동기 함수 설계 [#7]」입니다. 여기서는 앞선 설명을 구현·런타임 관점에서 한 번 더 압축합니다. 시스템·런타임 경계(스케줄링, I/O, 메모리, 동시성)를 기준으로 생각하면, “입력이 어디서 검증되고, 핵심 연산이 어디서 일어나며, 부작용(I/O·네트워크·디스크)이 어디서 터지는가”가 한눈에 드러납니다.
처리 파이프라인(개념도)
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
알고리즘·프로토콜 관점에서의 체크포인트
- 불변 조건(Invariant): 각 단계가 만족해야 하는 조건(예: 버퍼 경계, 프로토콜 상태, 트랜잭션 격리)을 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 동일 입력에 동일 출력이 보장되는 순수한 층과, 시간·네트워크에 의해 달라질 수 있는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화/역직렬화, 문자 인코딩, syscall 횟수, 락 경합처럼 “한 번의 호출이 아니라 누적되는 비용”을 의심 목록에 넣습니다.
프로덕션 운영 패턴
실서비스에서는 기능 구현과 함께 관측·배포·보안·비용이 동시에 요구됩니다. 아래는 팀에서 자주 쓰는 최소 체크리스트입니다.
| 영역 | 운영 관점에서의 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율/지연 분위수, 주요 의존성 타임아웃이 보이는가 |
| 안전성 | 입력 검증·권한·비밀 관리가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등한 연산에만 적용되는가, 서킷 브레이커·백오프가 있는가 |
| 성능 | 캐시 계층·배치 크기·풀링·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리, 마이그레이션 호환성이 문서화되어 있는가 |
운영 환경에서는 “개발자 PC에서는 재현되지 않던 문제”가 시간·부하·데이터 크기 때문에 드러납니다. 따라서 스테이징의 데이터 양·네트워크 지연을 가능한 한 현실에 가깝게 맞추는 것이 중요합니다.
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스 컨디션, 타임아웃, 외부 의존성 불안정 | 최소 재현 스크립트 작성, 분산 트레이스·로그 상관관계 확인 |
| 성능 저하 | N+1 쿼리, 동기 I/O, 잠금 경합, 과도한 직렬화 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 클로저/이벤트 구독 누수, 대용량 객체의 불필요한 복사 | 상한·TTL·스냅샷 비교(힙 덤프/트레이스) |
| 빌드·배포만 실패 | 환경 변수·권한·플랫폼 차이 | CI 로그와 로컬 diff, 컨테이너/런타임 버전 핀(pin) |
권장 디버깅 순서: (1) 최소 재현 만들기 (2) 최근 변경 범위 좁히기 (3) 의존성·환경 변수 차이 확인 (4) 관측 데이터로 가설 검증 (5) 수정 후 회귀·부하 테스트.
관련 글
- C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
- C++ Strand | 락(Lock) 없는 동시성 제어 [#3]
- C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]
- C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]
- C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]
이 글에서 다루는 키워드 (관련 검색어)
C++, Boost.Asio, Composed Operation, 비동기, async_initiate, 고성능네트워크 등으로 검색하시면 이 글이 도움이 됩니다.