C++ 예외 처리: try/catch/throw, 예외 안전성, 언제 예외를 쓰나
이 글의 핵심
try/catch/throw 기본과 표준 예외 계층에서 출발해 예외와 에러 코드 중 무엇을 쓸지, RAII로 예외 안전성을 확보하는 법, 스택 풀기 메커니즘과 예외 객체의 수명, 제로 오버헤드 모델의 실제 비용, noexcept가 컨테이너 최적화에 주는 영향까지 다룹니다.
기본 예외 처리
throw로 예외를 던지면 실행은 그 자리에서 멈추고, 호출 스택을 거슬러 올라가며 타입이 맞는 catch를 찾습니다. 그 사이에 있는 지역 객체는 모두 소멸자가 호출됩니다.
#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 (const runtime_error& e) {
cout << "에러: " << e.what() << endl;
}
return 0;
}
표준 예외 클래스
표준 예외는 모두 std::exception을 뿌리로 하는 계층을 이룹니다. 주요 클래스만 추리면 다음과 같습니다.
std::exception <exception>
├─ logic_error <stdexcept> 호출자의 실수(전제 조건 위반)
│ ├─ invalid_argument
│ ├─ domain_error
│ ├─ length_error
│ └─ out_of_range (vector::at 등)
├─ runtime_error <stdexcept> 실행 중에야 알 수 있는 실패
│ ├─ range_error
│ ├─ overflow_error
│ ├─ underflow_error
│ └─ system_error <system_error> (OS 오류 코드 포함)
├─ bad_alloc <new> 메모리 할당 실패
├─ bad_cast <typeinfo> 참조 dynamic_cast 실패
└─ bad_optional_access 등
여러 예외 처리
catch는 위에서부터 순서대로 검사되고, 처음으로 타입이 맞는 하나만 실행됩니다. 그래서 파생 클래스를 기본 클래스보다 먼저 써야 합니다. catch (const std::exception&)를 맨 위에 두면 아래의 out_of_range 핸들러는 절대 실행되지 않고, GCC·Clang은 이런 순서에 경고를 냅니다. catch (...)는 타입을 알 수 없는 예외까지 잡으므로 항상 마지막에 둡니다.
try {
// 예외 발생 가능한 코드
} catch (const out_of_range& e) {
cout << "범위 초과: " << e.what() << endl;
} catch (const invalid_argument& e) {
cout << "잘못된 인자: " << e.what() << endl;
} catch (const 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 (const 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 (const InvalidAgeException& e) {
cerr << "에러: " << e.what() << endl;
}
return 0;
}
생성자는 값을 반환할 수 없으므로, 잘못된 인자로 객체를 만들려 할 때 실패를 알리는 표준적인 방법이 예외입니다. 예외가 생성자 밖으로 나가면 객체는 만들어지지 않은 것으로 취급되어 p2.print()는 실행되지 않습니다. 실무에서는 std::exception을 직접 상속하기보다 std::invalid_argument처럼 의미가 맞는 표준 예외를 상속하면 메시지 저장도 대신 해 줍니다.
예시 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 {
// 수동 관리: 예외가 나면 delete까지 가지 못해 누수
// Resource* r = new Resource();
// dangerousFunction();
// delete r; // 실행되지 않음
// RAII: 스코프를 벗어날 때 자동 해제
unique_ptr<Resource> r = make_unique<Resource>();
r->use();
dangerousFunction();
// 예외 발생해도 자동으로 해제됨
} catch (const exception& e) {
cout << "에러: " << e.what() << endl;
}
return 0;
}
예외가 try 블록을 빠져나가는 순간 unique_ptr의 소멸자가 호출되므로, 출력은 “리소스 할당 → 리소스 사용 → 리소스 해제 → 에러: 에러 발생!” 순서가 됩니다. 해제가 catch보다 먼저 일어난다는 점이 핵심입니다.
자주 발생하는 문제
문제 1: 예외를 값으로 캐치
기본 클래스 타입의 값으로 잡으면 예외 객체가 기본 클래스 부분만 복사되어 잘립니다(slicing). 그러면 what()도 파생 클래스의 메시지 대신 std::exception의 기본 문자열을 돌려줄 수 있습니다.
// 잘못: 값으로 잡으면 runtime_error 부분이 잘려 나감
try {
throw runtime_error("에러");
} catch (exception e) {
cout << e.what() << endl; // 구현 정의 문자열(예: "std::exception")
}
// 올바름: const 참조로 잡으면 복사도, 잘림도 없음
try {
throw runtime_error("에러");
} catch (const exception& e) {
cout << e.what() << endl; // "에러"
}
문제 2: 소멸자에서 예외 던지기
C++11부터 소멸자는 따로 지정하지 않으면 암묵적으로 noexcept입니다. 그래서 소멸자 밖으로 예외가 나가면 다른 예외가 전파 중이 아니어도 즉시 std::terminate가 호출됩니다. noexcept(false)를 붙여 이 규칙을 피하더라도, 스택 풀기 도중 호출된 소멸자에서 예외가 나가면 여전히 std::terminate입니다.
// 잘못: 소멸자는 암묵적 noexcept라 여기서 terminate
class BadResource {
public:
~BadResource() {
throw runtime_error("에러");
}
};
// 올바름: 실패할 수 있는 정리는 소멸자 안에서 처리하고 밖으로 내보내지 않음
class Resource {
public:
~Resource() {
try {
flush(); // 실패할 수 있는 작업
} catch (...) {
// 로그를 남기고 삼킴
}
}
void flush();
};
정리 실패를 호출자가 알아야 한다면 close()처럼 예외를 던질 수 있는 명시적 함수를 따로 두고, 소멸자는 그 함수가 호출되지 않았을 때의 마지막 안전망으로만 쓰는 것이 일반적입니다.
문제 3: 예외 명세 (exception specification)
동적 예외 명세(throw(타입 목록))는 C++11에서 deprecated되었고 C++17에서 제거되어 C++17 이상으로 컴파일하면 오류입니다. 빈 명세 throw()도 C++17에서 noexcept(true)의 별칭이 되었다가 C++20에서 제거되었습니다.
// C++17 이후 컴파일 오류
void func() throw(int, runtime_error) {
// ...
}
// noexcept 사용
void func() noexcept { // 예외를 밖으로 내보내지 않음
// ...
}
void func2() noexcept(false) { // 예외 던질 수 있음
// ...
}
예외 vs 에러 코드
| 측면 | 예외 | 에러 코드(expected, optional, bool 등) |
|---|---|---|
| 제어 흐름 | 실패가 드물 때 깔끔 | 실패가 흔한 API에 적합 |
| 성능 | 예외 경로는 상대적으로 비용 큼 | 핫 루프에서 예측 가능 |
| C 연동 | C API와 섞기 어려움 | errno·리턴 코드와 잘 맞음 |
| 가시성 | 함수 시그니처만 보면 던짐 여부를 알 수 없음 | 반환 타입에 실패가 드러나 호출부 처리를 강제하기 쉬움 |
C++23의 std::expected<T, E>는 “값 또는 에러”를 타입으로 표현해, 예외 없이도 실패를 명시적으로 전달할 수 있습니다. 일반적인 기준은 실패가 드물고 호출 스택 여러 단계를 건너 처리해야 하는 경우(설정 파일 손상, 메모리 부족, 생성자 실패)에는 예외를, 실패가 흔하고 바로 위 호출자가 처리하는 경우(사용자 입력 파싱, 캐시 미스, 파일 없음 확인)에는 expected나 반환값을 쓰는 것입니다. 정상적인 제어 흐름이나 성능이 중요한 루프 안에서 예외를 쓰는 것은 피합니다.
RAII와 예외 안전성
RAII: 자원은 생성자에서 잡고 소멸자에서 놓습니다. 예외가 나도 스택 풀기로 소멸자가 호출되므로 누수를 막습니다.
예외 안전성 보장의 전형적인 세 단계는 다음과 같습니다.
- 기본 보장(basic): 예외가 나도 리소스 누수 없이 유효한(일관성이 깨질 수는 있는) 상태로 남습니다.
- 강한 보장(strong): 실패 시 상태가 변하지 않음(트랜잭션·복사-교체 관용구와 연결).
- 예외 없음 보장(nothrow): 연산이 예외를 던지지 않음(
noexcept로 표현 가능).
예를 들어 vector::push_back은 원소의 이동 생성자가 예외를 던지지 않거나 복사 가능하면 강한 보장을 제공합니다. 이처럼 표준 컨테이너의 예외 보장을 알아두면 설계가 쉬워집니다. 커스텀 클래스도 복사 대입·이동 대입이 예외를 낼 때 클래스 불변식이 어떻게 되는지 문서화하는 것이 좋습니다.
스택 풀기(stack unwinding)의 메커니즘
throw가 발생하면 실행은 가장 가까운 적합한 catch 절을 찾을 때까지 호출 스택을 따라 올라갑니다. 그 과정에서 아직 파괴되지 않은 자동 저장 기간 객체의 소멸자가 역순으로 호출됩니다. 이것이 예외 안전 RAII의 근간입니다.
구현 관점에서 보면(Itanium C++ ABI 등), 컴파일러는 함수마다 Landing Pad와 Personality routine을 통해 “어디서 어떤 타입을 잡을지”를 결정합니다. DWARF 등의 디버그·예외 메타데이터와 함께, 런타임은 스택을 되짚으며 필요한 소멸자 호출과 catch 핸들러 진입을 조정합니다. 세부는 툴체인·ABI에 따라 다르지만, 언어 의미론은 동일합니다: 풀기 도중에도 자동 객체는 정상적으로 파괴됩니다.
주의할 점은 다음과 같습니다.
- 풀기 중 두 번째 예외: 이미 예외가 전파되는 중인데 소멸자 등에서 다시
throw하면std::terminate로 이어지는 것이 일반적입니다. 따라서 소멸자·delete연산자·스왑 등은 사실상 절대 예외를 밖으로 내보내지 않는 계약에 가깝습니다. std::uncaught_exceptions()(C++17): 소멸자 안에서 “지금 예외가 활성화되어 있는가”를 세밀하게 판단할 때 쓸 수 있습니다. 예를 들어 로거가 실패 시 예외를 던질지 말지를 분기할 때 참고합니다.noexcept(false)소멸자: 명시적으로 예외를 허용한 소멸자라도, 스택 풀기 중에 호출되어 예외를 밖으로 내보내면std::terminate입니다. 운영 코드에서는 소멸자·이동·스왑을noexcept로 유지하는 편이 안전합니다.
한 가지 덜 알려진 점은, 맞는 catch를 끝내 찾지 못하면 std::terminate가 호출되는데 이때 스택 풀기가 일어나는지는 구현 정의라는 것입니다. 그래서 main이나 스레드 함수 최상단에서 예외를 잡지 않으면 지역 객체의 소멸자가 실행된다는 보장이 없습니다.
예외 객체의 수명과 저장
throw expr에서 expr의 타입이 예외 타입이 됩니다. 구현은 이 값을 예외 객체(exception object)로 복사·이동해 별도 저장소에 둔 뒤, 스택 풀기를 수행하고 마지막에 catch에 넘깁니다.
- 값으로 던지고 참조로 잡기:
throw MyError{};뒤catch (const MyError& e)가 전형적입니다. 예외 객체는 이를 처리하는catch블록이 끝날 때까지 유지됩니다. - 슬라이싱(slicing):
catch (std::exception e)처럼 값으로 잡으면 파생 클래스 정보가 잘릴 수 있습니다. 다형적으로 잡으려면const std::exception&처럼 기본 클래스에 대한 참조를 사용합니다. - 재던지기:
catch안에서throw;는 현재 처리 중인 예외 객체를 그대로 다시 전파합니다. 반면throw e;는e의 정적 타입으로 새 예외 객체를 복사해 만들므로,const std::exception& e로 잡은 뒤throw e;를 하면 원래의 파생 타입이 잘려 나갑니다. 원본 타입을 유지하려면throw;를 씁니다. std::exception_ptr:std::current_exception()으로 현재 예외를 붙잡아 다른 스레드나 비동기 완료 시점으로 넘길 수 있습니다. 포인터 자체는 타입이 지워진 핸들이지만,std::rethrow_exception으로 다시 던지면 원래 타입 그대로catch할 수 있습니다.
예외 객체가 힙에만 있다거나 스택에만 있다고 단정하기 어렵습니다. 중요한 것은 표준 보장: 적합한 catch가 매칭되면 그 참조·값은 해당 블록에서 사용할 수 있으며, catch를 빠져나가면 예외 객체는 파괴됩니다(단, exception_ptr가 아직 참조하고 있으면 마지막 참조가 사라질 때까지 유지됩니다). 대용량 페이로드를 예외로 실어 나르는 설계는 할당·복사 비용과 실패 경로의 빈도를 함께 봐야 합니다.
제로 오버헤드 예외 모델과 실제 비용
C++은 흔히 “제로 오버헤드(zero-overhead) 원칙”을 언급합니다. 예외에 대해서는 관용적으로 “성공 경로(예외가 없을 때)에 추가 비용이 거의 없다”는 의미로 사용됩니다. 즉, 매번 실패 여부를 검사하는 분기로 성공 경로를 오염시키지 않는 쪽에 무게를 둡니다.
다만 구현체 차이를 알아야 합니다.
- 테이블 기반 unwinding(GCC/Clang/LLVM 계열에서 흔함): 성공 경로는 가볍지만, 바이너리에 LSDA·예외 테이블이 붙고 예외 발생 시 테이블 탐색·풀기 비용이 큽니다. “콜드 패스” 비용이라는 말이 여기서 나옵니다.
- MSVC: x64와 ARM64에서는 마찬가지로 테이블 기반이라 성공 경로 비용이 거의 없습니다. 반면 32비트 x86에서는 함수에 진입할 때마다 예외 핸들러를 스택에 등록하는 방식이라, 예외가 나지 않아도 약간의 비용이 있습니다.
따라서 핫 루프에서 예외로 흐름 제어를 하면 안 된다는 권고가 나옵니다. 빈번한 실패는 에러 코드·std::expected·Outcome 스타일로 처리하며, 예외는 드물고 정말 비정상에 쓰는 편이 성능·예측 가능성 모두에 유리합니다.
noexcept 심화: 의미·컨테이너·최적화
- 의미:
noexcept는 “이 함수는 예외를 밖으로 던지지 않는다”는 프로그램 수준 계약입니다. 실제로 던지면std::terminate호출로 이어질 수 있습니다. 런타임 검사가 아니라 약속 위반에 대한 하드 실패에 가깝습니다. - 이동 연산과
std::vector: 재할당 시 요소를 이동하려면 이동이 예외를 내면 안 되는 경우가 많습니다.std::vector는 재할당 때std::move_if_noexcept로 원소를 옮기므로, 이동 생성자가noexcept이면 이동하고, 예외를 던질 수 있으면서 복사가 가능하면 강한 보장을 지키기 위해 복사합니다. 이동 생성자에noexcept를 빠뜨리면 재할당마다 전체 복사가 일어날 수 있는 이유가 이것입니다. - 컴파일러 최적화:
noexcept함수는 호출 측이 예외 전파를 고려하지 않아도 되는 경우가 있어, 인라인·코드 배치·데드 스토어 제거 등에 유리할 수 있습니다. 실제로 얼마나 차이가 나는지는 컴파일러와 타깃에 따라 다릅니다. - 소멸자: 소멸자는 기본적으로
noexcept(특정 상황 제외)로 간주됩니다. 소멸자에서 예외를 던지면 안 되는 이유가 여기와 연결됩니다.
void swap(MyType& a, MyType& b) noexcept {
// 멤버 swap만 호출하고 예외 없음을 보장할 때
}
noexcept(expr)로 조건부로 지정할 수 있습니다. 예: noexcept(noexcept(swap(std::declval<T&>(), std::declval<T&>()))) 패턴은 표준 라이브러리에서도 광범위하게 사용됩니다.
프로덕션 예외 패턴
문서·튜토리얼과 달리, 운영 서비스에서는 예외를 “편의 기능”이 아니라 관측·복구·경계 계약과 함께 설계합니다.
- 경계에서만 잡기: 라이브러리 코어는 예외를 전파하며, 스레드 상단·요청 핸들러·main에서 한 번에 로깅·메트릭·응답 코드로 변환합니다. 내부에서 모든 함수를
try로 둘러싸면 원인 추적이 어려워집니다. - 예외 금지 구역: 실시간 루프·오디오 콜백·드라이버 경로 등에서는 예외 없음을 강제하며, 실패는 큐에 넘기거나 에러 코드로 처리합니다. 여기서
noexcept와 정적 분석 규칙이 함께 갑니다. std::nested_exception/std::throw_with_nested: 저수준 오류 메시지 위에 도메인 컨텍스트를 얹어 스택을 보존합니다. 운영 로그에서 “왜 여기까지 왔는지”를 재구성하기 쉬워집니다.exception_ptr: 워커 스레드에서 잡은 예외를 메인으로 넘겨 동일 타입으로 처리할 때 사용합니다. 비동기 프레임워크와 잘 맞습니다.- 관측: 예외율·타입별 카운트·스택 샘플링을 메트릭으로 올립니다. 예외가 정상 제어 흐름이 되면 지표가 무의미해집니다.
- 생성자 실패: “반쯤 초기화된 객체”를 피하려면 팩토리 함수가
expected를 반환하거나 예외를 던지게 하며, 팀 규칙을 하나로 통일합니다. - 테스트: Catch2/GoogleTest 등으로 던져야 할 예외와 절대 던지면 안 되는
noexceptAPI를 분리해 검증합니다.
같이 보면 좋은 글
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
- [Go 2주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라
- C++ throw()가 폐기되고 noexcept로 바뀐 이유: vector 재할당에서 이동 대신 복사가 일어나는 경우
- JavaScript 에러 처리 | try-catch, Error 객체, 커스텀 에러