icon
13장 : 예외 처리

예외 던지기

예외 상황이 발생했음을 시스템에 알리고, 그에 해당하는 예외 객체를 생성하여 던지는(throw) 방법에 대해 자세히 살펴보겠습니다.

throw 문은 예외 처리 메커니즘의 시작점이며, 어떤 타입의 객체를 던지느냐에 따라 catch 블록의 동작이 결정됩니다.


throw 문: 예외 발생 알림

throw은 현재 프로그램의 실행 흐름이 더 이상 정상적으로 진행될 수 없음을 알리고, 제어를 catch 블록으로 옮기는 역할을 합니다.

throw 키워드 뒤에는 던지고자 하는 예외 객체(Exception Object) 를 명시합니다.

throw 문 형식
throw 표현식;
  • 표현식: 어떤 타입의 객체든 될 수 있습니다. 이 객체가 catch 블록으로 전달되어 예외 정보로 활용됩니다.

throw 문의 동작

  1. throw 문이 실행되면, 표현식의 결과로 임시 예외 객체가 생성됩니다.
  2. 현재 함수의 실행이 즉시 중단됩니다.
  3. 현재 함수부터 호출 스택을 역순으로 따라가며, 해당 예외를 처리할 수 있는 catch 블록을 찾습니다. 이 과정에서 각 스택 프레임에 존재하는 지역 객체들의 소멸자가 호출됩니다. (스택 풀기, Stack Unwinding)
  4. 적절한 catch 블록이 발견되면, 해당 catch 블록으로 제어가 넘어갑니다.
  5. 만약 main 함수까지 도달할 때까지 어떤 catch 블록도 예외를 처리하지 못하면, 프로그램은 std::terminate 함수를 호출하며 비정상적으로 종료됩니다.

어떤 것을 throw 할 것인가?

C++에서 throw할 수 있는 객체의 타입에는 제한이 없습니다.

정수, 문자열 리터럴(const char*), std::string 객체, 그리고 사용자 정의 클래스의 객체 등 무엇이든 던질 수 있습니다.

1. 기본 타입의 값 던지기 (int, double, const char) 포인터: 간단한 예외 상황이나 디버깅 목적으로 사용할 수 있습니다.

기본 타입 예외 던지기
#include <iostream>

void divide(int a, int b) {
    if (b == 0) {
        throw 100; // int 타입의 에러 코드 던지기
    }
    std::cout << "Result: " << a / b << std::endl;
}

void processFile(const char* filename) {
    if (filename == nullptr || filename[0] == '\0') {
        throw "Filename cannot be empty!"; // const char* (C-스타일 문자열) 던지기
    }
    // 실제 파일 처리 로직...
    std::cout << "File '" << filename << "' processed successfully.\n";
}

int main() {
    try {
        divide(10, 0);
    } catch (int e) {
        std::cerr << "Caught integer error code: " << e << std::endl;
    }

    try {
        processFile("");
    } catch (const char* msg) {
        std::cerr << "Caught string error: " << msg << std::endl;
    }
    return 0;
}

주의: 기본 타입의 값을 던지는 것은 예외에 대한 정보를 충분히 제공하기 어렵고, 계층적인 예외 처리를 어렵게 하므로 일반적으로 권장되지 않습니다.

2. std::string 객체 던지기: 오류 메시지를 담는 데 유용하지만, std::exception 계층 구조에 포함되지 않으므로 다형성을 활용하기 어렵습니다.

std::string 예외 던지기
#include <iostream>
#include <string>

void connectToServer(const std::string& address) {
    if (address.empty()) {
        throw std::string("Connection Error: Server address is empty!"); // std::string 객체 던지기
    }
    // 실제 연결 로직...
    std::cout << "Connected to " << address << std::endl;
}

int main() {
    try {
        connectToServer("");
    } catch (const std::string& e) {
        std::cerr << "Connection Failed: " << e << std::endl;
    }
    return 0;
}

3. 표준 예외 클래스 객체 던지기 (std::exception 파생 클래스): 가장 권장되는 방법입니다. C++ 표준 라이브러리는 <stdexcept> 헤더에 다양한 표준 예외 클래스를 제공합니다. 이들은 모두 std::exception 클래스를 상속받습니다.

  • std::exception: 모든 표준 예외의 최상위 기반 클래스. what() 멤버 함수를 통해 오류 메시지를 얻을 수 있습니다.
  • std::logic_error: 프로그램의 논리적 오류 (예: std::invalid_argument, std::out_of_range)
  • std::runtime_error: 프로그램 실행 중 발생할 수 있는 오류 (예: std::overflow_error, std::underflow_error, std::range_error)
표준 예외 클래스 예시
#include <iostream>
#include <stdexcept> // std::invalid_argument, std::out_of_range, std::runtime_error
#include <vector>

void process_data(int data) {
    if (data < 0) {
        throw std::invalid_argument("Data cannot be negative."); // 논리 오류
    }
    if (data > 100) {
        throw std::out_of_range("Data is too large, must be <= 100."); // 범위 오류
    }
    std::cout << "Data processed: " << data << std::endl;
}

void simulate_hardware_failure() {
    bool failure_detected = true; // 실제로는 하드웨어 상태를 검사
    if (failure_detected) {
        throw std::runtime_error("Hardware malfunction detected!"); // 런타임 오류
    }
    std::cout << "Hardware operating normally.\n";
}

int main() {
    // std::invalid_argument 예외 처리
    try {
        process_data(-5);
    } catch (const std::invalid_argument& e) {
        std::cerr << "Caught invalid_argument: " << e.what() << std::endl;
    }

    // std::out_of_range 예외 처리
    try {
        process_data(150);
    } catch (const std::out_of_range& e) {
        std::cerr << "Caught out_of_range: " << e.what() << std::endl;
    }

    // std::runtime_error 예외 처리
    try {
        simulate_hardware_failure();
    } catch (const std::runtime_error& e) {
        std::cerr << "Caught runtime_error: " << e.what() << std::endl;
    }

    // 일반 std::exception으로 모든 표준 예외를 잡기
    try {
        // 다시 던져서 테스트
        // process_data(-10); // 주석 해제 시 위에서 잡힘
        std::vector<int> v(5);
        v.at(10) = 1; // std::out_of_range 발생
    } catch (const std::exception& e) { // std::out_of_range는 std::exception을 상속하므로 여기서 잡힘
        std::cerr << "Caught generic std::exception: " << e.what() << std::endl;
    }

    return 0;
}

표준 예외 클래스를 사용하면 예외에 대한 의미론적인 분류가 가능하며, 계층 구조를 통해 다형적인 예외 처리가 용이합니다.

4. 사용자 정의 예외 클래스 객체 던지기: 가장 유연하고 강력한 방법입니다. 프로그램 특정 오류에 대한 상세한 정보를 제공하고 싶을 때 사용합니다. 일반적으로 std::exception을 상속받아 what() 메서드를 오버라이드하여 사용자 정의 오류 메시지를 제공합니다. (다음 장에서 자세히 다룹니다.)

사용자 정의 예외 클래스 예시
// MyCustomException.h (예시, 다음 장에서 자세히)
#include <stdexcept>
#include <string>

class MyCustomException : public std::runtime_error {
public:
    MyCustomException(const std::string& message)
        : std::runtime_error(message) {}
};

// 사용 예시
void process_network_packet(int packet_size) {
    if (packet_size < 10) {
        throw MyCustomException("Packet size is too small.");
    }
    if (packet_size > 1024) {
        throw MyCustomException("Packet size exceeds maximum limit.");
    }
    std::cout << "Packet processed, size: " << packet_size << std::endl;
}

int main() {
    try {
        process_network_packet(5);
    } catch (const MyCustomException& e) {
        std::cerr << "Caught custom exception: " << e.what() << std::endl;
    } catch (const std::exception& e) { // MyCustomException도 std::exception을 상속하므로 여기서도 잡힐 수 있음
        std::cerr << "Caught general exception: " << e.what() << std::endl;
    }
    return 0;
}

throw 시 객체 복사 vs 참조

예외를 던질 때, 던져지는 객체는 throw 문에 의해 임시 객체로 복사(또는 이동)되어 예외 처리 메커니즘으로 전달됩니다.

따라서 예외 객체를 const MyException&와 같이 const 참조로 잡는 것이 일반적으로 권장됩니다.

이는 불필요한 복사 비용을 줄이고, 던져진 객체의 실제 타입이 보존되어 다형성을 활용할 수 있게 합니다.

예외 객체 던지기와 잡기
// 올바른 예시: 값으로 던지고, const 참조로 잡기
throw MyException("Error details."); // 값으로 던짐
// ...
catch (const MyException& e) { // const 참조로 잡음
    // 안전하고 효율적
}

// 잘못된 예시: 포인터로 던지고, 포인터로 잡기 (메모리 관리 문제)
// throw new MyException("Error details."); // new로 동적 할당된 객체 던짐
// // ...
// catch (MyException* e) { // 포인터로 잡음
//     delete e; // catch 블록에서 명시적으로 delete 해줘야 함 (번거롭고 실수할 가능성 높음)
// }
// C++에서는 예외를 포인터로 던지는 것을 강하게 비권장합니다.
// 예외 객체의 생명 주기는 시스템이 관리해야 합니다.

예외 발생 시 스택 풀기와 RAII

throw 문이 실행되어 예외가 던져지면, catch 블록이 발견될 때까지 함수 호출 스택을 역순으로 따라가면서 스택 프레임들을 해제합니다.

이 과정에서 각 스택 프레임에 있는 지역 객체들의 소멸자가 자동으로 호출됩니다. 이를 스택 풀기(Stack Unwinding) 라고 합니다.

이러한 스택 풀기 메커니즘은 RAII(Resource Acquisition Is Initialization) 패턴과 결합될 때 매우 강력해집니다.

RAII는 자원(메모리, 파일 핸들, 뮤텍스 등)을 객체에 캡슐화하고, 객체의 생성자에서 자원을 획득하며, 소멸자에서 자원을 해제하도록 설계하는 기법입니다.

RAII의 장점

  • 예외 안전성: 예외가 발생하여 스택 풀기가 일어나더라도, 스택에 있는 RAII 객체들의 소멸자가 자동으로 호출되어 자원이 안전하게 해제됩니다. 이는 자원 누수(Resource Leak)를 방지하는 핵심적인 방법입니다.
  • 코드 간결성: 자원 해제 코드를 명시적으로 작성할 필요가 줄어듭니다.
RAII를 통한 자원 안전성 보장
#include <iostream>
#include <string>
#include <stdexcept>
#include <fstream> // for std::ofstream

// RAII를 적용한 파일 핸들 관리 클래스
class FileHandler {
private:
    std::ofstream _file;
    std::string _filename;

public:
    FileHandler(const std::string& filename) : _filename(filename) {
        _file.open(filename);
        if (!_file.is_open()) {
            throw std::runtime_error("Failed to open file: " + filename);
        }
        std::cout << "FileHandler: File '" << _filename << "' opened.\n";
    }

    void write(const std::string& data) {
        if (!_file.is_open()) {
            throw std::runtime_error("File is not open for writing.");
        }
        _file << data << std::endl;
        std::cout << "FileHandler: Wrote '" << data << "' to file.\n";
    }

    // 소멸자에서 파일 자동 닫기 (RAII 핵심)
    ~FileHandler() {
        if (_file.is_open()) {
            _file.close();
            std::cout << "FileHandler: File '" << _filename << "' closed.\n";
        }
    }
};

void manipulateFile(const std::string& filename, bool throw_error) {
    FileHandler handler(filename); // 파일 열기 (생성자)
    handler.write("First line.");

    if (throw_error) {
        throw std::runtime_error("Simulating an error during file manipulation."); // 예외 발생
    }

    handler.write("Second line (this might not be written).");
    std::cout << "File manipulation finished normally.\n";
}

int main() {
    std::cout << "--- Scenario 1: No exception ---\n";
    try {
        manipulateFile("output1.txt", false);
    } catch (const std::exception& e) {
        std::cerr << "Caught exception: " << e.what() << std::endl;
    }
    std::cout << "Scenario 1 finished.\n";

    std::cout << "\n--- Scenario 2: With exception ---\n";
    try {
        manipulateFile("output2.txt", true);
    } catch (const std::exception& e) {
        std::cerr << "Caught exception: " << e.what() << std::endl;
    }
    std::cout << "Scenario 2 finished.\n";

    return 0;
}
RAII 예시 실행 결과
--- Scenario 1: No exception ---
FileHandler: File 'output1.txt' opened.
FileHandler: Wrote 'First line.' to file.
FileHandler: Wrote 'Second line (this might not be written).' to file.
File manipulation finished normally.
FileHandler: File 'output1.txt' closed.
Scenario 1 finished.

--- Scenario 2: With exception ---
FileHandler: File 'output2.txt' opened.
FileHandler: Wrote 'First line.' to file.
Caught exception: Simulating an error during file manipulation.
FileHandler: File 'output2.txt' closed. // 예외 발생 시에도 소멸자가 호출되어 파일이 닫힘!
Scenario 2 finished.

Scenario 2에서 manipulateFile 함수 내부에 예외가 발생했음에도 불구하고, FileHandler 객체의 소멸자가 호출되어 파일이 안전하게 닫히는 것을 확인할 수 있습니다.

이것이 RAII와 예외 처리의 중요한 시너지 효과입니다.

std::unique_ptr, std::shared_ptr, std::fstream 등 대부분의 표준 라이브러리 클래스들은 RAII 원칙을 따르므로 예외 상황에서도 자원 관리에 대해 크게 걱정할 필요가 없습니다.