비동기 작업에서 오류 처리는 매우 중요하다. 비동기 프로그래밍은 병렬적인 작업 실행과 밀접한 관련이 있으며, 동기 방식에서는 발생하지 않는 여러 오류 시나리오에 대한 고려가 필요하다. 특히 비동기 작업이 여러 스레드에서 동시에 실행될 수 있다는 점에서 오류의 전파와 처리 방법은 매우 세밀하게 설계되어야 한다.

오류 처리의 기본 개념

비동기 작업에서 오류가 발생할 수 있는 일반적인 경우는 크게 두 가지다. 첫째는 작업 실행 도중 발생하는 오류(예: 네트워크 실패, I/O 작업 실패), 둘째는 비동기 작업 자체가 정상적으로 실행되지 못하는 경우다(예: 핸들러가 잘못된 방식으로 호출됨).

이때 각 오류는 적절한 위치에서 포착하고, 이를 다시 상위 계층으로 전달하거나 적절하게 처리해야 한다. 일반적으로 비동기 작업에서 오류 처리는 다음과 같은 방법을 통해 이루어진다.

오류 전파 메커니즘

비동기 작업에서는 오류가 발생한 즉시 오류를 처리할 수 없기 때문에, 대부분의 경우 오류는 상위로 전파된다. 일반적으로는 다음과 같은 전파 경로를 따른다.

  1. 작업 자체에서의 오류 감지: 작업이 실행되는 도중 특정 오류가 발생하면, 해당 작업에서 오류가 감지되고 이는 핸들러로 전달된다.
  2. 핸들러에서의 오류 전파: 핸들러 내부에서 오류를 다시 감지하고, 이를 더 상위의 처리 로직으로 전파하거나 적절한 복구 작업을 수행한다.
  3. 상위 레벨로의 전파: 필요시, 오류가 시스템의 상위 레벨(예: 메인 프로그램)로 전달되어 전역적인 오류 처리를 수행할 수 있다.

수학적 모델로서의 오류 전파

비동기 작업에서의 오류 전파는 수학적 모델로 표현할 수 있다. 이를 단순화된 상태 전이 모델로 설명할 수 있다. 상태 S_i가 시간 t_i에서 오류가 발생하지 않은 정상 상태를 나타내고, 오류가 발생한 상태를 E_i라고 하자. 비동기 작업에서 오류가 발생하면 상태가 S_i \to E_i로 전환된다. 이때 오류 전파를 모델링하기 위해 오류 전파 함수를 다음과 같이 정의할 수 있다.

f_{\text{error}}(S_i) = E_i

이 오류 상태는 상위 레벨로 전파되거나, 오류 복구가 가능한 경우 다시 정상 상태로 전환될 수 있다. 오류 복구 함수는 다음과 같다.

f_{\text{recover}}(E_i) = S_j

이때 복구된 상태 S_j는 원래 상태 S_i와 동일하지 않을 수 있으며, 시스템이 일부 손상되었거나 복구 과정에서 상태가 변형되었을 수 있다. 이를 고려한 복구 상태 모델은 다음과 같이 정의된다.

S_j = f_{\text{modify}}(S_i)

여기서 f_{\text{modify}}는 오류 복구 과정에서 상태가 변경되는 과정을 나타낸다.

오류 발생 시의 비동기 작업 처리 흐름

비동기 작업에서 오류 처리는 일반적으로 다음과 같은 흐름을 따른다. 이 흐름은 각 단계에서의 오류 처리 및 전파 방식을 명확히 이해하는 데 도움을 준다.

  1. 작업 요청: 비동기 작업이 요청될 때, 작업은 백그라운드에서 실행될 준비를 마친다. 이때 작업의 성공 및 실패에 대한 콜백 함수가 설정된다.
  2. 작업 실행: 작업이 비동기적으로 실행되며, 여기서 오류가 발생할 수 있다. 오류 발생 시 오류 정보를 핸들러에 전달하기 위해 작업 결과와 함께 오류 코드가 반환된다.
  3. 핸들러 실행: 비동기 작업이 완료되면 해당 작업의 성공 여부와 상관없이 핸들러가 실행된다. 이때 작업의 성공 또는 오류 여부에 따라 핸들러는 다른 로직을 처리하게 된다.
  4. 오류 처리 로직: 핸들러 내부에서 작업이 실패했는지 확인하고, 오류 처리 루틴을 실행한다. 오류 코드와 메시지를 기반으로 복구 작업을 시도할 수 있다.
  5. 오류 전파 또는 복구 시도: 복구 가능한 오류일 경우 재시도하거나 대체 작업을 수행한다. 복구가 불가능한 경우에는 상위 수준으로 오류가 전파되어야 한다.

이 흐름을 그림으로 나타내면, 각 단계에서 발생하는 오류와 그에 따른 처리를 시각화할 수 있다. 아래는 해당 흐름을 간단히 표현한 다이어그램이다.

graph TD A(비동기 작업 요청) --> B[작업 실행] B -->|성공| C[핸들러 실행] B -->|오류 발생| D[오류 처리 로직] D -->|복구 불가| E[오류 전파] D -->|복구 시도| F[재시도 또는 대체 작업] E --> C F --> C

오류 코드와 예외 처리

비동기 작업에서는 두 가지 방식으로 오류를 처리할 수 있다. 첫째는 오류 코드를 반환하는 방식이고, 둘째는 예외를 던지는 방식이다. 각각의 방법은 장단점이 있으며, 비동기 작업에서는 주로 오류 코드를 사용하는 방식이 많이 쓰인다.

오류 코드 방식

오류 코드 방식은 작업이 완료될 때 특정 코드 값을 반환하여 작업의 성공 또는 실패 여부를 나타낸다. 이 방식은 일반적으로 네트워크 통신이나 파일 입출력 같은 비동기 작업에서 많이 사용된다. 오류 코드 방식의 대표적인 예는 다음과 같다.

boost::system::error_code ec;
boost::asio::ip::tcp::resolver resolver(io_context);
auto results = resolver.resolve("www.example.com", "http", ec);

if (ec) {
    // 오류 발생 시 처리
    std::cout << "Error: " << ec.message() << std::endl;
}

위의 코드에서 boost::system::error_code 객체 ec는 작업 결과를 나타내며, 오류가 발생한 경우 적절한 메시지를 출력한다. 오류 코드를 사용하면 오류를 세밀하게 관리할 수 있지만, 코드의 가독성이 떨어질 수 있다.

예외 처리 방식

예외 처리 방식은 오류가 발생했을 때 예외를 던져 상위 수준에서 이를 처리하는 방식이다. 예외 처리는 비동기 작업에서는 잘 사용되지 않는 경향이 있는데, 그 이유는 예외가 비동기적 흐름에서 즉시 처리되지 않기 때문이다. 다만, 특정 상황에서는 예외 처리 방식이 유용할 수 있다.

try {
    boost::asio::ip::tcp::resolver resolver(io_context);
    auto results = resolver.resolve("www.example.com", "http");
} catch (const std::exception& e) {
    // 예외 발생 시 처리
    std::cout << "Exception: " << e.what() << std::endl;
}

이 방식에서는 try-catch 블록을 사용하여 오류를 처리한다. 예외는 코드 흐름을 방해하지 않으며, 처리 로직을 명확하게 분리할 수 있다는 장점이 있지만, 비동기 작업에서는 예외가 발생한 지점을 추적하기 어렵다.

비동기 작업에서의 오류 재시도

비동기 작업이 실패했을 때 단순히 오류를 기록하고 끝나는 것이 아니라, 오류 복구를 위해 재시도를 시도할 수 있다. 재시도는 네트워크 통신이나 I/O 작업에서 자주 사용하는 방식으로, 일시적인 오류(예: 네트워크 불안정성, 파일 접근 오류 등)일 때 매우 유용하다. 이를 구현하기 위한 전략은 다음과 같다.

재시도 횟수 제한

무한 반복적으로 재시도하는 것은 자원 낭비로 이어질 수 있다. 따라서 일반적으로는 재시도 횟수를 제한하는 방식으로 구현한다. 예를 들어, 5번까지 재시도를 허용하고, 그 이후에는 오류를 상위로 전파하거나 다른 작업을 수행하는 방식이다.

int retry_count = 0;
const int max_retries = 5;

while (retry_count < max_retries) {
    boost::system::error_code ec;
    boost::asio::ip::tcp::resolver resolver(io_context);
    auto results = resolver.resolve("www.example.com", "http", ec);

    if (!ec) {
        // 성공 시 작업 종료
        break;
    } else {
        retry_count++;
        std::cout << "Error: " << ec.message() << ", retrying (" << retry_count << "/" << max_retries << ")" << std::endl;
    }
}

if (retry_count == max_retries) {
    std::cout << "Max retry limit reached. Aborting operation." << std::endl;
}

위 코드에서 retry_countmax_retries보다 작을 동안 오류가 발생하면 재시도를 수행하며, 최대 재시도 횟수에 도달하면 작업을 중단한다.

재시도 간의 지연 시간

재시도를 즉시 실행하는 것은 효율적이지 않다. 특히 네트워크 통신에서 재시도 간의 적절한 지연 시간을 두는 것은 네트워크 부하를 줄이고 성공 가능성을 높인다. 이를 위해 일정한 지연 시간을 두거나 지수적 백오프(Exponential Backoff) 알고리즘을 사용할 수 있다.

int retry_count = 0;
const int max_retries = 5;
int delay = 1000; // 1초

while (retry_count < max_retries) {
    boost::system::error_code ec;
    boost::asio::ip::tcp::resolver resolver(io_context);
    auto results = resolver.resolve("www.example.com", "http", ec);

    if (!ec) {
        // 성공 시 작업 종료
        break;
    } else {
        retry_count++;
        std::cout << "Error: " << ec.message() << ", retrying (" << retry_count << "/" << max_retries << ")" << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(delay));
        delay *= 2; // 지수적으로 지연 시간 증가
    }
}

if (retry_count == max_retries) {
    std::cout << "Max retry limit reached. Aborting operation." << std::endl;
}

이 코드는 초기 지연 시간을 1초로 설정하고, 각 재시도마다 지연 시간을 두 배로 증가시킨다. 이는 네트워크 상태가 개선될 시간을 확보해 줄 수 있다.

오류 로그 기록

비동기 작업에서 발생하는 오류는 단순히 처리하고 끝내는 것이 아니라, 적절히 로그로 기록하는 것이 매우 중요하다. 로그를 남기면 나중에 시스템에서 오류 발생 빈도와 원인을 분석할 수 있으며, 시스템의 신뢰성을 높이는 데 기여한다.

일반적으로 로그 기록에는 두 가지 정보가 필수적으로 포함된다.

Boost Asio에서 제공하는 boost::system::error_code를 활용하면 오류 메시지를 간단하게 로그로 남길 수 있다.

boost::system::error_code ec;
boost::asio::ip::tcp::resolver resolver(io_context);
auto results = resolver.resolve("www.example.com", "http", ec);

if (ec) {
    std::ofstream log_file("error_log.txt", std::ios_base::app);
    log_file << "Error occurred at: " << __TIME__ << " in file: " << __FILE__ 
             << ", Line: " << __LINE__ << ", Error: " << ec.message() << std::endl;
}

이 코드는 오류가 발생할 경우 로그 파일에 오류 발생 시점, 파일 위치, 라인 번호, 그리고 오류 메시지를 기록한다. 이 정보를 통해 개발자는 나중에 발생한 오류의 원인을 쉽게 파악할 수 있다.

오류 복구 패턴

비동기 작업에서 발생하는 오류는 단순히 재시도하는 것만으로 해결되지 않을 수 있다. 따라서 다양한 복구 패턴을 고려해야 한다.

  1. Fallback 패턴: 주 작업이 실패했을 경우, 보조 작업을 실행하는 방식이다. 예를 들어, 서버 간의 네트워크 통신이 실패했을 때, 다른 서버로 요청을 전환하는 것이 Fallback 패턴의 일종이다.
boost::system::error_code ec;
boost::asio::ip::tcp::resolver resolver(io_context);
auto results = resolver.resolve("www.example.com", "http", ec);

if (ec) {
    // 주 작업이 실패했으므로 대체 작업 실행
    results = resolver.resolve("backup.example.com", "http", ec);
}
  1. Circuit Breaker 패턴: 오류가 연속적으로 발생할 경우, 시스템 전체가 과부하되지 않도록 특정 작업을 일정 시간 동안 차단하는 패턴이다. 이를 통해 시스템이 안정성을 확보한 후 다시 작업을 재개할 수 있다.

Circuit Breaker 패턴

Circuit Breaker 패턴은 오류가 연속적으로 발생할 경우 시스템을 보호하기 위해 사용된다. 이 패턴은 비동기 작업에서 특히 유용하다. 반복적인 오류가 발생할 경우 자원 소모를 방지하기 위해 작업을 중단하고, 일정 시간이 지나면 다시 작업을 시도한다. 이 패턴은 네트워크 통신이나 외부 리소스와의 연결에서 자주 사용된다.

Circuit Breaker는 세 가지 주요 상태를 가진다:

Circuit Breaker의 상태 전이를 수식으로 표현하면 다음과 같다. 오류가 발생하지 않는 정상 상태를 S_{\text{closed}}, 연속적인 오류로 인한 중단 상태를 S_{\text{open}}, 부분적인 복구 상태를 S_{\text{half-open}}으로 나타내자.

f_{\text{trans}}(S_{\text{closed}}, n_{\text{error}} > N) = S_{\text{open}}

여기서 n_{\text{error}}는 연속적으로 발생한 오류의 횟수이며, N은 허용 가능한 최대 오류 횟수다. 오류가 허용 범위를 초과하면 상태가 Open으로 전환된다.

이후 일정 시간이 지나면 상태가 Half-Open으로 전환되며, 이 상태에서 일부 요청을 테스트하여 성공 여부에 따라 다시 Closed 상태로 전환하거나 Open 상태를 유지한다.

class CircuitBreaker {
public:
    CircuitBreaker(int max_failures, int reset_time)
        : max_failures_(max_failures), reset_time_(reset_time), failure_count_(0), state_(State::Closed) {}

    bool allowRequest() {
        if (state_ == State::Open) {
            auto now = std::chrono::system_clock::now();
            if (now - last_failure_time_ > std::chrono::seconds(reset_time_)) {
                state_ = State::HalfOpen;
            } else {
                return false;
            }
        }
        return true;
    }

    void onSuccess() {
        failure_count_ = 0;
        state_ = State::Closed;
    }

    void onFailure() {
        failure_count_++;
        last_failure_time_ = std::chrono::system_clock::now();
        if (failure_count_ >= max_failures_) {
            state_ = State::Open;
        }
    }

private:
    enum class State { Closed, Open, HalfOpen };
    State state_;
    int max_failures_;
    int reset_time_;
    int failure_count_;
    std::chrono::time_point<std::chrono::system_clock> last_failure_time_;
};

위 코드에서는 CircuitBreaker 클래스를 사용해 비동기 작업에서 Circuit Breaker 패턴을 구현했다. 이 클래스는 요청이 허용되는지 여부를 allowRequest 함수에서 결정하며, 오류가 일정 횟수를 초과하면 상태를 Open으로 전환하고 일정 시간이 지나면 Half-Open 상태로 전환한다.

비동기 작업에서의 자원 누수 방지

비동기 작업에서 오류 처리가 제대로 이루어지지 않으면 자원 누수가 발생할 수 있다. 특히 네트워크 통신이나 파일 입출력 같은 경우에는 자원이 제대로 해제되지 않으면 메모리나 파일 핸들이 점점 소모되면서 시스템의 성능을 저하시킬 수 있다. 이를 방지하기 위한 대표적인 방법은 RAII(Resource Acquisition Is Initialization) 패턴을 사용하는 것이다.

RAII 패턴은 객체가 생성될 때 자원을 획득하고, 객체가 소멸될 때 자원을 자동으로 해제하는 방식이다. C++에서는 주로 스마트 포인터를 사용하여 자원의 누수를 방지한다.

void handle_request() {
    std::shared_ptr<boost::asio::ip::tcp::socket> socket = 
        std::make_shared<boost::asio::ip::tcp::socket>(io_context);

    boost::system::error_code ec;
    socket->connect(endpoint, ec);

    if (ec) {
        std::cout << "Connection error: " << ec.message() << std::endl;
        return;
    }

    // 자원은 함수가 종료될 때 자동으로 해제됨
}

위 코드에서는 std::shared_ptr를 사용하여 소켓 객체를 관리한다. 이 객체는 함수가 종료될 때 자동으로 소멸하며, 소멸될 때 소켓 연결이 자동으로 해제된다. 이를 통해 자원 누수를 방지할 수 있다.

비동기 작업에서의 타임아웃 처리

비동기 작업에서는 타임아웃 처리가 매우 중요하다. 작업이 너무 오래 걸리면 시스템 성능이 저하될 수 있으며, 이러한 상황을 방지하기 위해 작업에 제한 시간을 설정하고 일정 시간이 지나면 작업을 취소하거나 오류로 처리할 수 있어야 한다.

Boost Asio에서는 타이머를 사용하여 비동기 작업의 타임아웃을 구현할 수 있다. boost::asio::steady_timer를 사용하면 일정 시간이 지나면 작업을 자동으로 취소할 수 있다.

boost::asio::steady_timer timer(io_context);
boost::system::error_code ec;
timer.expires_after(std::chrono::seconds(5));

timer.async_wait([&](const boost::system::error_code& ec) {
    if (ec != boost::asio::error::operation_aborted) {
        std::cout << "Operation timed out" << std::endl;
        socket.cancel(); // 작업 취소
    }
});

socket.async_connect(endpoint, [&](const boost::system::error_code& ec) {
    if (!ec) {
        timer.cancel(); // 작업이 성공했으므로 타이머 취소
        std::cout << "Connection established" << std::endl;
    } else {
        std::cout << "Connection error: " << ec.message() << std::endl;
    }
});

io_context.run();

위 코드에서는 5초의 타임아웃을 설정하여 비동기 작업이 5초 이상 걸리면 자동으로 작업을 취소하고 타임아웃 메시지를 출력한다. 반대로 작업이 성공하면 타이머를 취소하여 타임아웃이 발생하지 않도록 한다.

비동기 작업 오류 처리의 예제

마지막으로 비동기 작업에서의 오류 처리를 종합한 예제를 보여준다. 이 예제는 오류 처리, 재시도, 타임아웃, Circuit Breaker를 모두 결합한 코드다.

boost::asio::io_context io_context;
CircuitBreaker breaker(3, 10); // 최대 3번 실패하면 10초 동안 작업 중단

boost::asio::steady_timer timer(io_context);
timer.expires_after(std::chrono::seconds(5));

while (breaker.allowRequest()) {
    boost::system::error_code ec;
    timer.async_wait([&](const boost::system::error_code& ec) {
        if (ec != boost::asio::error::operation_aborted) {
            std::cout << "Operation timed out" << std::endl;
            socket.cancel();
        }
    });

    socket.async_connect(endpoint, [&](const boost::system::error_code& ec) {
        if (!ec) {
            breaker.onSuccess();
            timer.cancel();
            std::cout << "Connection successful" << std::endl;
        } else {
            breaker.onFailure();
            std::cout << "Connection error: " << ec.message() << std::endl;
        }
    });

    io_context.run();

    if (breaker.allowRequest()) {
        std::cout << "Retrying connection..." << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(2));
    } else {
        std::cout << "Circuit breaker activated. Aborting operation." << std::endl;
        break;
    }
}

이 코드는 재시도 및 타임아웃을 관리하고, 오류가 연속으로 발생할 경우 Circuit Breaker 패턴을 사용하여 일정 시간 동안 작업을 중단한다. 이처럼 여러 기법을 조합하면 비동기 작업의 오류 처리 로직을 효과적으로 구성할 수 있다.