C++ Asio Composed Operation | 비동기 함수 설계 [#7]

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_initiateasync_compose (또는 수동 초기화)로 구현하는 흐름
  • 실전: async_read_packet (헤더 4바이트 + 바디) 예시

목차

  1. Composed Operation이란
  2. 설계 목표: async_read_packet
  3. 구현 흐름: 상태 머신과 연쇄 호출
  4. async_initiate와 완료 토큰
  5. 정리

실무에서 겪은 문제

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

문제 상황과 해결

대규모 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. 1단계: async_read로 정확히 4바이트(헤더)를 읽는다. 완료 핸들러에서:
    • 에러면 최종 완료 콜백/awaitable에 에러 전달.
    • 성공이면 헤더에서 바디 길이를 파싱하고, 2단계로 진행.
  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. #1 — io_context, run/poll, Proactor, work_guard
  2. #2 — 멀티스레드 Asio, Data Race, Mutex 한계
  3. #3 — Strand, make_strand, bind_executor
  4. #4 — post, dispatch, defer
  5. #5 — 핸들러 메모리, 커스텀 할당자
  6. #6 — C++20 코루틴, awaitable
  7. #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]