C++ 커스텀 예외 클래스 만들기 | 예외 성능과 Zero-Cost Exception
💡 핵심 개념: (1)
std::runtime_error등 표준 예외로도 충분한지 먼저 보고 → (2) 도메인별 메시지·코드가 필요하면std::exception상속 +what()→ (3) 고빈도 경로에서는 예외 대신 반환형을 검토. 예외 기초(#8-1)·예외 안전성(#8-2)을 거친 뒤 읽으면 이해가 빠릅니다.
들어가며: “예외가 느려서 서버가 죽었어요”
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에서 로그만 남깁니다. 잘못된 요청 비율이 높으면 예외가 수만 번 발생하며, 예외 던지기/잡기는 스택 언와인딩 비용 때문에 에러 코드 검사보다 훨씬 느려져 성능이 급격히 떨어집니다. 문제:
- 잘못된 요청이 절반 가까이 되는 부하 테스트였음
- 루프에서 예외를 수만 번 던짐
- 예외 던지기/잡기는 반환값 검사보다 수백 배 이상 비쌀 수 있음 (환경에 따라 크게 다름)
예외가 비싼 이유는 “던지는 순간”에 해야 할 일이 많기 때문입니다. 일반적인 구현(Itanium ABI를 따르는 GCC·Clang)에서는 예외 객체를 힙에 할당하고(__cxa_allocate_exception), 호출 스택을 한 프레임씩 거슬러 올라가며 각 함수의 언와인드 테이블을 조회해 맞는 catch를 찾고, 그 사이의 모든 지역 객체 소멸자를 실행합니다. 멀티스레드 서버에서는 여기에 확장성 문제가 더해집니다. 오래된 glibc 환경에서는 언와인더가 로드된 모듈 목록을 조회할 때 전역 락을 잡는 경로가 있어서, 여러 스레드가 동시에 예외를 던지면 서로를 기다리며 처리량이 코어 수만큼 늘지 않는 현상이 보고되어 왔습니다. 단일 스레드 벤치마크에서는 견딜 만했던 예외 비용이 부하 테스트에서 갑자기 병목으로 드러나는 이유 중 하나입니다.
정리: 예외는 “예외적인” 상황용이 맞습니다. 검증 실패처럼 자주 발생하는 실패는 반환값·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합니다. 예외를 쓰지 않으므로 루프 안에서도 비용이 거의 없으며, 자주 나오는 실패는 이렇게 처리하는 것이 적절합니다. 교훈:
- 예외는 예외적인 상황에만 사용
- 빈번한 에러는 반환값으로 처리
- 성능 크리티컬한 루프에서는 예외 피하기
DB 연결 실패처럼 예외 타입 구분이 필요한 상황
DB 연결 실패 시 타입 구분 불가
std::runtime_error만 던지면 “연결 거부”, “타임아웃”, “인증 실패”를 구분할 수 없습니다. 커스텀 예외 계층을 두면 catch (const ConnectionRefusedException&)로 재시도하며, catch (const AuthException&)로 로그인 화면으로 리다이렉트하는 식으로 분기할 수 있습니다.
파싱 에러에서 위치 정보 손실
JSON 파싱 실패 시 “어디서” 잘못됐는지 알 수 없으면 디버깅이 어렵습니다. ParseException에 line, column을 담으면 parse error at line 42, column 15처럼 정확한 에러 위치를 보여줄 수 있습니다.
네트워크 라이브러리 에러 코드 누락
errno나 GetLastError() 값을 예외에 포함하지 않으면, 운영체제/라이브러리 수준의 원인을 추적하기 어렵습니다. 커스텀 예외에 errorCode를 포함해 로그에 기록하면 문제 해결이 쉬워집니다.
예외 슬라이싱으로 파생 타입 정보 손실
catch (std::exception e)로 값으로 잡으면 NetworkException이 std::exception으로 잘려 들어가 e.what()만 남으며, retry() 같은 네트워크 전용 처리 로직을 호출할 수 없습니다. 참조로 잡아야 파생 타입이 유지됩니다.
커스텀 예외를 쓰면 도메인별 에러 타입(네트워크 오류, 검증 실패 등)을 나눌 수 있어서, 상위에서 catch할 때 처리 방식을 구분하기 쉽습니다. 다만 예외를 자주 던지는 경로에서는 성능 부담이 있으므로, 이 글에서 다루는 “언제 예외, 언제 반환값” 기준을 한 번 정리해 두는 것이 좋습니다.
std::exception을 상속한 커스텀 예외 만들기
기본 커스텀 예외
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에 출력됩니다.
이 구현은 동작하지만 한 가지 약점이 있습니다. 예외 객체는 던져지는 과정과 잡히는 과정에서 복사될 수 있고, 멤버 std::string의 복사는 메모리 할당이 필요해 std::bad_alloc을 던질 수 있습니다. 예외를 처리하는 도중 또 예외가 나면 std::terminate입니다. 표준 라이브러리의 std::runtime_error는 이 문제를 피하려고 메시지를 참조 카운트 방식으로 보관해 복사가 예외를 던지지 않게 구현되어 있습니다. 그래서 메시지만 필요하다면 아래처럼 쓰는 것이 더 간단하고 안전합니다.
class FileException : public std::runtime_error {
public:
using std::runtime_error::runtime_error; // 생성자 상속, what()도 그대로 사용
};
std::exception을 직접 상속해 what()을 만드는 것은 메시지를 지연 생성하거나 여러 필드를 조합하는 등 특별한 이유가 있을 때로 한정하는 편이 좋습니다.
계층적 예외 클래스
도메인별로 예외 타입을 나누면 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에서 “어느 필드에서 왜 실패했는지” 구체적으로 보여줄 때 유용합니다.
에러 코드와 컨텍스트를 담는 예외 계층 설계
실무에서는 에러 코드, 컨텍스트 정보, 계층 구조를 함께 갖춘 예외가 필요합니다. 아래는 웹 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 응답에 포함합니다.
connectToDatabase처럼 하위 예외를 잡아 도메인 예외로 다시 던질 때, 새 예외에 e.what()만 복사하면 원래 예외의 타입과 에러 코드는 사라집니다. std::system_error라면 e.code()의 값(ECONNREFUSED 등)이 원인 분석에 가장 중요한 정보인데, 메시지 문자열만 남으면 로그를 파싱해야 알 수 있게 됩니다. C++11의 std::throw_with_nested(DatabaseException(...))를 쓰면 현재 처리 중인 예외를 새 예외 안에 보존할 수 있고, 로깅하는 쪽에서 std::rethrow_if_nested로 원인 사슬을 따라가며 전부 출력할 수 있습니다. 그리고 에러 메시지를 외부 API 응답에 그대로 넣을 때는 context에 호스트 이름, SQL 문, 파일 경로 같은 내부 정보가 들어 있을 수 있으므로 로그용 메시지와 사용자용 메시지를 분리하는 것이 좋습니다.
루프에서 throw할 때의 비용 측정
벤치마크: 예외 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가 있어 약간의 차이가 날 수 있으며, 에러 경로에서는 예외를 던질 때 스택 언와인딩 비용 때문에 에러 코드보다 훨씬 느려짐을 확인할 수 있습니다.
이 벤치마크를 직접 돌릴 때는 결과를 해석하는 데 주의가 필요합니다. 최적화 빌드(-O2)에서는 errorCodePath(1)처럼 결과를 쓰지 않는 호출이 통째로 제거되어 “에러 코드 0 us”가 나오기 쉽고, 호출 스택이 한 단계뿐이라 실제 서버처럼 여러 프레임을 언와인딩하는 비용도 반영되지 않습니다. 결과를 volatile 변수에 누적하거나 Google Benchmark의 DoNotOptimize를 쓰고, 여러 단계의 함수 호출 안에서 던지도록 바꿔 보면 차이가 더 현실적으로 보입니다. 어떤 환경이든 공통적으로 관찰되는 경향은 다음 두 가지입니다.
결론:
- 예외를 던지지 않으면 성능 차이 거의 없음 (try 블록 진입 비용이 사실상 0)
- 예외를 던지면 매우 느림 (예외 객체 할당, 테이블 검색, 스택 언와인딩 비용)
Zero-Cost Exception: 던지지 않을 때만 공짜
“예외를 안 쓰면 비용이 없다”
C++의 예외는 Zero-Cost Abstraction입니다:
-
예외를 던지지 않는 정상 경로에서는 오버헤드가 거의 없음
-
예외를 던질 때만 비용 발생 (스택 언와인딩, catch 블록 찾기) 구현 방식:
-
컴파일러가 예외 테이블을 생성 (별도 메모리)
-
정상 경로에는 예외 체크 코드가 들어가지 않음
-
예외 발생 시 테이블을 참조해 catch 블록 찾음
void func() {
Resource r;
doWork(); // 예외 체크 코드 없음 (zero-cost)
// 예외 발생 시에만 테이블 참조
}
정상 경로(doWork가 예외를 던지지 않을 때)에는 컴파일러가 예외 관련 코드를 넣지 않는 zero-cost 방식으로 동작합니다. 예외가 실제로 발생할 때만 예외 테이블을 사용해 catch 블록을 찾고 스택을 풀기 때문에, “예외를 던지지 않으면 비용이 거의 없다”는 말이 성립합니다.
다만 “zero-cost”는 실행 시간에 대한 이야기이고 공짜는 아닙니다. 언와인드 테이블과 정리 코드(landing pad)가 바이너리에 추가되어 실행 파일이 커지고, 컴파일러는 “여기서 예외가 날 수 있다”는 가능성 때문에 일부 최적화(명령 재배치 등)를 포기하기도 합니다. 게임 콘솔이나 임베디드 환경에서 -fno-exceptions로 예외를 끄는 이유가 이 코드 크기와 예측 가능성 때문이며, 이 경우 throw를 쓰는 코드는 컴파일 에러가 되고 표준 라이브러리의 new 실패나 vector::at() 범위 초과는 예외 대신 프로그램 종료로 처리됩니다. 참고로 32비트 Windows(x86)의 SEH 기반 구현처럼 정상 경로에도 비용이 드는 예외 모델도 있었으므로, “zero-cost”는 현대 64비트 플랫폼의 테이블 기반 구현을 전제로 한 말입니다.
컴파일러 최적화
// 예외를 던지지 않는 함수
void fastPath() noexcept {
// 컴파일러가 예외 테이블 생성 안 함
// 더 작은 코드 생성
}
noexcept의 이득은 주로 호출하는 쪽에서 생깁니다. 호출 대상이 예외를 던지지 않는다고 확신할 수 있으므로, 호출부 주변에 정리용 코드나 언와인드 정보를 덜 만들 수 있습니다. 함수 자체에서는 오히려 “예외가 밖으로 나가면 std::terminate를 부른다”는 처리가 필요합니다. 즉 noexcept 함수 안에서 호출한 무언가가 예외를 던지면 잡을 기회도 없이 프로그램이 종료되므로, 정말 예외가 날 수 없는 함수(또는 나면 종료가 맞는 함수)에만 붙여야 합니다. 성능보다 중요한 효과는 이동 생성자에 대한 것으로, 아래 규칙 3에서 다룹니다.
예외 vs 에러 코드 선택 기준
예외를 쓰는 경우
| 상황 | 이유 |
|---|---|
| 생성자 실패 | 반환값이 없음 |
| 깊은 호출 스택 | 중간 체크 코드 제거 |
| 복구 불가 에러 | 프로그램 계속 실행 불가 |
| 라이브러리 API | 사용자가 에러 처리 선택 |
| 드문 에러 | 예외 비용이 전체에 미미 |
에러 코드를 쓰는 경우
| 상황 | 이유 |
|---|---|
| 빈번한 에러 | 예외 비용이 큼 |
| 성능 크리티컬 | 루프, 실시간 시스템 |
| 예상 가능한 실패 | 사용자가 입력한 경로의 파일 없음, 재시도가 일상적인 타임아웃 |
같은 “파일 없음”이라도 맥락에 따라 분류가 달라집니다. 사용자가 입력한 경로를 여는 함수에서 파일이 없는 것은 흔하고 예상된 결과이므로 반환값이 맞고, 프로그램이 설치 시 함께 배포한 설정 파일이 없는 것은 환경이 망가졌다는 뜻이므로 예외가 맞습니다. 표를 기계적으로 적용하기보다 “호출자가 이 실패를 매번 처리해야 하는가, 아니면 상위 어딘가에서 한 번만 처리하면 되는가”를 기준으로 판단하면 대부분 답이 나옵니다. | 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로 처리합니다. 같은 조회라도 사용 맥락에 따라 반환값과 예외를 나누는 하이브리드 패턴입니다.
what()의 nullptr 반환, 예외 슬라이싱, 소멸자 throw
문제 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(); // 수정 없음, 스레드 안전
}
what() 안에서 문자열을 조합하는 방식에는 데이터 경쟁 말고도 문제가 하나 더 있습니다. 문자열 연결은 메모리를 할당하므로 std::bad_alloc을 던질 수 있는데, what()은 noexcept라서 그 예외가 밖으로 나가는 순간 std::terminate가 호출됩니다. 메모리가 부족해서 발생한 예외를 로깅하려고 what()을 부르다가 프로그램이 종료되는 경우가 바로 이것입니다. 생성자에서 미리 조합하면 할당 실패가 예외 생성 시점의 일반 예외로 바뀌어 이 문제도 함께 사라집니다.
문제 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
}
커스텀 예외를 쓸 때 지킬 규칙
규칙 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를 붙이는 것이 관례입니다.
이 규칙을 어겼을 때의 증상은 에러가 아니라 조용한 성능 저하입니다. std::vector가 용량을 늘릴 때 원소의 이동 생성자가 noexcept가 아니면, 이동 도중 예외가 나면 원래 상태로 되돌릴 수 없으므로 std::move_if_noexcept 규칙에 따라 복사를 선택합니다. 이동 생성자를 직접 작성하면서 noexcept를 빠뜨린 클래스를 벡터에 담으면, 코드는 멀쩡히 동작하는데 재할당마다 모든 원소가 깊은 복사되어 느려집니다. static_assert(std::is_nothrow_move_constructible_v<MyClass>);를 클래스 정의 아래에 두면 이런 실수를 컴파일 단계에서 잡을 수 있습니다.
규칙 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, 실패하면 에러 메시지”를 반환하면 호출자가 값/에러만 검사해 처리할 수 있으며, 빈번한 실패 경로에서는 예외보다 적합합니다. std::expected는 C++23(<expected>)부터 제공되므로, 그 이전 표준에서는 tl::expected 같은 호환 라이브러리를 쓰거나 std::variant<User, Error>로 비슷하게 표현합니다.
예외를 로그와 HTTP 응답으로 바꾸는 경계 설계
패턴 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(); // maxRetries >= 1이면 도달하지 않음
}
마지막 줄의 std::terminate()는 반환 타입 추론을 만족시키기 위한 장치인데, maxRetries에 0이나 음수를 넘기면 루프를 한 번도 돌지 않고 실제로 도달해 프로그램이 종료됩니다. 함수 첫 줄에서 maxRetries < 1을 검사하거나 f()를 한 번은 반드시 호출하는 구조로 바꾸는 편이 안전합니다. 또 이 템플릿은 NetworkException만 재시도하므로, 재시도해도 소용없는 인증 실패 같은 예외가 NetworkException의 하위 타입으로 설계되어 있으면 쓸데없이 대기만 늘어납니다. 재시도 여부는 예외 타입보다 e.code() 같은 명시적 정보로 판단하는 편이 의도가 분명합니다.
패턴 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()}
});
}
}
typeid(e).name()이 돌려주는 문자열은 구현마다 다릅니다. MSVC는 class NetworkException처럼 읽을 수 있는 이름을 주지만, GCC·Clang은 16NetworkException 같은 맹글링된 이름을 주므로 대시보드에 그대로 올리면 알아보기 어렵습니다. abi::__cxa_demangle로 풀거나, 예외 클래스에 virtual const char* type_name() const noexcept 같은 고정 이름을 두는 편이 플랫폼 간에 일관된 메트릭을 만듭니다.
커스텀 예외 팀 규칙 체크리스트
- 모든 커스텀 예외가
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 보장: RAII와 copy-and-swap으로 구현하기
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
- C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
자주 묻는 질문 (FAQ)
Q. 커스텀 예외의 what()에서 std::string 멤버의 c_str()을 반환해도 안전한가요?
A. 예외 객체가 살아 있는 동안에는 멤버 문자열도 유효하므로 멤버의 c_str()을 반환하는 것 자체는 안전하지만, what() 안에서 지역 문자열을 만들어 그 c_str()을 반환하면 함수가 끝나는 순간 댕글링 포인터가 됩니다. 또 예외 객체는 던지고 잡는 과정에서 복사될 수 있는데, 복사 생성자가 예외를 던지면 std::terminate로 이어질 수 있습니다. 메시지를 std::runtime_error 생성자에 넘겨 상속하면 표준 라이브러리가 예외 없이 복사되는 방식으로 메시지를 보관해 주므로 가장 간단하고 안전합니다.