icon
13장 : 예외 처리

try-catch 구문

지난 장에서 우리는 프로그램 실행 중 발생할 수 있는 '예외(Exception)'의 개념과 왜 예외 처리가 중요한지에 대해 살펴보았습니다.

이제 C++에서 이러한 예외를 실제로 어떻게 '잡아서(catch)' 처리하는지에 대해 알아볼 시간입니다.

C++은 try, catch, throw라는 세 가지 키워드를 사용하여 예외 처리 메커니즘을 제공합니다.

이 중에서 trycatch는 예외가 발생할 가능성이 있는 코드를 감싸고, 발생한 예외를 처리하는 데 사용되는 핵심 구문입니다.


try-catch 구문이란 무엇인가?

try-catch 구문은 다음과 같은 흐름으로 작동합니다.

  1. try 블록: 예외가 발생할 수 있다고 예상되는 코드를 이 블록 안에 작성합니다.
  2. throw: try 블록 안의 코드에서 예외 상황이 발생하면, throw 키워드를 사용하여 예외를 '던집니다'. 이때 어떤 타입의 데이터든 던질 수 있습니다. (예: 정수, 문자열, 사용자 정의 클래스 객체 등)
  3. catch 블록: throw 된 예외를 '잡아서' 처리하는 블록입니다. catch 키워드 뒤에는 괄호 () 안에 처리하고자 하는 예외의 '타입'과 '매개변수'를 명시합니다. 마치 함수의 매개변수처럼, 던져진 예외 객체를 받아올 수 있습니다.

이러한 메커니즘을 통해 프로그램의 정상적인 실행 흐름과 예외 처리 흐름을 명확하게 분리하여 코드의 가독성과 유지보수성을 높일 수 있습니다.


기본 문법

try-catch 구문의 기본적인 형태는 다음과 같습니다.

try-catch 구문 기본 문법
try {
    // 예외가 발생할 가능성이 있는 코드
    // ...
    // 만약 예외 상황 발생 시:
    // throw 예외_객체;
} catch (예외_타입1 매개변수_이름1) {
    // 예외_타입1에 해당하는 예외가 발생했을 때 실행될 코드
    // ...
} catch (예외_타입2 매개변수_이름2) {
    // 예외_타입2에 해당하는 예외가 발생했을 때 실행될 코드
    // ...
} catch (...) { // 모든 타입의 예외를 잡는 catch 블록 (선택 사항)
    // 위에서 명시된 어떤 예외 타입에도 해당하지 않는 예외가 발생했을 때 실행될 코드
    // ...
}

여기서 주목할 점은 여러 개의 catch 블록을 연속해서 작성할 수 있다는 것입니다.

이는 마치 if-else if-else 구문처럼, 다양한 종류의 예외에 대해 각기 다른 방식으로 대응할 수 있게 해줍니다.


try-catch 구문의 작동 원리

try-catch 구문이 작동하는 과정을 조금 더 자세히 들여다보겠습니다.

  1. 프로그램은 try 블록 안의 코드를 실행하기 시작합니다.
  2. try 블록 안에서 throw 문이 실행되면, 즉시 현재 try 블록의 실행을 중단하고 throw 된 예외 객체와 일치하는 catch 블록을 찾기 시작합니다.
  3. 해당하는 catch 블록을 찾으면, 그 catch 블록 안의 코드를 실행하여 예외를 처리합니다. 예외가 성공적으로 처리되면, 프로그램의 실행 흐름은 해당 try-catch 구문 다음으로 이어집니다.
  4. 만약 try 블록 안의 코드가 예외를 던지지 않고 성공적으로 완료되면, catch 블록은 건너뛰고 try-catch 구문 다음 코드가 실행됩니다.
  5. 중요: 만약 던져진 예외와 일치하는 catch 블록이 없다면, 예외는 현재 함수의 호출 스택을 따라 상위 호출자로 전파됩니다. 즉, 예외를 던진 함수를 호출한 함수에서 다시 try-catch 블록을 찾아보게 됩니다. 이 과정은 적절한 catch 블록을 찾거나, 프로그램의 main 함수까지 도달할 때까지 계속됩니다. main 함수에서조차 처리되지 않은 예외는 프로그램의 비정상적인 종료로 이어집니다. (이는 이전 장에서 설명했던 '예외 전파' 개념과 연결됩니다.)

숫자를 0으로 나누는 상황은 흔히 발생하는 오류 중 하나입니다. 이를 try-catch 구문을 사용하여 처리하는 예시를 살펴보겠습니다.

0으로 나누기 예외 처리
#include <iostream>
#include <string> // 예외로 문자열을 던질 수 있습니다.

double divide(int numerator, int denominator) {
    if (denominator == 0) {
        // 0으로 나누는 상황은 비정상적이므로 예외를 던집니다.
        // 여기서는 C-스타일 문자열을 예외로 던져보겠습니다.
        throw "Error: Division by zero is not allowed!";
    }
    return static_cast<double>(numerator) / denominator;
}

int main() {
    std::cout << "--- 예외 발생 상황 ---" << std::endl;
    try {
        double result = divide(10, 0); // 0으로 나누는 함수 호출
        std::cout << "Result: " << result << std::endl; // 이 줄은 실행되지 않습니다.
    } catch (const char* errorMessage) { // C-스타일 문자열 예외를 잡습니다.
        std::cerr << "Caught exception: " << errorMessage << std::endl;
    }
    std::cout << "Program continues after exception handling." << std::endl;
    std::cout << std::endl;

    std::cout << "--- 정상 실행 상황 ---" << std::endl;
    try {
        double result = divide(10, 2); // 정상적인 나누기 함수 호출
        std::cout << "Result: " << result << std::endl; // 이 줄은 정상적으로 실행됩니다.
    } catch (const char* errorMessage) {
        // 이 catch 블록은 실행되지 않습니다.
        std::cerr << "Caught exception: " << errorMessage << std::endl;
    }
    std::cout << "Program continues normally." << std::endl;

    return 0;
}
예외 처리 실행 결과
--- 예외 발생 상황 ---
Caught exception: Error: Division by zero is not allowed!
Program continues after exception handling.

--- 정상 실행 상황 ---
Result: 5
Program continues normally.
  1. divide 함수는 denominator가 0인 경우 "Error: Division by zero is not allowed!"라는 C-스타일 문자열을 throw합니다.
  2. main 함수의 첫 번째 try 블록에서는 divide(10, 0)을 호출하여 예외를 발생시킵니다.
  3. throw 문이 실행되는 순간, try 블록의 남은 코드는 실행되지 않고, C-스타일 문자열을 받을 수 있는 catch (const char* errorMessage) 블록으로 제어가 넘어갑니다.
  4. catch 블록 안에서 예외 메시지를 출력하고, 이후 try-catch 구문 밖의 코드가 정상적으로 실행됩니다. 이로써 프로그램이 비정상적으로 종료되는 것을 방지합니다.
  5. 두 번째 try 블록에서는 divide(10, 2)를 호출하며, 이 경우 예외가 발생하지 않으므로 catch 블록은 건너뛰고 try 블록 내부의 모든 코드와 그 이후의 코드가 정상적으로 실행됩니다.

여러 종류의 예외 처리하기

하나의 try 블록에서 여러 종류의 예외가 발생할 수 있습니다.

예를 들어, 0으로 나누는 예외 외에 음수를 입력했을 때의 예외도 처리하고 싶다고 가정해봅시다. 이때는 여러 개의 catch 블록을 사용할 수 있습니다.

다중 catch 블록 예시
#include <iostream>
#include <string>
#include <stdexcept> // 표준 예외 클래스들을 사용하기 위해 포함

// 특정 숫자 범위를 벗어났을 때 던질 예외 클래스 (사용자 정의 예외)
class OutOfRangeException : public std::runtime_error {
public:
    // 생성자에서 부모 클래스의 생성자를 호출하여 메시지를 전달합니다.
    OutOfRangeException(const std::string& msg) : std::runtime_error(msg) {}
};

double calculate(int val1, int val2, char op) {
    if (op == '/') {
        if (val2 == 0) {
            // 0으로 나누는 경우, 표준 예외 std::invalid_argument를 던집니다.
            throw std::invalid_argument("Division by zero error!");
        }
    } else if (op != '+' && op != '-' && op != '*' && op != '/') {
        // 유효하지 않은 연산자일 경우, 사용자 정의 예외를 던집니다.
        throw OutOfRangeException("Invalid operator provided.");
    }

    // 간단한 계산 로직 (예외 발생 조건에만 집중)
    if (op == '+') return val1 + val2;
    if (op == '-') return val1 - val2;
    if (op == '*') return val1 * val2;
    if (op == '/') return static_cast<double>(val1) / val2;

    return 0.0; // 도달하지 않는 코드
}

int main() {
    // 0으로 나누기 예외 상황
    std::cout << "--- Case 1: Division by Zero ---" << std::endl;
    try {
        double result = calculate(10, 0, '/');
        std::cout << "Result: " << result << std::endl;
    } catch (const std::invalid_argument& e) { // std::invalid_argument 예외를 잡습니다.
        std::cerr << "Error (Invalid Argument): " << e.what() << std::endl;
    } catch (const OutOfRangeException& e) {
        std::cerr << "Error (Out of Range): " << e.what() << std::endl;
    } catch (const std::exception& e) { // 모든 표준 예외의 부모 클래스를 잡습니다.
        std::cerr << "Error (General Std Exception): " << e.what() << std::endl;
    } catch (...) { // 그 외 모든 예외를 잡습니다.
        std::cerr << "Error (Unknown Exception)!" << std::endl;
    }
    std::cout << "--- End of Case 1 ---" << std::endl << std::endl;


    // 유효하지 않은 연산자 예외 상황
    std::cout << "--- Case 2: Invalid Operator ---" << std::endl;
    try {
        double result = calculate(5, 2, '%'); // 유효하지 않은 연산자
        std::cout << "Result: " << result << std::endl;
    } catch (const std::invalid_argument& e) {
        std::cerr << "Error (Invalid Argument): " << e.what() << std::endl;
    } catch (const OutOfRangeException& e) { // OutOfRangeException 예외를 잡습니다.
        std::cerr << "Error (Out of Range): " << e.what() << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "Error (General Std Exception): " << e.what() << std::endl;
    } catch (...) {
        std::cerr << "Error (Unknown Exception)!" << std::endl;
    }
    std::cout << "--- End of Case 2 ---" << std::endl << std::endl;

    // 정상 실행 상황
    std::cout << "--- Case 3: Normal Calculation ---" << std::endl;
    try {
        double result = calculate(20, 5, '*');
        std::cout << "Result: " << result << std::endl;
    } catch (const std::invalid_argument& e) {
        std::cerr << "Error (Invalid Argument): " << e.what() << std::endl;
    } catch (const OutOfRangeException& e) {
        std::cerr << "Error (Out of Range): " << e.what() << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "Error (General Std Exception): " << e.what() << std::endl;
    } catch (...) {
        std::cerr << "Error (Unknown Exception)!" << std::endl;
    }
    std::cout << "--- End of Case 3 ---" << std::endl << std::endl;

    return 0;
}
다중 catch 블록 실행 결과
--- Case 1: Division by Zero ---
Error (Invalid Argument): Division by zero error!
--- End of Case 1 ---

--- Case 2: Invalid Operator ---
Error (Out of Range): Invalid operator provided.
--- End of Case 2 ---

--- Case 3: Normal Calculation ---
Result: 100
--- End of Case 3 ---
  1. calculate 함수는 연산자에 따라 두 가지 다른 종류의 예외를 던질 수 있습니다.
    • std::invalid_argument: 0으로 나누는 경우와 같이, 함수에 전달된 인수가 유효하지 않을 때 사용되는 표준 예외입니다. <stdexcept> 헤더에 정의되어 있습니다.
    • OutOfRangeException: 유효하지 않은 연산자가 입력되었을 때 던질 사용자 정의 예외입니다. std::runtime_error를 상속받아 만들었는데, 이는 C++ 표준 라이브러리의 예외 계층 구조를 따르는 좋은 방법입니다. what() 메서드를 통해 예외 메시지를 얻을 수 있습니다.
  2. main 함수에서는 calculate 함수 호출을 각각 다른 try 블록으로 감싸고, 그 뒤에 여러 개의 catch 블록을 두어 다양한 예외 상황에 대비합니다.
  3. catch 블록은 위에서부터 순서대로 매칭을 시도합니다. 따라서 더 구체적인 예외 타입을 먼저 배치하고, 더 일반적인 예외 타입 (예: std::exception 또는 ...)은 나중에 배치하는 것이 중요합니다.
    • std::invalid_argumentOutOfRangeExceptionstd::exception의 자식 클래스이므로, std::exception을 먼저 잡는다면 구체적인 예외를 처리할 기회를 잃게 됩니다.
  4. catch (...)는 모든 타입의 예외를 잡는 '만능' catch 블록입니다. 이 블록은 일반적으로 예상치 못한 예외가 발생했을 때 프로그램이 비정상적으로 종료되는 것을 방지하기 위한 최후의 수단으로 사용됩니다. 하지만 어떤 예외가 던져졌는지 정확히 알 수 없으므로, 디버깅이 어려워질 수 있습니다. 따라서 가능한 한 구체적인 예외를 명시하여 처리하는 것이 바람직합니다.

예외 객체 참조로 받기

catch 블록에서 예외 객체를 받을 때 const 참조(const 예외_타입&)로 받는 것이 일반적입니다.

  • 성능: 예외 객체가 복사되는 것을 방지하여 불필요한 오버헤드를 줄입니다.
  • 다형성: 예외 클래스가 상속 계층 구조를 가질 때, 부모 클래스 참조로 자식 클래스의 예외를 잡을 수 있게 해줍니다. (예: catch (const std::exception& e))
  • 수정 방지: const를 사용하여 예외 객체의 내용이 catch 블록 내에서 변경되지 않도록 보장합니다.

throw 문 이후의 동작

throw 문이 실행되면, 해당 try 블록의 나머지 코드는 더 이상 실행되지 않습니다.

즉시 제어권이 가장 적절한 catch 블록으로 넘어갑니다. 이는 함수의 return 문과 유사하게, 현재 실행 흐름을 중단하고 새로운 흐름으로 전환하는 역할을 합니다.

이 점을 이해하는 것은 예외가 발생했을 때 코드의 동작을 예측하는 데 매우 중요합니다.