C++ 예외 처리 | try/catch/throw '완벽 정리' [에러 처리]
이 글의 핵심
C++ 예외 처리의 핵심 개념과 실무 포인트를 정리합니다. 스택 풀기·예외 객체 수명·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이 실패하면 무엇이 보장되는지처럼 표준 컨테이너의 예외 보장을 알아두면 설계가 쉬워집니다. 커스텀 클래스도 복사 대입·이동 대입이 예외를 낼 때 클래스 불변식이 어떻게 되는지 문서화하는 것이 좋습니다.
스택 풀기(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가 거짓인 소멸자: 풀기 중 해당 소멸자가 예외를 던지면 동일하게 종료 규칙에 걸리기 쉽습니다. 운영 코드에서는 소멸자·이동·스왑을
noexcept로 고정하는 편이 안전합니다.
요약하면, 스택 풀기는 “예외가 지나가는 경로에 있는 지역 객체의 수명 종료를 보장하는 절차”이며, 그 절차를 깨지 않도록 소멸 경로를 예외 없음에 가깝게 유지하는 것이 전문가 수준의 기본기입니다.
예외 객체의 수명과 저장
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;와 다름). 원본 타입·스택 정보를 유지하려면throw;를 쓰는 것이 맞습니다. std::exception_ptr:std::current_exception()으로 잡아 다른 스레드나 비동기 완료 시점으로 넘길 수 있습니다. “예외를 값처럼 옮기되, 타입 소거 없이” 다룰 때 씁니다.
예외 객체가 힙에만 있다거나 스택에만 있다고 단정하기 어렵습니다. 중요한 것은 표준 보장: 적합한 catch가 매칭되면 그 참조·값은 해당 블록에서 사용할 수 있고, catch를 빠져나가면 예외 객체는 파괴됩니다. 대용량 페이로드를 예외로 실어 나르는 설계는 할당·복사 비용과 실패 경로의 빈도를 함께 봐야 합니다.
제로 오버헤드 예외 모델과 실제 비용
C++은 흔히 “제로 오버헤드(zero-overhead) 원칙”을 언급합니다. 예외에 대해서는 관용적으로 “성공 경로(예외가 없을 때)에 추가 비용이 거의 없다”는 의미로 쓰입니다. 즉, 매번 실패 여부를 검사하는 분기로 성공 경로를 오염시키지 않는 쪽에 무게를 둡니다.
다만 구현체 차이를 알아야 합니다.
- 테이블 기반 unwinding(GCC/Clang/LLVM 계열에서 흔함): 성공 경로는 가볍지만, 바이너리에 LSDA·예외 테이블이 붙고 예외 발생 시 테이블 탐색·풀기 비용이 큽니다. “콜드 패스” 비용이라는 말이 여기서 나옵니다.
- MSVC 등 다른 전략: 세부는 다르지만, 예외 발생 시 비용이 크고, 성공 경로 최적화를 우선하는 철학은 유사하게 이해할 수 있습니다.
그래서 핫 루프에서 예외로 흐름 제어를 하면 안 된다는 권고가 나옵니다. 빈번한 실패는 에러 코드·std::expected·Outcome 스타일로 처리하고, 예외는 드물고 진짜 비정상에 쓰는 편이 성능·예측 가능성 모두에 유리합니다.
noexcept 심화: 의미·컨테이너·최적화
- 의미:
noexcept는 “이 함수는 예외를 밖으로 던지지 않는다”는 프로그램 수준 계약입니다. 실제로 던지면std::terminate호출로 이어질 수 있습니다. 런타임 검사가 아니라 약속 위반에 대한 하드 실패에 가깝습니다. - 이동 연산과
std::vector: 재할당 시 요소를 이동하려면 이동이 예외를 내면 안 되는 경우가 많습니다. 이동 생성자·이동 대입이noexcept일 때 강한 예외 보장을 유지하며 이동을 선택하는 패턴이 흔합니다. 반면 이동이 예외를 던질 수 있으면 복사로 되돌아가 오버헤드가 커질 수 있습니다. - 컴파일러 최적화:
noexcept함수는 호출 측이 예외 전파를 고려하지 않아도 되는 경우가 있어, 인라인·코드 배치·데드 스토어 제거 등에 유리할 수 있습니다. 반대로 거짓인noexcept는 호출부에 unwind 준비가 남을 수 있습니다. (정확한 최적화 결과는-O2·-O3와 타깃에 따라 다릅니다.) - 소멸자: 소멸자는 기본적으로
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를 분리해 검증합니다.
기존 실무 팁과 합치면 다음과 같습니다: 핫 루프는 에러 코드, I/O·파싱·도메인 규칙 위반은 예외(또는 expected)처럼 팀 컨벤션을 단일화하고, 스택 풀기·소멸자 계약·ABI 비용을 염두에 두면 설계 품질이 한 단계 올라갑니다.
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 | “예외 명세” 가이드
관련 글
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「C++ 예외 처리 | try/catch/throw ‘완벽 정리’ [에러 처리]」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ 예외 처리 | try/catch/throw ‘완벽 정리’ [에러 처리]」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, 예외처리, try-catch, throw, 에러처리, noexcept, RAII 등으로 검색하시면 이 글이 도움이 됩니다.