C++ 커스텀 예외 클래스 만들기 | 예외 성능과 Zero-Cost Exception
이 글의 핵심
C++ 사용자 정의 예외 클래스 완벽 가이드. std::exception 상속해 커스텀 예외 만들기, what() 오버라이드, 예외 성능 측정·최적화, zero-cost exception 원리, 예외 vs 에러 코드 선택 기준을 실전 벤치마크와 함께 설명합니다.
들어가며: “예외가 느려서 서버가 죽었어요”
루프에서 예외를 던졌더니 성능이 100배 느려졌다
API 서버에서 요청을 검증하는 코드를 작성했습니다. 잘못된 요청이 들어오면 예외를 던지도록 했는데, 부하 테스트에서 서버가 멈췄습니다.
문제의 코드:
void validateRequest(const Request& req) {
if (req.userId.empty()) {
throw std::invalid_argument("userId required");
}
if (req.action.empty()) {
throw std::invalid_argument("action required");
}
}
void handleRequests(const std::vector<Request>& requests) {
for (const auto& req : requests) {
try {
validateRequest(req);
process(req);
} catch (const std::exception& e) {
logError(e.what());
}
}
}
위 코드 설명: 루프 안에서 validateRequest가 실패할 때마다 예외를 던지고 catch에서 로그만 남깁니다. 잘못된 요청 비율이 높으면 예외가 수만 번 발생하고, 예외 던지기/잡기는 스택 언와인딩 비용 때문에 에러 코드 검사보다 훨씬 느려져 성능이 급격히 떨어집니다.
문제:
- 잘못된 요청이 50%였음
- 루프에서 예외를 수만 번 던짐
- 예외 던지기/잡기는 에러 코드보다 100배 이상 느림
정리: 예외는 “예외적인” 상황용이 맞습니다. 검증 실패처럼 자주 발생하는 실패는 반환값·optional·에러 코드로 처리하고, 예외는 정말 드문 오류(파일 없음, 네트워크 끊김 등)에만 쓰면 성능과 가독성 모두 유리합니다.
해결 후:
struct ValidationResult {
bool valid;
std::string error;
};
ValidationResult validateRequest(const Request& req) {
if (req.userId.empty()) {
return {false, "userId required"};
}
if (req.action.empty()) {
return {false, "action required"};
}
return {true, ""};
}
void handleRequests(const std::vector<Request>& requests) {
for (const auto& req : requests) {
auto result = validateRequest(req);
if (!result.valid) {
logError(result.error);
continue;
}
process(req);
}
}
위 코드 설명: 검증 실패를 반환값(ValidationResult)으로 돌려주고, 호출부에서 valid 여부만 검사한 뒤 에러면 로그하고 continue합니다. 예외를 쓰지 않으므로 루프 안에서도 비용이 거의 없고, 자주 나오는 실패는 이렇게 처리하는 것이 적절합니다.
교훈:
- 예외는 예외적인 상황에만 사용
- 빈번한 에러는 반환값으로 처리
- 성능 크리티컬한 루프에서는 예외 피하기
추가 문제 시나리오
시나리오 1: DB 연결 실패 시 타입 구분 불가
std::runtime_error만 던지면 “연결 거부”, “타임아웃”, “인증 실패”를 구분할 수 없습니다. 커스텀 예외 계층을 두면 catch (const ConnectionRefusedException&)로 재시도하고, catch (const AuthException&)로 로그인 화면으로 리다이렉트하는 식으로 분기할 수 있습니다.
시나리오 2: 파싱 에러에서 위치 정보 손실
JSON 파싱 실패 시 “어디서” 잘못됐는지 알 수 없으면 디버깅이 어렵습니다. ParseException에 line, column을 담으면 parse error at line 42, column 15처럼 정확한 에러 위치를 보여줄 수 있습니다.
시나리오 3: 네트워크 라이브러리 에러 코드 누락
errno나 GetLastError() 값을 예외에 포함하지 않으면, 운영체제/라이브러리 수준의 원인을 추적하기 어렵습니다. 커스텀 예외에 errorCode를 포함해 로그에 기록하면 문제 해결이 쉬워집니다.
시나리오 4: 예외 슬라이싱으로 파생 타입 정보 손실
catch (std::exception e)로 값으로 잡으면 NetworkException이 std::exception으로 잘려 들어가 e.what()만 남고, retry() 같은 네트워크 전용 처리 로직을 호출할 수 없습니다. 참조로 잡아야 파생 타입이 유지됩니다.
커스텀 예외를 쓰면 도메인별 에러 타입(네트워크 오류, 검증 실패 등)을 나눌 수 있어서, 상위에서 catch할 때 처리 방식을 구분하기 쉽습니다. 다만 예외를 자주 던지는 경로에서는 성능 부담이 있으므로, 이 글에서 다루는 “언제 예외, 언제 반환값” 기준을 한 번 정리해 두는 것이 좋습니다.
이 글을 읽으면:
- 커스텀 예외 클래스를 작성할 수 있습니다.
- 예외의 성능 특성을 이해할 수 있습니다.
- 예외 vs 에러 코드를 상황에 맞게 선택할 수 있습니다.
- 실전에서 예외를 효율적으로 사용할 수 있습니다.
목차
- 커스텀 예외 클래스 만들기
- 완전한 예외 계층 예제 (에러 코드·컨텍스트)
- 예외 성능 측정
- Zero-Cost Exception이란
- 예외 vs 에러 코드 선택 기준
- 자주 발생하는 에러와 해결법
- 실전 가이드라인
- 프로덕션 패턴
1. 커스텀 예외 클래스 만들기
기본 커스텀 예외
std::exception을 상속하고 what() 을 noexcept로 오버라이드해 에러 메시지를 반환하게 합니다. 생성자에서 std::string을 받아 멤버에 저장하고, what()에서는 message.c_str()을 반환합니다. 이렇게 하면 catch (const FileException& e)로 잡았을 때 e.what()으로 “Cannot open: missing.txt” 같은 메시지를 얻을 수 있고, 파일 관련 에러만 따로 처리할 수 있습니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o file_exception file_exception.cpp && ./file_exception
#include <exception>
#include <string>
#include <fstream>
#include <iostream>
class FileException : public std::exception {
std::string message;
public:
explicit FileException(const std::string& msg) : message(msg) {}
const char* what() const noexcept override { return message.c_str(); }
};
void openFile(const std::string& path) {
std::ifstream file(path);
if (!file) {
throw FileException("Cannot open: " + path);
}
}
int main() {
try {
openFile("missing.txt");
} catch (const FileException& e) {
std::cerr << "File error: " << e.what() << "\n";
}
return 0;
}
위 코드 설명: FileException은 std::exception을 상속하고, 생성자에서 받은 메시지를 멤버에 저장한 뒤 what()에서 c_str()로 반환합니다. openFile에서 파일 열기 실패 시 이 타입으로 throw하면, main에서 catch (const FileException& e)로 파일 관련 에러만 골라 처리할 수 있습니다.
실행 결과: File error: Cannot open: missing.txt 가 stderr에 출력됩니다.
계층적 예외 클래스
도메인별로 예외 타입을 나누면 catch 순서로 처리 우선순위를 정할 수 있습니다. AppException을 베이스로 두고 NetworkException, DatabaseException을 파생시키고, handleRequest에서는 파생 타입을 먼저 catch하고 마지막에 AppException으로 나머지를 처리합니다. 이렇게 하면 “네트워크만 재시도”, “DB만 캐시 사용”처럼 에러 종류별로 다른 복구 로직을 넣기 쉽습니다.
// 최상위 애플리케이션 예외
class AppException : public std::exception {
protected:
std::string message;
public:
explicit AppException(const std::string& msg) : message(msg) {}
const char* what() const noexcept override {
return message.c_str();
}
};
// 네트워크 예외
class NetworkException : public AppException {
public:
explicit NetworkException(const std::string& msg)
: AppException("Network: " + msg) {}
};
// 데이터베이스 예외
class DatabaseException : public AppException {
public:
explicit DatabaseException(const std::string& msg)
: AppException("Database: " + msg) {}
};
void handleRequest() {
try {
connectDatabase();
fetchData();
} catch (const NetworkException& e) {
// 네트워크 에러 처리
retry();
} catch (const DatabaseException& e) {
// DB 에러 처리
useCache();
} catch (const AppException& e) {
// 기타 앱 에러
logError(e.what());
}
}
위 코드 설명: AppException을 베이스로 두고 NetworkException, DatabaseException을 파생시켜 도메인별로 예외를 구분합니다. handleRequest에서는 구체적인 타입(Network, Database)을 먼저 catch해 각각 재시도·캐시 사용 등 다른 처리를 하고, 나머지는 AppException으로 한 번에 처리합니다.
추가 정보를 담는 예외
검증 실패 시 어떤 필드에서 왜 실패했는지 호출부에 넘기고 싶을 때, 예외 객체에 field와 reason을 넣고 getField(), getReason()으로 노출합니다. what()은 한 번만 문자열을 조합해 fullMessage에 저장해 두고(mutable로 선언해 const 메서드 안에서 수정 가능), 그 포인터를 반환합니다. 이렇게 하면 UI에서 “name 필드: 비어 있을 수 없습니다”처럼 구체적인 메시지를 보여줄 수 있습니다.
class ValidationException : public std::exception {
std::string field;
std::string reason;
mutable std::string fullMessage; // what()에서 생성
public:
ValidationException(const std::string& f, const std::string& r)
: field(f), reason(r) {}
const char* what() const noexcept override {
if (fullMessage.empty()) {
fullMessage = "Validation failed for '" + field + "': " + reason;
}
return fullMessage.c_str();
}
const std::string& getField() const { return field; }
const std::string& getReason() const { return reason; }
};
void validateUser(const User& user) {
if (user.name.empty()) {
throw ValidationException("name", "cannot be empty");
}
if (user.age < 0) {
throw ValidationException("age", "must be non-negative");
}
}
int main() {
try {
User user{"", -5};
validateUser(user);
} catch (const ValidationException& e) {
std::cerr << "Field: " << e.getField() << "\n";
std::cerr << "Reason: " << e.getReason() << "\n";
std::cerr << "Full: " << e.what() << "\n";
}
}
위 코드 설명: field와 reason을 멤버로 두고 getField(), getReason()으로 노출합니다. what()에서는 한 번만 fullMessage를 조합해 mutable 멤버에 저장한 뒤 그 포인터를 반환해, const 메서드 안에서도 캐시된 문자열을 쓸 수 있습니다. UI에서 “어느 필드에서 왜 실패했는지” 구체적으로 보여줄 때 유용합니다.
2. 완전한 예외 계층 예제 (에러 코드·컨텍스트)
실무에서는 에러 코드, 컨텍스트 정보, 계층 구조를 함께 갖춘 예외가 필요합니다. 아래는 웹 API 서버에서 사용할 수 있는 완전한 예외 계층 예제입니다.
예외 계층 다이어그램
flowchart TB
subgraph std["표준"]
E["std exception"]
end
subgraph app["애플리케이션"]
AE[AppException]
NE[NetworkException]
DE[DatabaseException]
VE[ValidationException]
PE[ParseException]
end
E --> AE
AE --> NE
AE --> DE
AE --> VE
AE --> PE
에러 코드 열거형
// 에러 코드: 로깅·모니터링·API 응답에 사용
enum class ErrorCode : int {
Unknown = 0,
// 네트워크 (1xxx)
ConnectionRefused = 1001,
Timeout = 1002,
DNSFailure = 1003,
// 데이터베이스 (2xxx)
ConnectionFailed = 2001,
QueryTimeout = 2002,
ConstraintViolation = 2003,
// 검증 (3xxx)
InvalidField = 3001,
MissingRequired = 3002,
// 파싱 (4xxx)
InvalidJson = 4001,
InvalidXml = 4002,
};
베이스 예외: 에러 코드 + 컨텍스트
#include <exception>
#include <string>
#include <sstream>
class AppException : public std::exception {
protected:
ErrorCode code_;
std::string message_;
std::string context_; // 파일 경로, URL, 사용자 ID 등
mutable std::string fullMessage_;
public:
explicit AppException(ErrorCode code, const std::string& msg,
const std::string& context = "")
: code_(code), message_(msg), context_(context) {}
ErrorCode code() const noexcept { return code_; }
const std::string& context() const noexcept { return context_; }
const char* what() const noexcept override {
if (fullMessage_.empty()) {
std::ostringstream oss;
oss << "[" << static_cast<int>(code_) << "] " << message_;
if (!context_.empty()) {
oss << " (context: " << context_ << ")";
}
fullMessage_ = oss.str();
}
return fullMessage_.c_str();
}
};
도메인별 파생 예외
class NetworkException : public AppException {
public:
explicit NetworkException(ErrorCode code, const std::string& msg,
const std::string& context = "")
: AppException(code, "Network: " + msg, context) {}
};
class DatabaseException : public AppException {
public:
explicit DatabaseException(ErrorCode code, const std::string& msg,
const std::string& context = "")
: AppException(code, "Database: " + msg, context) {}
};
class ValidationException : public AppException {
std::string field_;
public:
ValidationException(const std::string& field, const std::string& reason)
: AppException(ErrorCode::InvalidField,
"Validation failed: " + reason, field),
field_(field) {}
const std::string& field() const noexcept { return field_; }
};
class ParseException : public AppException {
int line_ = -1;
int column_ = -1;
public:
ParseException(ErrorCode code, const std::string& msg,
int line = -1, int column = -1)
: AppException(code, msg,
(line >= 0) ? ("line " + std::to_string(line) +
(column >= 0 ? ", column " + std::to_string(column) : "")) : ""),
line_(line), column_(column) {}
int line() const noexcept { return line_; }
int column() const noexcept { return column_; }
};
사용 예시: 에러 코드·컨텍스트 활용
void connectToDatabase(const std::string& host) {
try {
// DB 연결 시도...
} catch (const std::system_error& e) {
throw DatabaseException(
ErrorCode::ConnectionFailed,
e.what(),
"host=" + host
);
}
}
void handleRequest(const Request& req) {
try {
connectToDatabase(req.dbHost);
validateRequest(req);
} catch (const NetworkException& e) {
if (e.code() == ErrorCode::Timeout) {
retryWithBackoff();
} else {
logError(e.what(), e.code(), e.context());
return errorResponse(503, e.code());
}
} catch (const ValidationException& e) {
return errorResponse(400, e.code(), {{"field", e.field()}});
} catch (const AppException& e) {
logError(e.what(), e.code(), e.context());
return errorResponse(500, e.code());
}
}
위 코드 설명: AppException에 ErrorCode, context를 두어 로깅·API 응답·모니터링에 활용합니다. handleRequest에서는 e.code()로 타임아웃만 재시도하고, e.context()로 호스트 정보를 로그에 남깁니다. ValidationException은 field()로 어떤 필드가 잘못됐는지 API 응답에 포함합니다.
3. 예외 성능 측정
벤치마크: 예외 vs 에러 코드
정상 경로에서는 예외를 던지지 않아도 try-catch 블록이 있으면 일부 컴파일러/플랫폼에서 약간의 오버헤드가 들 수 있고, 에러 경로에서는 예외를 던지고 잡는 비용이 반환값 검사보다 훨씬 큽니다. 아래 코드는 같은 연산을 에러 코드 방식과 예외 방식으로 각각 백만 번 돌려서 걸린 시간을 비교합니다. 실무에서는 “예외는 정말 드문 경우에만” 던지도록 설계하면 성능 차이가 거의 나지 않습니다.
#include <chrono>
#include <iostream>
// 에러 코드 방식
bool errorCodePath(int value) {
if (value < 0) return false;
return true;
}
// 예외 방식
void exceptionPath(int value) {
if (value < 0) {
throw std::invalid_argument("negative");
}
}
int main() {
const int iterations = 1000000;
// 1. 에러 코드 (정상 경로)
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; ++i) {
if (!errorCodePath(1)) {
// 에러 처리
}
}
auto end = std::chrono::high_resolution_clock::now();
auto errorCodeNormal = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
// 2. 예외 (정상 경로)
start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; ++i) {
try {
exceptionPath(1);
} catch (...) {
// 에러 처리
}
}
end = std::chrono::high_resolution_clock::now();
auto exceptionNormal = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
// 3. 에러 코드 (에러 경로)
start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10000; ++i) { // 적은 반복
if (!errorCodePath(-1)) {
// 에러 처리
}
}
end = std::chrono::high_resolution_clock::now();
auto errorCodeError = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
// 4. 예외 (에러 경로)
start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10000; ++i) {
try {
exceptionPath(-1);
} catch (...) {
// 에러 처리
}
}
end = std::chrono::high_resolution_clock::now();
auto exceptionError = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
std::cout << "Error code (normal): " << errorCodeNormal << " us\n";
std::cout << "Exception (normal): " << exceptionNormal << " us\n";
std::cout << "Error code (error): " << errorCodeError << " us\n";
std::cout << "Exception (error): " << exceptionError << " us\n";
}
위 코드 설명: 같은 연산을 에러 코드(반환값 검사)와 예외(throw/catch) 방식으로 각각 정상 경로·에러 경로에서 반복 측정합니다. 정상 경로에서는 예외를 던지지 않아도 try-catch가 있어 약간의 차이가 날 수 있고, 에러 경로에서는 예외를 던질 때 스택 언와인딩 비용 때문에 에러 코드보다 훨씬 느려짐을 확인할 수 있습니다.
실제 결과 (예시):
Error code (normal): 1,200 us
Exception (normal): 1,250 us (거의 동일)
Error code (error): 150 us
Exception (error): 15,000 us (100배 느림!)
결론:
- 예외를 던지지 않으면 성능 차이 거의 없음
- 예외를 던지면 매우 느림 (스택 언와인딩 비용)
4. Zero-Cost Exception이란
“예외를 안 쓰면 비용이 없다”
C++의 예외는 Zero-Cost Abstraction입니다:
- 예외를 던지지 않는 정상 경로에서는 오버헤드가 거의 없음
- 예외를 던질 때만 비용 발생 (스택 언와인딩, catch 블록 찾기)
구현 방식:
- 컴파일러가 예외 테이블을 생성 (별도 메모리)
- 정상 경로에는 예외 체크 코드가 들어가지 않음
- 예외 발생 시 테이블을 참조해 catch 블록 찾음
void func() {
Resource r;
doWork(); // 예외 체크 코드 없음 (zero-cost)
// 예외 발생 시에만 테이블 참조
}
위 코드 설명: 정상 경로(doWork가 예외를 던지지 않을 때)에는 컴파일러가 예외 관련 코드를 넣지 않는 zero-cost 방식으로 동작합니다. 예외가 실제로 발생할 때만 예외 테이블을 사용해 catch 블록을 찾고 스택을 풀기 때문에, “예외를 던지지 않으면 비용이 거의 없다”는 말이 성립합니다.
컴파일러 최적화
// 예외를 던지지 않는 함수
void fastPath() noexcept {
// 컴파일러가 예외 테이블 생성 안 함
// 더 작은 코드 생성
}
위 코드 설명: noexcept로 선언된 함수는 예외를 던지지 않으므로, 컴파일러가 예외 테이블을 만들지 않고 코드 크기를 줄일 수 있습니다. 예외를 전혀 사용하지 않는 경로에는 noexcept를 붙이면 최적화에 유리합니다.
5. 예외 vs 에러 코드 선택 기준
예외를 쓰는 경우
| 상황 | 이유 |
|---|---|
| 생성자 실패 | 반환값이 없음 |
| 깊은 호출 스택 | 중간 체크 코드 제거 |
| 복구 불가 에러 | 프로그램 계속 실행 불가 |
| 라이브러리 API | 사용자가 에러 처리 선택 |
| 에러 빈도 < 1% | 예외 비용이 전체에 미미 |
에러 코드를 쓰는 경우
| 상황 | 이유 |
|---|---|
| 빈번한 에러 | 예외 비용이 큼 |
| 성능 크리티컬 | 루프, 실시간 시스템 |
| 예상 가능한 실패 | 파일 없음, 네트워크 타임아웃 |
| C API 호환 | C 코드와 연동 |
| 임베디드/게임 | 예외 비활성화 환경 |
하이브리드 접근
// 빈번한 에러: 반환값
std::optional<User> findUser(int id) {
auto it = users.find(id);
if (it == users.end()) {
return std::nullopt; // 흔한 상황
}
return it->second;
}
// 드문 에러: 예외
User& getUser(int id) {
auto it = users.find(id);
if (it == users.end()) {
throw std::out_of_range("User not found: " + std::to_string(id));
}
return it->second;
}
// 사용
if (auto user = findUser(123)) {
// 있을 때만 처리
} else {
// 없는 건 정상
}
try {
User& user = getUser(123); // 반드시 있어야 함
} catch (const std::out_of_range& e) {
// 없으면 심각한 문제
}
위 코드 설명: “찾을 수 없음”이 자주 나오는 경우(findUser)는 optional로 반환해 호출자가 if (auto user = findUser(…))로 처리합니다. 반드시 있어야 하는 경우(getUser)는 없으면 예외를 던져 호출자가 try-catch로 처리합니다. 같은 조회라도 사용 맥락에 따라 반환값과 예외를 나누는 하이브리드 패턴입니다.
6. 자주 발생하는 에러와 해결법
문제 1: what()에서 nullptr 반환
증상: e.what() 호출 시 크래시 또는 빈 문자열
원인: what()이 nullptr를 반환하거나, 임시 객체가 소멸한 뒤 c_str() 포인터를 반환
// ❌ 나쁜 예: 임시 문자열의 c_str() 반환
const char* what() const noexcept override {
return ("Error: " + message_).c_str(); // 임시 소멸 후 dangling pointer!
}
// ✅ 좋은 예: 멤버에 저장 후 반환
const char* what() const noexcept override {
return message_.c_str();
}
문제 2: 예외 슬라이싱
증상: catch 블록에서 파생 타입 정보가 사라짐
원인: catch (std::exception e)처럼 값으로 잡으면 복사 시 파생 부분이 잘림
// ❌ 나쁜 예
catch (std::exception e) {
// NetworkException이 std::exception으로 잘림
retry(); // 컴파일 에러: e에서 retry 호출 불가
}
// ✅ 좋은 예
catch (const std::exception& e) {
if (auto* ne = dynamic_cast<const NetworkException*>(&e)) {
ne->retry();
}
}
// 또는 파생 타입을 먼저 catch
catch (const NetworkException& e) {
retry();
} catch (const std::exception& e) {
logError(e.what());
}
문제 3: 소멸자에서 예외 발생 → std::terminate
증상: 예외 처리 중 프로그램이 std::terminate()로 종료
원인: 스택 언와인딩 중 소멸자가 예외를 던지면 C++ 표준이 terminate() 호출을 요구함
// ❌ 나쁜 예
~Resource() {
if (!closed_) {
close(); // close()가 예외를 던지면 terminate!
}
}
// ✅ 좋은 예
~Resource() noexcept {
try {
if (!closed_) close();
} catch (...) {
std::cerr << "Cleanup failed\n"; // 로그만, 예외 삼킴
}
}
문제 4: mutable을 잘못 사용한 스레드 안전성
증상: 멀티스레드에서 what() 호출 시 데이터 레이스
원인: mutable std::string fullMessage_를 what()에서 수정할 때 동기화 없음
// ⚠️ 단일 스레드에서는 OK, 멀티스레드에서는 위험
const char* what() const noexcept override {
if (fullMessage_.empty()) {
fullMessage_ = "Error: " + message_; // 동시 접근 시 데이터 레이스
}
return fullMessage_.c_str();
}
// ✅ 좋은 예: 생성자에서 미리 조합
explicit MyException(const std::string& msg)
: message_(msg), fullMessage_("Error: " + msg) {}
const char* what() const noexcept override {
return fullMessage_.c_str(); // 수정 없음, 스레드 안전
}
문제 5: 예외로 인한 리소스 누수
증상: 파일 핸들, 소켓이 닫히지 않음
원인: 예외 발생 시 close()가 호출되지 않음
// ❌ 나쁜 예
void process() {
FILE* f = fopen("data.txt", "r");
parse(f); // 예외 발생 시 fclose 호출 안 됨!
fclose(f);
}
// ✅ 좋은 예: RAII
void process() {
std::ifstream f("data.txt");
parse(f); // 예외 발생해도 f 소멸 시 자동 close
}
7. 실전 가이드라인
규칙 1: 예외는 예외적인 상황에만
// ❌ 나쁜 예: 정상 흐름에 예외 사용
int findIndex(const std::vector<int>& vec, int value) {
for (size_t i = 0; i < vec.size(); ++i) {
if (vec[i] == value) return i;
}
throw std::runtime_error("Not found"); // 흔한 상황
}
// ✅ 좋은 예: optional 사용
std::optional<size_t> findIndex(const std::vector<int>& vec, int value) {
for (size_t i = 0; i < vec.size(); ++i) {
if (vec[i] == value) return i;
}
return std::nullopt;
}
위 코드 설명: “못 찾음”은 검색에서 자주 나오는 정상적인 결과이므로 예외로 던지면 안 됩니다. 나쁜 예는 못 찾을 때 runtime_error를 던지는 것이고, 좋은 예는 optional로 “있으면 인덱스, 없으면 nullopt”를 반환해 호출자가 값 여부만 검사하도록 하는 것입니다.
규칙 2: 소멸자는 noexcept
class Resource {
public:
~Resource() noexcept { // 필수!
try {
cleanup();
} catch (...) {
// 에러를 삼킴 (로그만)
std::cerr << "Cleanup failed\n";
}
}
};
위 코드 설명: 소멸자에서 예외를 밖으로 던지면 스택 언와인딩 중 std::terminate()가 호출될 수 있으므로, 소멸자에서는 예외를 던지지 않고 try-catch로 잡아 로그만 남기거나 삼키는 방식이 안전합니다. noexcept를 명시하면 이 계약이 분명해집니다.
규칙 3: move/swap은 noexcept
class MyClass {
public:
MyClass(MyClass&& other) noexcept {
// move는 noexcept여야 최적화됨
data = other.data;
other.data = nullptr;
}
void swap(MyClass& other) noexcept {
std::swap(data, other.data);
}
private:
int* data = nullptr;
};
위 코드 설명: move 생성자와 swap을 noexcept로 선언하면 std::vector 등이 재할당·정렬 시 move를 사용해 최적화할 수 있습니다. move나 swap이 실패하면 Strong Guarantee를 지키기 어렵기 때문에, 이 연산들은 예외를 던지지 않도록 하고 noexcept를 붙이는 것이 관례입니다.
규칙 4: 예외는 참조로 잡기
try {
throw std::runtime_error("Error");
} catch (std::exception e) { // ❌ 복사 발생, 슬라이싱 가능
// ...
}
try {
throw std::runtime_error("Error");
} catch (const std::exception& e) { // ✅ 참조로 잡기
// ...
}
위 코드 설명: catch (std::exception e)처럼 값으로 잡으면 예외 객체가 복사되고, 파생 타입이 베이스 타입으로 잘려 들어갈 수 있어(슬라이싱) 정보가 손실됩니다. const std::exception& e처럼 참조로 잡으면 복사와 슬라이싱이 없어 원래 타입과 메시지를 유지할 수 있습니다.
규칙 5: 예외 재전파
void wrapper() {
try {
riskyOperation();
} catch (const std::exception& e) {
logError(e.what());
throw; // ✅ 같은 예외 재전파 (타입 유지)
// throw e; // ❌ 복사 발생, 슬라이싱 가능
}
}
위 코드 설명: 로그를 남긴 뒤 같은 예외를 다시 던질 때는 throw; 만 쓰면 됩니다. throw; 는 현재 catch된 예외를 그대로 재전파해서 타입과 메시지가 유지됩니다. throw e; 로 쓰면 e가 복사되면서 파생 타입이 베이스로 잘릴 수 있어(슬라이싱) 피하는 것이 좋습니다.
규칙 6: 빈번한 에러는 반환값으로
// ❌ 나쁜 예: 50% 확률로 예외
User parseUser(const std::string& json) {
if (!isValidJson(json)) {
throw std::invalid_argument("Invalid JSON"); // 자주 발생
}
// ...
}
// ✅ 좋은 예: expected/optional
std::expected<User, std::string> parseUser(const std::string& json) {
if (!isValidJson(json)) {
return std::unexpected("Invalid JSON");
}
// ...
}
위 코드 설명: 파싱 실패가 자주 나오는 경로에서 예외를 던지면(나쁜 예) 성능 부담이 큽니다. 좋은 예처럼 std::expected(또는 optional)로 “성공하면 User, 실패하면 에러 메시지”를 반환하면 호출자가 값/에러만 검사해 처리할 수 있고, 빈번한 실패 경로에서는 예외보다 적합합니다.
8. 프로덕션 패턴
패턴 1: 예외 → 로그 → HTTP 응답 변환
API 서버에서 예외를 잡아 HTTP 상태 코드와 JSON 응답으로 변환합니다.
#include <nlohmann/json.hpp>
Response handleApiRequest(const Request& req) {
try {
return processRequest(req);
} catch (const ValidationException& e) {
logWarn("Validation failed", e.field(), e.what());
return Response(400, nlohmann::json{
{"error", "validation_error"},
{"code", static_cast<int>(e.code())},
{"field", e.field()},
{"message", e.what()}
}.dump());
} catch (const DatabaseException& e) {
logError("DB error", e.code(), e.context(), e.what());
return Response(503, nlohmann::json{
{"error", "service_unavailable"},
{"code", static_cast<int>(e.code())}
}.dump());
} catch (const AppException& e) {
logError("App error", e.code(), e.context(), e.what());
return Response(500, nlohmann::json{
{"error", "internal_error"},
{"code", static_cast<int>(e.code())}
}.dump());
}
}
패턴 2: 예외 경계 (Exception Boundary)
C API나 예외를 던지지 않는 코드와의 경계에서 예외를 잡아 에러 코드로 변환합니다.
// C++ 라이브러리 내부: 예외 사용
extern "C" int process_data_c(const char* path) {
try {
processData(parseFile(path));
return 0;
} catch (const std::exception& e) {
setLastError(e.what());
return -1;
} catch (...) {
setLastError("Unknown error");
return -1;
}
}
패턴 3: 예외 안전한 재시도
네트워크 예외만 재시도하고, maxRetries를 넘으면 포기합니다.
template<typename Func>
auto retryOnNetworkError(Func&& f, int maxRetries = 3) {
for (int i = 0; i < maxRetries; ++i) {
try {
return f();
} catch (const NetworkException& e) {
if (i == maxRetries - 1) throw;
logWarn("Retry", i + 1, e.what());
std::this_thread::sleep_for(std::chrono::seconds(1 << i));
}
}
std::terminate(); // 도달하지 않음
}
패턴 4: 예외 타입별 메트릭 수집
모니터링 시스템에 예외 타입·에러 코드별로 카운트를 올립니다.
void logAndMetric(const std::exception& e) {
logError(e.what());
if (auto* ae = dynamic_cast<const AppException*>(&e)) {
metrics::increment("exception", {
{"code", std::to_string(static_cast<int>(ae->code()))},
{"type", typeid(e).name()}
});
}
}
프로덕션 체크리스트
- 모든 커스텀 예외가
std::exception상속 -
what()이noexcept이고nullptr반환 안 함 - catch는 참조로 (
const std::exception&) - 소멸자·move·swap에
noexcept명시 - 예외에 에러 코드·컨텍스트 포함 (로깅·API 응답용)
- 예외 경계(C API·스레드)에서 예외 → 에러 코드 변환
- 빈번한 에러는
optional/expected로 처리
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
- C++ 예외 안전성 | “예외 발생 시 리소스 누수” Basic·Strong·Nothrow 보장
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
이 글에서 다루는 키워드 (관련 검색어)
C++ 커스텀 예외, 사용자 정의 예외, std::exception 상속, what(), zero-cost exception, 예외 성능 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| 커스텀 예외 | std::exception 상속, what() 오버라이드 |
| 성능 | 던지지 않으면 zero-cost, 던지면 매우 느림 |
| 선택 기준 | 빈도 < 1% → 예외, 빈도 > 10% → 반환값 |
| noexcept | 소멸자, move, swap에 필수 |
| 잡기 | 참조로 잡기, 재전파 시 throw; |
| 가이드라인 | 예외는 예외적인 상황에만 |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ 사용자 정의 예외 클래스 완벽 가이드. std::exception 상속해 커스텀 예외 만들기, what() 오버라이드, 예외 성능 측정·최적화, zero-cost exception 원리, 예외 vs 에러 코드… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: 커스텀 예외 클래스로 에러 종류를 나누고, noexcept로 성능을 챙길 수 있습니다. 다음으로 템플릿 기초(#9-1)를 읽어보면 좋습니다.
다음 글: C++ 실전 가이드 #9-1: 템플릿 기초
관련 글
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
- C++ 예외 안전성 |
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
- C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing