[2026] C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]
이 글의 핵심
C++20 코루틴과 Asio: 콜백 지옥 탈출 [#6]. 콜백에서 코루틴으로·실무에서 겪은 문제.
들어가며: 콜백에서 코루틴으로
비동기 코드의 가독성 한계
Asio의 async_read / async_write를 콜백으로 이어 붙이면 “읽기 완료 → 처리 → 쓰기 시작 → 쓰기 완료 → 다시 읽기 시작 → …”이 중첩 람다로 깊어집니다. 에러 처리와 타이밍을 넣을수록 콜백 지옥이 됩니다. C++20 코루틴과 Asio의 awaitable을 쓰면, 같은 비동기 흐름을 동기 코드처럼 한 줄씩 co_await로 쓸 수 있습니다. “읽기 완료될 때까지 대기 → 처리 → 쓰기 완료될 때까지 대기”가 그대로 읽히므로, 유지보수와 디버깅이 훨씬 수월해집니다. 처음 코루틴을 접할 때 “co_await 한 번에 스레드가 블로킹되나?”라고 생각할 수 있습니다. 블로킹되지 않습니다. co_await 시 그 코루틴만 일시 정지하고, io_context는 다른 핸들러를 계속 실행합니다. 완료되면 해당 코루틴이 재개되므로, 논블로킹 이벤트 루프 모델은 그대로 유지됩니다. C++20 코루틴과 Asio를 함께 쓰려면 컴파일러가 C++20을 지원해야 하며, MSVC/GCC/Clang 최신 버전을 사용하면 됩니다. 목표:
- boost::asio::awaitable과 co_await 기본 사용
- async_read, async_write를 awaitable로 감싸서 사용
- 에러 처리와 실행 맥락(executor) 지정
- 실전: Echo 서버를 코루틴 스타일로 작성
목차
- awaitable이란
- co_await로 비동기 대기
- 에러 처리와 executor
- 실전: 코루틴 Echo 서버
- 정리
- 심화: 코루틴 상태 머신
- 심화: awaitable 구현 원리
- 심화: 실전 에러 처리
- 심화: 성능 비교
1. awaitable이란
Asio의 awaitable 타입
boost::asio::awaitable
- async_read를 awaitable 버전으로 호출하면, co_await한 순간 그 코루틴은 일시 정지하고, 읽기가 완료되면 재개되며 결과(바이트 수 등)를 받습니다.
- 코루틴은 스택이 아닌 힙/프레임에 상태가 저장되므로, “대기 중”에 다른 핸들러가 실행될 수 있어 논블로킹이 유지됩니다.
기본 형태
아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#include <boost/asio.hpp>
#include <boost/asio/use_awaitable.hpp>
boost::asio::awaitable<void> session(boost::asio::ip::tcp::socket socket) {
std::array<char, 1024> buf;
for (;;) {
std::size_t n = co_await socket.async_read_some(
boost::asio::buffer(buf),
boost::asio::use_awaitable);
co_await boost::asio::async_write(socket,
boost::asio::buffer(buf.data(), n),
boost::asio::use_awaitable);
}
}
- use_awaitable은 “이 비동기 연산을 awaitable로 바꿔 달라”는 토큰입니다.
- co_await 시 해당 연산이 완료될 때까지 코루틴이 일시 정지하고, 완료 후 결과를 받아 다음 줄로 진행합니다.
2. co_await로 비동기 대기
async_read_some / async_write
- async_read_some(…, use_awaitable) → 완료 시 읽은 바이트 수를 반환.
- async_write(…, use_awaitable) → 완료 시 쓴 바이트 수를 반환 (에러면 예외). 에러가 나면 use_awaitable은 기본적으로 boost::system::system_error를 던지므로, try/catch로 처리할 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
try {
std::size_t n = co_await socket.async_read_some(
boost::asio::buffer(buf), boost::asio::use_awaitable);
// ...
} catch (const boost::system::system_error& e) {
if (e.code() != boost::asio::error::eof)
std::cerr << "read error: " << e.what() << "\n";
co_return;
}
3. 에러 처리와 executor
executor 바인딩
코루틴이 어느 executor(예: strand) 에서 실행될지는, 해당 코루틴을 어디서 시작하느냐에 달려 있습니다. co_spawn(io, session(std::move(socket)), boost::asio::detached) 처럼 co_spawn에 executor를 넘기면, 그 executor에서 코루틴이 실행됩니다.
auto ex = boost::asio::make_strand(io.get_executor());
boost::asio::co_spawn(ex, session(std::move(socket)), boost::asio::detached);
- session의 co_await들은 모두 ex(strand) 위에서 재개되므로, Strand의 순서 보장을 그대로 받을 수 있습니다.
use_awaitable에 할당자 넘기기
use_awaitable(allocator) 로 핸들러/코루틴 프레임에 쓸 커스텀 할당자를 지정할 수 있습니다. (이전 글의 핸들러 메모리 최적화와 연계 가능)
4. 실전: 코루틴 Echo 서버
accept 루프와 세션
아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
boost::asio::awaitable<void> listener(boost::asio::ip::tcp::acceptor& acceptor) {
for (;;) {
auto socket = co_await acceptor.async_accept(boost::asio::use_awaitable);
auto ex = socket.get_executor();
boost::asio::co_spawn(ex, session(std::move(socket)), boost::asio::detached);
}
}
int main() {
boost::asio::io_context io;
boost::asio::ip::tcp::acceptor acceptor(io, { boost::asio::ip::tcp::v4(), 8080 });
boost::asio::co_spawn(io, listener(acceptor), boost::asio::detached);
io.run();
}
- async_accept(use_awaitable) 로 새 연결을 co_await로 받고,
- 각 연결마다 session 코루틴을 co_spawn으로 띄웁니다.
- session 안에서는 async_read_some → async_write를 co_await로 반복하면, 콜백 중첩 없이 Echo 로직이 직선으로 읽힙니다.
5. 정리
- awaitable과 co_await로 Asio 비동기 연산을 “동기처럼” 한 줄씩 작성할 수 있음.
- use_awaitable을 비동기 연산에 넘기면 해당 연산이 완료될 때까지 코루틴이 일시 정지하고, 완료 후 재개됨.
- co_spawn으로 코루틴을 시작하고, executor를 지정하면 Strand 등과 결합 가능.
- 콜백 지옥을 피하고 가독성과 유지보수성을 크게 높일 수 있는 최신 기법입니다.
보강: 실전 코드 예제 확장
에코 루프에 상한·로깅을 넣은 형태입니다. co_await 사이에 동기 코드가 있어도, 실행 스레드는 완료마다 달라질 수 있으므로 세션 상태는 Strand 안에서만 건드리는 것이 안전합니다.
다음은 cpp를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
boost::asio::awaitable<void> session(boost::asio::ip::tcp::socket socket) {
std::array<char, 4096> buf{};
std::size_t total = 0;
const std::size_t max_echo = 1 << 20;
try {
for (;;) {
std::size_t n = co_await socket.async_read_some(
boost::asio::buffer(buf), boost::asio::use_awaitable);
total += n;
if (total > max_echo) co_return;
co_await boost::asio::async_write(socket,
boost::asio::buffer(buf.data(), n), boost::asio::use_awaitable);
}
} catch (const boost::system::system_error& e) {
if (e.code() != boost::asio::error::eof)
std::cerr << e.what() << '\n';
}
}
보강: co_await 동작 원리 (요약)
co_await expr를 만나면 컴파일러는 awaitable에 대해await_ready→ 필요 시 코루틴 프레임에 상태 저장 후 일시 정지 → I/O 완료 시await_resume로 결과를 받는 흐름을 생성합니다.- Asio의
async_*(..., use_awaitable)는 완료 시 현재 코루틴을 executor에 다시 스케줄해, 논블로킹으로 다음 줄을 실행합니다. - 따라서 스레드가 소켓에서 막히지 않고,
io_context는 다른 핸들러·다른 코루틴을 계속 진행합니다. 디버깅 시 유의: “한 함수 안의 다음 줄”은 같은 OS 스레드라는 뜻이 아니라, 같은 논리 흐름이 이어진다는 뜻에 가깝습니다(executor 정책에 따름).
심화: 코루틴 상태 머신 (개념도)
co_await를 만나면 컴파일러는 코루틴 프레임(힙 또는 커스텀 할당)에 지역 변수·일시 값·재개 지점을 저장합니다. Asio는 완료 시 executor에 “이 코루틴을 재개하는 클로저”를 넣습니다.
아래 코드는 mermaid를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 실행 예제
stateDiagram-v2
[*] --> Running
Running --> Suspended: co_await (I/O 대기)
Suspended --> Scheduled: 완료 콜백이 executor에 enqueue
Scheduled --> Running: 재개 (다음 줄)
Running --> Done: co_return 또는 예외
Done --> [*]
중요: Suspended 동안 스택 프레임이 유지되는 것이 아니라, 프레임이 힙(또는 커스텀 할당)에 있습니다. 그래서 재개 시 스레드가 바뀔 수 있고, 이것이 “콜백과 동일하게 공유 상태에 주의”라는 이유입니다.
심화: awaitable 구현 원리 (요약)
표준 코루틴에서 co_await는 대기 대상이 await_ready / await_suspend / await_resume를 제공하는지 확인합니다. Boost.Asio의 async_*(..., use_awaitable)는 내부적으로 비동기 연산의 완료 시점에 현재 코루틴 핸들을 다시 스케줄하도록 연결합니다.
- await_ready: 이미 완료면 동기적으로 진행.
- await_suspend: 핸들러에 코루틴 핸들을 넘겨, 완료 시
resume되게 함. - await_resume: 결과(
error_code, 전송 바이트 등)를 반환하거나 예외를 던짐. 실무에서의 함의: 코루틴 함수는 일반 함수처럼 스택만 보면 안 되고,co_await마다 끊길 수 있는 지점으로 봐야 합니다. 뮤텍스를 잡은 채co_await하면 데드락·레이스로 이어지기 쉽습니다.
심화: 실전 에러 처리 패턴
1) 예외 기반 (use_awaitable)
다음은 cpp를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
boost::asio::awaitable<void> session(boost::asio::ip::tcp::socket s) {
std::array<std::byte, 2048> buf{};
try {
for (;;) {
std::size_t n = co_await s.async_read_some(
boost::asio::buffer(buf), boost::asio::use_awaitable);
co_await boost::asio::async_write(s,
boost::asio::buffer(buf.data(), n), boost::asio::use_awaitable);
}
} catch (const boost::system::system_error& e) {
if (e.code() != boost::asio::error::eof)
std::cerr << "session error: " << e.what() << '\n';
}
co_return;
}
2) error_code 기반 (프로젝트 규칙이 “예외 금지”일 때)
프로젝트·Asio 버전에 따라 use_awaitable_t + redirect_error 또는 콜백 스타일의 error_code 오버로드를 코루틴 밖에서 래핑합니다. 핵심은 성공/실패를 한 레이어에서 정책화하는 것입니다.
3) 취소·타임아웃
steady_timer를 같은 executor에서 co_await하고, 소켓 닫기·작업 취소를 한 경로로 모읍니다. 타임아웃과 정상 완료가 경쟁하면 연결 상태 머신(열림/닫힘/드레인)을 명시하는 편이 안전합니다.
심화: 성능 비교 (콜백 vs 코루틴, 체크리스트)
| 항목 | 콜백 | 코루틴 |
|---|---|---|
| 코드 크기/분기 | 상태를 수동으로 유지 | 컴파일러가 프레임에 저장 |
| 할당 | 핸들러 객체만 | 핸들러 + 코루틴 프레임 |
| 인라인 가능성 | 작은 람다에 유리할 수 있음 | 컴파일러·Asio 버전에 의존 |
| 디버깅 | 콜백 체인 추적이 번거로움 | 단일 함수에 브레이크포인트 가능 |
측정 제안: 동일 RPS에서 instructions per request와 p99를 함께 보고, 차이가 크면 프로파일에서 coroutine/await 관련 심볼과 할당 비중을 확인합니다. 종종 가독성을 위해 코루틴을 쓰고, 핫패스만 콜백으로 내리는 하이브리드가 현실적인 타협입니다. |
보강: awaitable vs 콜백 비교
| 항목 | 콜백 | awaitable + co_await |
|---|---|---|
| 제어 흐름 | 중첩·상태 플래그로 분기 | 위에서 아래로 읽히는 순차 코드 |
| 에러 처리 | 각 콜백마다 ec 전파 | try/catch로 한 블록에 모을 수 있음 |
| 스택 | 콜백 깊이만큼 논리적 복잡도 | 코루틴 프레임(힙) — 깊이에 덜 민감 |
| 오버헤드 | 핸들러 객체만 | 코루틴 프레임 + 핸들러(최적화 여부는 컴파일러/Asio 버전 의존) |
| 디버깅 | 브레이크포인트가 여러 콜백에 분산 | 한 코루틴 함수에 브레이크포인트 가능 |
| 선택: 지연에 극도로 민감한 핫패스는 프로파일 후 결정하고, 대부분의 네트워크 서비스 로직은 가독성·정확성 때문에 코루틴이 유리한 경우가 많습니다. |
보강: 디버깅 팁
- 단일 스레드
io.run()으로 먼저 동작을 검증한 뒤, 멀티 스레드·Strand를 올리면 원인 분리가 쉽습니다. - TSan 빌드로 세션 공유 데이터가 코루틴과 콜백 경로로 새어 나갔는지 확인합니다.
보강: 성능 측정 방법
- 동일 부하로 콜백 구현 vs 코루틴 구현의 RPS·p99를 비교합니다. 차이가 크면 프로파일러로 코루틴 프레임 할당·await 준비 비용을 확인합니다.
보강: 흔한 실수와 해결책
| 실수 | 해결 |
|---|---|
co_await 없이 코루틴 함수만 호출 | 비동기 연산이 시작되지 않음 → 반드시 co_spawn 등으로 실행. |
코루틴 안에서 블로킹 read/sleep | 이벤트 루프를 막음 → Asio 비동기 API만 사용. |
| 여러 연결이 같은 코루틴 로컬 상태를 공유 | 데이터 레이스 → 연결별 객체·Strand로 분리. |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. co_await와 boost::asio::awaitable을 이용해, 스파게티처럼 꼬인 비동기 콜백 코드를 동기 코드처럼 우아하고 가독성 좋게 작성하는 최신 기법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다. 다음 글: [C++ 고성능 네트워크 가이드 #7] 실전 아키텍처: Composed Operation으로 나만의 비동기 함수 만들기
아키텍처 다이어그램
아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
graph TD
A[시작] --> B{조건 확인}
B -->|예| C[처리 1]
B -->|아니오| D[처리 2]
C --> E[완료]
D --> E
설명: 위 다이어그램은 전체 흐름을 보여줍니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++ Asio Composed Operation | 비동기 함수 설계 [#7]
- C++20 Coroutine | co_await·co_yield로 “콜백 지옥” 탈출하기