본문 바로가기
잡(job)기술

I/O 기반 코루틴: 블로킹을 피하는 우아한 방법

by 무니이구나 2025. 9. 4.

대부분의 애플리케이션에서 파일 읽기, 네트워크 요청, 데이터베이스 접근 등 I/O 작업은 필수적이다. 하지만 이러한 작업은 CPU를 사용하지 않으면서도 완료될 때까지 긴 시간을 대기하는 경우가 많다. 이때 스레드가 멈춰서 아무 일도 하지 못하는 상태를 바로 블로킹 I/O라고 한다. 이는 마치 요리가 완성될 때까지 다음 손님을 받지 않고 레스토랑 문을 잠가두는 것과 같다. 스레드라는 귀한 자원을 낭비하게 되고, 높은 동시성을 요구하는 환경에서는 심각한 성능 저하로 이어질 수 있다.

 

이러한 문제를 해결하기 위해 코루틴을 사용하면 좋다. 코루틴은 I/O 작업이 시작되는 순간 실행을 일시 중단(suspend)하고, 그 스레드를 해제하여 다른 유용한 작업을 할 수 있도록 만든다. 이후 작업이 완료되면 코루틴은 다시 재개(resume)되어 멈췄던 지점부터 실행을 이어갈 수 있다. 이 메커니즘 덕분에 소수의 스레드만으로도 수많은 동시 I/O 작업을 효율적으로 처리할 수 있게 된다.

 

코루틴의 작동 원리를 이해하기 위해 몇 가지 핵심 구성 요소를 살펴볼 필요가 있다. 먼저 Task는 코루틴의 생명주기를 책임지는 객체로, 코루틴의 handle을 관리한다. 비동기 작업을 캡슐화하는 핵심은 Awaitable 객체이다. 이 객체는 await_suspend()에서 실제 I/O 작업을 시작하며 코루틴을 중단시키는 역할을 한다. 그리고 작업이 완료된 후 await_resume()에서 그 결과를 반환한다. 코루틴 내부에서는 co_await라는 키워드를 사용하여 Awaitable 객체를 호출하는데, 이 키워드를 만나면 코루틴은 자동으로 제어권을 awaitable에게 넘겨주고 잠시 멈춘다.

 

실제 코드가 어떻게 동작하는지 단계별 흐름을 따라가 보면, main 함수에서 코루틴을 호출하고 task.handle.resume()를 통해 실행을 시작한다. 코루틴은 실행 중 co_await를 만나면 일시 중단되고, await_suspend()가 호출된다. 이 함수는 백그라운드 스레드에 I/O 작업을 위임하고, 작업이 완료되면 저장해두었던 코루틴 핸들로 h.resume()를 호출하여 코루틴을 깨운다. 코루틴이 깨어나면 await_resume()가 호출되어 작업 결과를 반환받고, 나머지 코드를 이어서 실행한 후 종료된다. 이러한 과정을 통해 메인 스레드는 I/O 대기 시간 동안 다른 작업을 수행하면서 자원을 효율적으로 활용할 수 있게 된다.

 

이러한 코루틴의 이점은 명확하다. 스레드를 I/O 대기에 묶어두지 않고 재사용하여 시스템 자원을 아낄 수 있고, co_await 덕분에 복잡한 콜백 함수나 future 체이닝 없이도 비동기 로직을 동기 코드처럼 직관적으로 작성할 수 있다. 결과적으로 적은 수의 스레드로도 수많은 동시 연결을 처리할 수 있어, 서버나 서비스의 확장성이 크게 향상된다.

실전 예제: 비동기 HTTP 요청

다음은 I/O 작업의 대표적인 예시인 HTTP 요청을 코루틴으로 처리하는 실전 예제이다. 실제 HTTP 라이브러리 대신 std::asyncstd::future를 이용해 가상의 네트워크 요청을 시뮬레이션했다.

#include <iostream>
#include <string>
#include <future>
#include <coroutine>
#include <thread>
#include <chrono>

// 간단한 Task 정의 (코루틴의 반환 타입)
struct Task {
    struct promise_type {
        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    std::coroutine_handle<promise_type> handle;
    Task(std::coroutine_handle<promise_type> h) : handle(h) {}
    Task(Task&& other) noexcept : handle(other.handle) { other.handle = nullptr; }
    ~Task() { if (handle) handle.destroy(); }
};

// Awaitable: 비동기 HTTP 요청
// 실제로는 소켓 통신을 포함하는 HTTP 클라이언트 라이브러리 역할을 한다.
struct AsyncHttpRequestor {
    std::string url;
    std::future<std::string> httpFuture;

    AsyncHttpRequestor(const std::string& targetUrl) : url(targetUrl) {}

    bool await_ready() const noexcept {
        return false; // 항상 중단
    }

    void await_suspend(std::coroutine_handle<> h) {
        // 비동기 작업 시작: 별도의 스레드에서 HTTP 요청을 시뮬레이션한다.
        httpFuture = std::async(std::launch::async, [this, h]() {
            // 실제 네트워크 통신 대신 2초 지연을 시뮬레이션한다.
            std::this_thread::sleep_for(std::chrono::seconds(2));

            std::string response = "HTTP/1.1 200 OK\nContent-Type: text/plain\n\n";
            response += "Response from: " + this->url;

            // 작업 완료 후 코루틴 재개
            h.resume();
            return response;
        });
    }

    std::string await_resume() noexcept {
        return httpFuture.get();
    }
};

// 비동기 함수: HTTP 요청
Task fetch_url_async(const std::string& url) {
    std::cout << "URL: '" << url << "' 요청 시작...\n";
    std::string response = co_await AsyncHttpRequestor(url);
    std::cout << "요청 완료. 받은 응답:\n---\n" << response << "\n---\n";
}

int main() {
    auto task = fetch_url_async("[https://example.com/api/data](https://example.com/api/data)");
    task.handle.resume();

    std::cout << "메인 함수는 요청 완료를 기다리지 않고 계속 실행 중...\n";

    // 코루틴이 완료될 시간을 주기 위해 메인 스레드 대기
    std::this_thread::sleep_for(std::chrono::seconds(3));

    return 0;
}

HTTP 요청 코루틴 다이어그램

아래는 HTTP 요청 코루틴의 실행 흐름을 시각적으로 나타낸 다이어그램이다. 이 다이어그램을 통해 코루틴이 어떻게 제어권을 넘겨주고 다시 받는지를 한눈에 확인할 수 있다.