C++ 커스텀 예외 클래스 만들기 | 예외 성능과 Zero-Cost Exception

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 파싱 실패 시 “어디서” 잘못됐는지 알 수 없으면 디버깅이 어렵습니다. ParseExceptionline, column을 담으면 parse error at line 42, column 15처럼 정확한 에러 위치를 보여줄 수 있습니다.

시나리오 3: 네트워크 라이브러리 에러 코드 누락
errnoGetLastError() 값을 예외에 포함하지 않으면, 운영체제/라이브러리 수준의 원인을 추적하기 어렵습니다. 커스텀 예외에 errorCode를 포함해 로그에 기록하면 문제 해결이 쉬워집니다.

시나리오 4: 예외 슬라이싱으로 파생 타입 정보 손실
catch (std::exception e)로 값으로 잡으면 NetworkExceptionstd::exception으로 잘려 들어가 e.what()만 남고, retry() 같은 네트워크 전용 처리 로직을 호출할 수 없습니다. 참조로 잡아야 파생 타입이 유지됩니다.

커스텀 예외를 쓰면 도메인별 에러 타입(네트워크 오류, 검증 실패 등)을 나눌 수 있어서, 상위에서 catch할 때 처리 방식을 구분하기 쉽습니다. 다만 예외를 자주 던지는 경로에서는 성능 부담이 있으므로, 이 글에서 다루는 “언제 예외, 언제 반환값” 기준을 한 번 정리해 두는 것이 좋습니다.

이 글을 읽으면:

  • 커스텀 예외 클래스를 작성할 수 있습니다.
  • 예외의 성능 특성을 이해할 수 있습니다.
  • 예외 vs 에러 코드를 상황에 맞게 선택할 수 있습니다.
  • 실전에서 예외를 효율적으로 사용할 수 있습니다.

목차

  1. 커스텀 예외 클래스 만들기
  2. 완전한 예외 계층 예제 (에러 코드·컨텍스트)
  3. 예외 성능 측정
  4. Zero-Cost Exception이란
  5. 예외 vs 에러 코드 선택 기준
  6. 자주 발생하는 에러와 해결법
  7. 실전 가이드라인
  8. 프로덕션 패턴

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으로 한 번에 처리합니다.

추가 정보를 담는 예외

검증 실패 시 어떤 필드에서 실패했는지 호출부에 넘기고 싶을 때, 예외 객체에 fieldreason을 넣고 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());
    }
}

위 코드 설명: AppExceptionErrorCode, context를 두어 로깅·API 응답·모니터링에 활용합니다. handleRequest에서는 e.code()로 타임아웃만 재시도하고, e.context()로 호스트 정보를 로그에 남깁니다. ValidationExceptionfield()로 어떤 필드가 잘못됐는지 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