icon
13장 : 예외 처리

예외 처리의 기본 개념

프로그램이 실행되는 동안 발생할 수 있는 예상치 못한 문제, 즉 오류(Error)예외(Exception) 를 어떻게 효과적으로 처리할 것인지에 대해 알아보겠습니다.

이 절에서는 C++의 예외 처리(Exception Handling) 메커니즘을 다룹니다.

예외 처리는 프로그램의 안정성을 높이고, 오류 발생 시 적절하게 대응하여 프로그램이 비정상적으로 종료되는 것을 방지하는 데 필수적인 기법입니다.


예외(Exception)란 무엇인가?

프로그램이 실행되는 동안 발생할 수 있는 비정상적인 상황을 예외(Exception) 라고 합니다.

이는 일반적으로 프로그램이 정상적인 흐름을 이어갈 수 없음을 의미합니다.

예외의 예시

  • 메모리 부족: new 연산자가 메모리 할당에 실패한 경우 (std::bad_alloc)
  • 파일 열기 실패: 존재하지 않는 파일을 열려고 시도하거나, 권한이 없어 열 수 없는 경우
  • 배열/컨테이너 범위 초과: 배열이나 std::vector의 유효하지 않은 인덱스에 접근하려는 경우 (std::out_of_range)
  • 0으로 나누기: 수학적으로 불가능한 연산을 시도하는 경우
  • 네트워크 연결 끊김: 데이터를 전송하는 도중 네트워크 연결이 끊어진 경우

이러한 예외 상황이 발생했을 때 적절히 처리하지 않으면 프로그램은 비정상적으로 종료되거나, 예측 불가능한 결과를 초래할 수 있습니다.


기존 오류 처리 방식의 한계

예외 처리 메커니즘이 도입되기 전이나, 간단한 오류 처리 시에는 주로 다음과 같은 방식을 사용했습니다.

  1. 반환 값(Return Value)을 이용한 오류 보고

    • 함수가 오류 발생 시 특정 값(예: -1, nullptr, false)을 반환하여 호출자에게 오류를 알립니다.
    • 장점: 간단하고 직관적입니다.
    • 한계
      • 함수 반환 값이 항상 오류를 나타낼 수 있는 것은 아닙니다. (예: int를 반환하는 함수에서 -1이 유효한 결과일 수도 있음)
      • 호출자가 반환 값을 일일이 확인하고 처리하는 코드를 작성해야 하므로, 코드 가독성이 떨어지고 누락될 위험이 있습니다.
      • 함수 호출 스택이 깊어질수록 오류 정보를 상위 호출자에게 전달하기 위해 모든 중간 함수에서 반환 값을 확인하고 다시 반환해야 하는 "오류 전달 사슬"이 길어집니다.
    반환 값으로 오류 처리
    int divide(int a, int b) {
        if (b == 0) {
            std::cerr << "Error: Division by zero!\n";
            return -1; // 오류를 나타내는 특수 값 반환
        }
        return a / b;
    }
    
    int main() {
        int result = divide(10, 0);
        if (result == -1) {
            // 오류 처리
        } else {
            // 정상 처리
        }
        return 0;
    }
  2. 전역 변수/플래그 이용

    • 오류 발생 시 전역 변수나 플래그를 설정하여 오류 상태를 알립니다.
    • 장점: 함수 반환 값과 독립적으로 오류를 보고할 수 있습니다.
    • 한계
      • 멀티스레드 환경에서 안전하지 않을 수 있습니다.
      • 어떤 함수에서 오류가 발생했는지 추적하기 어렵고, 오류가 발생한 함수 이후에도 정상 코드가 실행될 수 있습니다.
      • 함수 호출 스택을 역추적하기 어렵습니다.
  3. assert 매크로 이용

    • 프로그래머의 논리적인 오류(버그)를 잡아내는 데 사용됩니다. 특정 조건이 참이 아닐 경우 프로그램을 강제 종료합니다.
    • 장점: 개발 단계에서 버그를 빠르게 발견할 수 있습니다.
    • 한계
      • 릴리즈(Release) 빌드에서는 비활성화될 수 있으므로, 예상되는 런타임 예외 처리에 적합하지 않습니다.
      • 프로그램을 강제 종료하므로, 사용자에게 친화적인 오류 복구 메커니즘이 아닙니다.

이러한 방식들은 예상치 못한 런타임 예외를 처리하는 데에는 한계가 명확하며, 코드의 복잡성을 증가시키고 오류 처리 로직을 비효율적으로 만듭니다.


예외 처리의 기본 메커니즘

C++의 예외 처리 메커니즘은 다음과 같은 세 가지 키워드를 중심으로 이루어집니다.

  1. throw

    • 예외 상황이 발생했음을 알리고, 예외 객체(Exception Object)를 던집니다(throw).
    • throw 문이 실행되면 현재 함수의 실행은 즉시 중단되고, 해당 예외를 처리할 catch 블록을 찾기 위해 호출 스택을 역추적합니다.
    • 어떤 타입의 객체든 던질 수 있습니다. (정수, 문자열, 사용자 정의 클래스 객체 등)
    throw 사용 예시
    throw "Error: Division by zero!"; // C-스타일 문자열 예외
    throw 404;                     // int 타입 예외
    throw MyException("Invalid input"); // 사용자 정의 예외 객체
  2. try 블록

    • 예외가 발생할 가능성이 있는 코드를 포함하는 블록입니다.
    • try 블록 내에서 예외가 발생하면, 해당 예외를 처리할 catch 블록을 찾습니다.
    try 블록 사용 예시
    try {
        // 예외가 발생할 수 있는 코드
        // ...
    }
  3. catch 블록

    • try 블록에서 던져진 예외를 잡아서(catch) 처리하는 블록입니다.
    • catch 키워드 뒤에 괄호 안에 처리하고자 하는 예외의 타입을 선언합니다. 이는 함수의 매개변수 선언과 유사합니다.
    • 던져진 예외 객체의 타입과 일치하거나, 해당 타입의 기반 클래스인 catch 블록이 선택됩니다.
    catch 블록 사용 예시
    try {
        // 예외 발생 가능 코드
    } catch (int ex_code) { // int 타입 예외를 잡음
        std::cerr << "Caught integer exception: " << ex_code << std::endl;
        // 예외 처리 로직
    } catch (const char* msg) { // C-스타일 문자열 예외를 잡음
        std::cerr << "Caught string exception: " << msg << std::endl;
        // 예외 처리 로직
    } catch (...) { // 모든 종류의 예외를 잡음 (catch-all)
        std::cerr << "Caught unknown exception!" << std::endl;
        // 예외 처리 로직
    }
try-catch-throw의 기본 동작
#include <iostream>
#include <string>

double safeDivide(double numerator, double denominator) {
    if (denominator == 0) {
        throw std::string("Error: Division by zero is not allowed!"); // std::string 객체 던지기
    }
    return numerator / denominator;
}

int main() {
    double num1 = 10.0;
    double num2 = 2.0;
    double num3 = 0.0;

    // 1. 정상적인 경우
    try {
        double result = safeDivide(num1, num2);
        std::cout << "Result of " << num1 << " / " << num2 << " = " << result << std::endl;
    } catch (const std::string& e) { // std::string 예외를 잡음
        std::cerr << "Caught exception: " << e << std::endl;
    }

    std::cout << "-------------------------------------------\n";

    // 2. 예외가 발생하는 경우
    try {
        double result = safeDivide(num1, num3); // 여기서 예외가 발생
        std::cout << "This line will not be executed." << std::endl; // 실행되지 않음
    } catch (const std::string& e) { // 던져진 std::string 예외를 잡음
        std::cerr << "Caught exception: " << e << std::endl; // 출력: Caught exception: Error: Division by zero is not allowed!
    } catch (int errorCode) { // int 예외는 잡지 않음
        std::cerr << "Caught integer error code: " << errorCode << std::endl;
    } catch (...) { // 다른 모든 예외를 잡음
        std::cerr << "Caught an unexpected exception type." << std::endl;
    }

    std::cout << "Program continues after exception handling.\n";
    return 0;
}

예외의 전파 (Exception Propagation)

throw 문이 실행되면, C++ 런타임 시스템은 현재 함수를 호출한 곳으로 제어를 옮깁니다.

만약 그 호출한 곳에 해당 예외를 처리할 catch 블록이 없다면, 다시 그 함수를 호출한 곳으로 제어를 옮기는 과정을 반복합니다.

이 과정을 스택 풀기(Stack Unwinding) 또는 예외 전파(Exception Propagation) 라고 합니다.

호출 스택을 역추적하면서 catch 블록을 찾다가, 최종적으로 main 함수까지 도달했는데도 적절한 catch 블록을 찾지 못하면 프로그램은 비정상적으로 종료됩니다 (std::terminate 호출).

예외 전파 예시
#include <iostream>
#include <string>

void functionC() {
    std::cout << "Inside functionC\n";
    throw "An error occurred in functionC!"; // C-스타일 문자열 예외를 던짐
    std::cout << "This line in functionC will not be executed.\n";
}

void functionB() {
    std::cout << "Inside functionB\n";
    // functionB에는 catch 블록이 없으므로, 예외는 functionA로 전파됨
    functionC();
    std::cout << "This line in functionB will not be executed.\n";
}

void functionA() {
    std::cout << "Inside functionA\n";
    try {
        functionB(); // functionB에서 예외가 발생하여 여기로 전파됨
        std::cout << "This line in functionA's try block will not be executed.\n";
    } catch (const char* message) { // functionA에서 C-스타일 문자열 예외를 잡음
        std::cerr << "Caught exception in functionA: " << message << std::endl;
    }
    std::cout << "Exiting functionA.\n";
}

int main() {
    std::cout << "Starting main...\n";
    functionA();
    std::cout << "Main finished.\n";
    return 0;
}
예외 전파 예시 실행 결과
Starting main...
Inside functionA
Inside functionB
Inside functionC
Caught exception in functionA: An error occurred in functionC!
Exiting functionA.
Main finished.

functionC에서 예외가 던져진 후, functionB는 예외를 처리하지 않고 바로 functionA로 예외를 전파합니다.

functionAtry-catch 블록에서 해당 예외를 잡고 처리하는 것을 볼 수 있습니다.

예외가 던져진 지점부터 catch 블록까지의 모든 함수 호출 스택이 풀리면서 해당 함수의 지역 변수들은 소멸자가 호출되며 정리됩니다.

이 과정을 스택 풀기(Stack Unwinding) 라고 합니다.