C++ 예외 처리 | try/catch/throw "완벽 정리" [에러 처리]

C++ 예외 처리 | try/catch/throw "완벽 정리" [에러 처리]

이 글의 핵심

try·catch·throw 기본부터 RAII·예외 안전성·noexcept까지. 예외와 에러 코드 선택, 실무 패턴과 흔한 실수를 한 번에 정리합니다.

기본 예외 처리

#include <iostream>
#include <stdexcept>
using namespace std;

int divide(int a, int b) {
    if (b == 0) {
        throw runtime_error("0으로 나눌 수 없습니다");
    }
    return a / b;
}

int main() {
    try {
        int result = divide(10, 0);
        cout << result << endl;
    } catch (runtime_error& e) {
        cout << "에러: " << e.what() << endl;
    }
    
    return 0;
}

표준 예외 클래스

#include <exception>
#include <stdexcept>

// 기본 예외
exception

// 논리 에러
logic_error
  ├─ invalid_argument
  ├─ domain_error
  ├─ length_error
  └─ out_of_range

// 런타임 에러
runtime_error
  ├─ range_error
  ├─ overflow_error
  └─ underflow_error

여러 예외 처리

try {
    // 예외 발생 가능한 코드
} catch (out_of_range& e) {
    cout << "범위 초과: " << e.what() << endl;
} catch (invalid_argument& e) {
    cout << "잘못된 인자: " << e.what() << endl;
} catch (exception& e) {
    cout << "기타 에러: " << e.what() << endl;
} catch (...) {
    cout << "알 수 없는 에러" << endl;
}

실전 예시

예시 1: 파일 처리 예외

#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace std;

string readFile(const string& filename) {
    ifstream file(filename);
    
    if (!file.is_open()) {
        throw runtime_error("파일을 열 수 없습니다: " + filename);
    }
    
    string content, line;
    while (getline(file, line)) {
        content += line + "\n";
    }
    
    return content;
}

int main() {
    try {
        string content = readFile("config.txt");
        cout << content << endl;
    } catch (runtime_error& e) {
        cerr << "에러: " << e.what() << endl;
        return 1;
    }
    
    return 0;
}

설명: 파일 처리 시 예외를 던져서 에러를 명확하게 처리합니다.

예시 2: 커스텀 예외 클래스

#include <iostream>
#include <exception>
#include <string>
using namespace std;

class InvalidAgeException : public exception {
private:
    string message;
    
public:
    InvalidAgeException(int age) {
        message = "잘못된 나이: " + to_string(age);
    }
    
    const char* what() const noexcept override {
        return message.c_str();
    }
};

class Person {
private:
    string name;
    int age;
    
public:
    Person(string n, int a) : name(n) {
        if (a < 0 || a > 150) {
            throw InvalidAgeException(a);
        }
        age = a;
    }
    
    void print() {
        cout << name << ", " << age << "살" << endl;
    }
};

int main() {
    try {
        Person p1("Alice", 25);
        p1.print();
        
        Person p2("Bob", 200);  // 예외 발생
        p2.print();
    } catch (InvalidAgeException& e) {
        cerr << "에러: " << e.what() << endl;
    }
    
    return 0;
}

설명: 도메인 특화 예외를 만들어 에러를 더 명확하게 표현합니다.

예시 3: RAII와 예외 안전성

#include <iostream>
#include <memory>
using namespace std;

class Resource {
public:
    Resource() { cout << "리소스 할당" << endl; }
    ~Resource() { cout << "리소스 해제" << endl; }
    void use() { cout << "리소스 사용" << endl; }
};

void dangerousFunction() {
    throw runtime_error("에러 발생!");
}

int main() {
    try {
        // ❌ 수동 관리 (예외 시 누수)
        // Resource* r = new Resource();
        // dangerousFunction();
        // delete r;  // 실행 안됨!
        
        // ✅ RAII (자동 해제)
        unique_ptr<Resource> r = make_unique<Resource>();
        r->use();
        dangerousFunction();
        // 예외 발생해도 자동으로 해제됨
        
    } catch (exception& e) {
        cout << "에러: " << e.what() << endl;
    }
    
    return 0;
}

설명: 스마트 포인터를 사용하면 예외 발생 시에도 리소스가 안전하게 해제됩니다.

자주 발생하는 문제

문제 1: 예외를 값으로 캐치

증상: 예외 객체가 잘림 (slicing)

원인: 값으로 캐치하면 파생 클래스 정보 손실

해결법:

// ❌ 잘못된 코드
try {
    throw runtime_error("에러");
} catch (exception e) {  // 값으로 캐치
    // 파생 클래스 정보 손실
}

// ✅ 올바른 코드
try {
    throw runtime_error("에러");
} catch (exception& e) {  // 참조로 캐치
    cout << e.what() << endl;
}

// ✅ const 참조 (더 안전)
catch (const exception& e) {
    cout << e.what() << endl;
}

문제 2: 소멸자에서 예외 던지기

증상: 프로그램 비정상 종료

원인: 소멸자에서 예외가 발생하면 terminate() 호출

해결법:

// ❌ 위험한 코드
class Resource {
public:
    ~Resource() {
        throw runtime_error("에러");  // 절대 안됨!
    }
};

// ✅ 올바른 코드
class Resource {
public:
    ~Resource() noexcept {
        try {
            // 위험한 작업
        } catch (...) {
            // 예외 삼킴
        }
    }
};

문제 3: 예외 명세 (exception specification)

증상: throw() 사용 시 경고 또는 에러

원인: throw()는 C++11에서 deprecated

해결법:

// ❌ 구식 (deprecated)
void func() throw(int, runtime_error) {
    // ...
}

// ✅ noexcept 사용
void func() noexcept {  // 예외 안던짐
    // ...
}

void func2() noexcept(false) {  // 예외 던질 수 있음
    // ...
}

try-catch 기본 정리

  • try: 예외가 날 수 있는 코드 블록.
  • throw: throw 표현식의 타입이 첫 번째로 타입이 일치하는 catch로 전달됩니다. 파생 클래스를 잡으려면 기본 클래스보다 앞에 둡니다(아니면 slicing·놓침).
  • catch (...): 타입을 모를 때; 보통 마지막에 두고, 로깅 후 std::terminate를 피하기 위해 재던지기 여부를 신중히 결정합니다.
try {
    mayThrow();
} catch (const std::invalid_argument& e) {
    // 구체적 처리
} catch (const std::exception& e) {
    // 표준 예외 공통
} catch (...) {
    // 알 수 없는 예외
}

참조로 잡기: catch (const std::exception& e)가 값 복사보다 안전하고 효율적입니다.

예외 vs 에러 코드

측면예외에러 코드(expected, optional, bool 등)
제어 흐름실패가 드물 때 깔끔실패가 흔한 API에 적합
성능예외 경로는 상대적으로 비용 큼핫 루프에서 예측 가능
C 연동C API와 섞기 어려움errno·리턴 코드와 잘 맞음
가시성함수 시그니처만 보면 던짐 여부가 불명확할 수 있음(C++17까지)호출부에서 실패 처리 강제하기 쉬움

C++23의 **std::expected<T, E>는 “값 또는 에러”를 타입으로 표현해, 예외 없이도 실패를 명시적으로 전달할 수 있습니다. 팀 규칙으로 “예외는 정말 예외적인 실패만”**으로 한정하면 혼선이 줄어듭니다.

RAII와 예외 안전성

RAII: 자원은 생성자에서 잡고 소멸자에서 놓습니다. 예외가 나도 스택 풀기로 소멸자가 호출되므로 누수를 막습니다.

예외 안전성 보장의 전형적인 세 단계는 다음과 같습니다.

  • 기본 보장(basic): 예외가 나도 리소스 누수 없이 유효한(일관성이 깨질 수는 있는) 상태로 남습니다.
  • 강한 보장(strong): 실패 시 상태가 변하지 않음(트랜잭션·복사-교체 관용구와 연결).
  • 끊김 없는 보장(nothrow): 연산이 예외를 던지지 않음(noexcept로 표현 가능).

실무에서는 vector::push_back이 실패하면 무엇이 보장되는지처럼 표준 컨테이너의 예외 보장을 알아두면 설계가 쉬워집니다. 커스텀 클래스도 복사 대입·이동 대입이 예외를 낼 때 클래스 불변식이 어떻게 되는지 문서화하는 것이 좋습니다.

noexcept 심화

  • 의미: “이 함수는 예외를 밖으로 던지지 않는다.” 위반 시 std::terminate로 이어질 수 있습니다.
  • 이동 연산: std::vector가 재할당 시 이동을 쓰려면 이동이 noexcept인 경우가 많아, 이동 생성자/대입에 noexcept를 붙이는 것이 성능에 직결됩니다.
  • 소멸자: 소멸자는 암시적으로 noexcept입니다. 소멸자에서 예외를 던지면 안 됩니다.
void swap(MyType& a, MyType& b) noexcept {
    // 멤버 swap만 호출하고 예외 없음을 보장할 때
}

noexcept(expr) 형태로 조건부로 지정할 수도 있습니다.

실전 패턴

  1. 생성자 실패: 생성자에서는 반환값이 없으므로 유효하지 않은 상태를 두기 어렵다면 예외(또는 별도 팩토리 + expected)를 씁니다.
  2. 경계에서만 catch: 라이브러리 내부는 예외를 전파하고, UI·main 근처에서 로깅·복구합니다.
  3. std::nested_exception: 저수준에서 잡은 예외를 감싸 상위로 올릴 때 원인 보존.
  4. 테스트: REQUIRE_THROWS_AS (Catch2 등)로 예외 타입을 고정합니다.
  5. 신규 코드 가이드: 핫 루프는 에러 코드, I/O·파싱 실패는 예외처럼 팀 컨벤션을 한 가지로 정합니다.

FAQ

Q1: 예외 처리는 느린가요?

A: 예외가 발생하지 않으면 거의 오버헤드가 없습니다. 예외 발생 시에만 느립니다.

Q2: 언제 예외를 사용해야 하나요?

A:

  • 복구 불가능한 에러
  • 생성자 실패
  • 깊은 호출 스택에서 에러 전파

사용하지 말아야 할 때:

  • 정상적인 제어 흐름
  • 성능이 중요한 루프 내부

Q3: return vs throw?

A:

  • return: 정상 종료, 예상된 결과
  • throw: 비정상 상황, 에러

Q4: 모든 예외를 잡아야 하나요?

A: 처리할 수 있는 예외만 잡으세요. 처리 못하면 상위로 전파하는 것이 좋습니다.

Q5: noexcept는 왜 사용하나요?

A:

  • 컴파일러 최적화 가능
  • move 생성자에 필수 (vector 등에서 move 사용)
  • 의도 명확화

Q6: 예외 vs 에러 코드?

A:

  • 예외: 깔끔한 코드, 에러 전파 쉬움
  • 에러 코드: 성능 중요, C 호환 필요

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
  • [Go 2주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라
  • C++ Exception Specifications | “예외 명세” 가이드

관련 글

  • JavaScript 에러 처리 | try-catch, Error 객체, 커스텀 에러