본문으로 건너뛰기
Previous
Next
C++ Asio Composed Operation | 비동기 함수 설계 [#7]

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

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_initiateasync_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. 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++ 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++, Boost.Asio, Composed Operation, 비동기, async_initiate, 고성능네트워크 등으로 검색하시면 이 글이 도움이 됩니다.