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

이 글은 C++ 예외 처리, try-catch와 Error Code 중 뭐가 빠를까와 C++ 예외 안전성 Basic·Strong·Nothrow 보장: RAII와 copy-and-swap으로 구현하기에 이어지는 글입니다. 판단 순서는 단순합니다. 먼저 std::runtime_error 같은 표준 예외로 충분한지 보고, 도메인별 타입이나 에러 코드가 필요할 때 커스텀 예외 계층을 만들고, 실패가 자주 일어나는 경로에서는 예외 대신 반환값을 검토합니다.

들어가며: 예외를 너무 자주 던지는 서버

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());
        }
    }
}

잘못된 요청이 드물다면 이 코드에는 문제가 없습니다. 하지만 잘못된 요청의 비율이 높아지면(악의적인 트래픽이나 클라이언트 버그) 루프 안에서 예외가 수만 번 던져지고, 예외를 던지고 잡는 비용은 반환값 검사보다 훨씬 크기 때문에 처리량이 급격히 떨어집니다.

예외가 비싼 이유는 던지는 순간에 해야 할 일이 많기 때문입니다. 일반적인 구현(Itanium ABI를 따르는 GCC·Clang)에서는 예외 객체를 힙에 할당하고(__cxa_allocate_exception), 호출 스택을 한 프레임씩 거슬러 올라가며 각 함수의 언와인드 테이블을 조회해 맞는 catch를 찾고, 그 사이의 모든 지역 객체 소멸자를 실행합니다. 멀티스레드 서버에서는 확장성 문제가 더해집니다. 오래된 glibc 환경에서는 언와인더가 로드된 모듈 목록을 조회할 때 전역 락을 잡는 경로가 있어서, 여러 스레드가 동시에 예외를 던지면 서로를 기다리며 처리량이 코어 수만큼 늘지 않는 현상이 보고되어 왔습니다(최근 glibc와 GCC는 이 경로를 락 없이 처리하도록 개선했습니다). 단일 스레드 벤치마크에서는 견딜 만했던 예외 비용이 부하 테스트에서 갑자기 병목으로 드러나는 이유 중 하나입니다.

검증 실패처럼 자주 일어나는 실패는 반환값으로 처리합니다.

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);
    }
}

예외는 정말 드문 오류(설정 파일 손상, DB 연결 끊김 등)에 쓰고, 일상적인 실패는 반환값·optional·expected로 다루면 성능과 가독성 모두 유리합니다.

예외 타입을 나눠야 하는 이유

반대로 드문 오류라도 종류를 구분해야 할 때는 커스텀 예외가 필요합니다. std::runtime_error만 던지면 DB 연결에서 “연결 거부”, “타임아웃”, “인증 실패”를 구분할 수 없지만, 예외 계층을 두면 catch (const ConnectionRefusedException&)에서는 재시도하고 catch (const AuthException&)에서는 로그인 화면으로 보내는 식으로 분기할 수 있습니다. JSON 파싱 실패라면 ParseException에 줄과 열 번호를 담아 parse error at line 42, column 15처럼 위치를 알려 줄 수 있고, 시스템 호출 실패라면 errno나 GetLastError() 값을 담아 운영체제 수준의 원인을 로그에 남길 수 있습니다.

std::exception을 상속한 커스텀 예외 만들기

기본 커스텀 예외

가장 단순한 형태는 std::exception을 상속하고 what()을 noexcept로 오버라이드하는 것입니다.

// g++ -std=c++17 -o file_exception file_exception.cpp && ./file_exception
#include <exception>
#include <fstream>
#include <iostream>
#include <string>

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";
    }
}

missing.txt가 없으면 File error: Cannot open: missing.txt가 stderr에 출력되고, FileException만 골라 처리할 수 있습니다.

이 구현은 동작하지만 약점이 하나 있습니다. 예외 객체는 던져지고 잡히는 과정에서 복사될 수 있고(std::exception_ptr로 옮길 때, 값으로 잡을 때 등), 멤버 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 순서로 처리 우선순위를 정할 수 있습니다.

class AppException : public std::runtime_error {
public:
    using std::runtime_error::runtime_error;
};
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) {
        useCache();       // DB 에러면 캐시로 대체
    } catch (const AppException& e) {
        logError(e.what());
    }
}

catch 절은 위에서부터 차례로 검사되므로, 파생 타입을 먼저 쓰고 베이스 타입을 마지막에 둡니다. 순서를 거꾸로 쓰면 AppException 절이 모든 예외를 먼저 잡아 아래 절은 실행되지 않으며, GCC와 Clang은 이 경우 경고를 냅니다.

추가 정보를 담는 예외

검증 실패 시 어떤 필드에서 왜 실패했는지 호출부에 넘기고 싶다면 예외에 필드를 둡니다.

class ValidationException : public std::runtime_error {
    std::string field_;
    std::string reason_;
public:
    ValidationException(const std::string& field, const std::string& reason)
        : std::runtime_error("Validation failed for '" + field + "': " + reason),
          field_(field), reason_(reason) {}

    const std::string& field() const noexcept { return field_; }
    const std::string& reason() const noexcept { 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.field() << "\n";
        std::cerr << "Reason: " << e.reason() << "\n";
        std::cerr << "Full: " << e.what() << "\n";
    }
}

전체 메시지를 생성자에서 한 번 조합해 runtime_error에 넘기므로, what()은 아무것도 수정하지 않습니다. what() 안에서 mutable 멤버에 문자열을 늦게 조합하는 방식은 흔히 보이지만, 아래 “자주 하는 실수”에서 보듯 데이터 경쟁과 noexcept 위반 문제가 있습니다. 추가 필드인 field_, reason_은 일반 std::string이라 복사할 때 할당이 일어날 수 있다는 점은 남아 있으므로, 예외는 항상 참조로 잡아 불필요한 복사를 만들지 않는 것이 좋습니다.


에러 코드와 컨텍스트를 담는 예외 계층 설계

서버에서는 로깅, 모니터링, API 응답에 쓸 에러 코드와 컨텍스트(파일 경로, 호스트, 사용자 ID 등)를 예외에 함께 담는 경우가 많습니다.

flowchart TB
    subgraph std[표준]
        E["std::runtime_error"]
    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 <stdexcept>
#include <string>

class AppException : public std::runtime_error {
    ErrorCode code_;
    std::string context_;  // 파일 경로, URL, 사용자 ID 등

    static std::string format(ErrorCode code, const std::string& msg,
                              const std::string& context) {
        std::string s = "[" + std::to_string(static_cast<int>(code)) + "] " + msg;
        if (!context.empty()) s += " (context: " + context + ")";
        return s;
    }
public:
    AppException(ErrorCode code, const std::string& msg,
                 const std::string& context = "")
        : std::runtime_error(format(code, msg, context)),
          code_(code), context_(context) {}

    ErrorCode code() const noexcept { return code_; }
    const std::string& context() const noexcept { return context_; }
};

전체 메시지는 생성자에서 정적 함수로 만들어 runtime_error에 맡기므로 what()을 직접 구현할 필요가 없습니다.

도메인별 파생 예외

class NetworkException : public AppException {
public:
    NetworkException(ErrorCode code, const std::string& msg,
                     const std::string& context = "")
        : AppException(code, "Network: " + msg, context) {}
};
class DatabaseException : public AppException {
public:
    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);
    }
}

Response handleRequest(const Request& req) {
    try {
        connectToDatabase(req.dbHost);
        validateRequest(req);
        return process(req);
    } catch (const NetworkException& e) {
        if (e.code() == ErrorCode::Timeout) {
            return retryWithBackoff(req);
        }
        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());
    }
}

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할 때의 비용 측정

아래 코드는 같은 검사를 에러 코드 방식과 예외 방식으로 각각 정상 경로와 에러 경로에서 반복합니다.

#include <chrono>
#include <iostream>
#include <stdexcept>

volatile int sink = 0;  // 결과를 사용해 최적화기가 루프를 지우지 못하게 함

bool errorCodePath(int value) {
    return value >= 0;
}
void exceptionPath(int value) {
    if (value < 0) {
        throw std::invalid_argument("negative");
    }
}

template<typename F>
long long measure_us(F&& f) {
    auto start = std::chrono::steady_clock::now();
    f();
    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}

int main() {
    const int normalIters = 1000000;
    const int errorIters = 10000;

    auto errorCodeNormal = measure_us([&] {
        for (int i = 0; i < normalIters; ++i) sink = sink + errorCodePath(1);
    });
    auto exceptionNormal = measure_us([&] {
        for (int i = 0; i < normalIters; ++i) {
            try { exceptionPath(1); sink = sink + 1; } catch (...) {}
        }
    });
    auto errorCodeError = measure_us([&] {
        for (int i = 0; i < errorIters; ++i) sink = sink + errorCodePath(-1);
    });
    auto exceptionError = measure_us([&] {
        for (int i = 0; i < errorIters; ++i) {
            try { exceptionPath(-1); } catch (...) { sink = sink + 1; }
        }
    });

    std::cout << "Error code (normal, " << normalIters << "): " << errorCodeNormal << " us\n";
    std::cout << "Exception  (normal, " << normalIters << "): " << exceptionNormal << " us\n";
    std::cout << "Error code (error,  " << errorIters << "): " << errorCodeError << " us\n";
    std::cout << "Exception  (error,  " << errorIters << "): " << exceptionError << " us\n";
}

결과를 해석할 때는 주의가 필요합니다. 결과를 volatile 변수에 쓰지 않으면 최적화 빌드(-O2)에서 호출이 통째로 제거되어 “0 us”가 나오기 쉽고, 이 코드는 호출 스택이 한 단계뿐이라 실제 서버처럼 여러 프레임을 언와인딩하는 비용은 반영하지 않습니다. 여러 단계의 함수 호출 안에서 던지도록 바꿔 보거나 Google Benchmark의 DoNotOptimize를 쓰면 더 현실적인 차이를 볼 수 있습니다. 어떤 환경이든 공통적으로 보이는 경향은, 예외를 던지지 않는 정상 경로에서는 두 방식의 차이가 거의 없고 예외를 실제로 던지는 경로에서는 예외 방식이 훨씬 느리다는 것입니다.


Zero-Cost Exception: 던지지 않을 때만 공짜

현대 64비트 플랫폼의 C++ 예외는 테이블 기반으로 구현됩니다. 컴파일러는 각 함수에 대해 “이 구간에서 예외가 나면 어떤 소멸자를 부르고 어느 catch로 가는지”를 적은 언와인드 테이블을 별도 섹션에 만들고, 정상 경로의 코드에는 예외 검사 명령을 넣지 않습니다. 예외가 실제로 던져졌을 때만 런타임이 이 테이블을 읽어 catch를 찾고 스택을 풉니다. 그래서 “예외를 던지지 않으면 실행 비용이 거의 없다”는 의미에서 zero-cost라고 부릅니다.

void func() {
    Resource r;
    doWork();  // 호출 뒤에 예외 검사 코드가 없음
    // doWork가 던지면 언와인드 테이블을 보고 r의 소멸자를 실행
}

다만 zero-cost는 실행 시간에 대한 이야기이고 공짜는 아닙니다. 언와인드 테이블과 정리 코드(landing pad)가 바이너리에 추가되어 실행 파일이 커지고, 컴파일러는 “여기서 예외가 날 수 있다”는 가능성 때문에 일부 최적화(명령 재배치 등)를 포기하기도 합니다. 게임 콘솔이나 임베디드 환경에서 -fno-exceptions로 예외를 끄는 이유가 이 코드 크기와 예측 가능성 때문이며, 이 경우 throw를 쓰는 코드는 컴파일 에러가 되고 표준 라이브러리 안에서 발생하는 예외는 대개 프로그램 종료로 이어집니다. 또 32비트 Windows(x86)의 SEH 기반 구현처럼 정상 경로에도 비용이 드는 예외 모델도 있었으므로, zero-cost는 테이블 기반 구현을 전제로 한 말입니다.

noexcept가 주는 것

void fastPath() noexcept {
    // 이 함수에서 예외가 빠져나가려 하면 std::terminate
}

noexcept의 이득은 주로 호출하는 쪽에서 생깁니다. 호출 대상이 예외를 던지지 않는다고 확신할 수 있으므로, 호출부 주변에 정리용 코드나 언와인드 정보를 덜 만들 수 있습니다. 함수 자체에서는 오히려 “예외가 밖으로 나가면 std::terminate를 부른다”는 처리가 필요합니다. 즉 noexcept 함수 안에서 호출한 무언가가 예외를 던지면 잡을 기회도 없이 프로그램이 종료되므로, 정말 예외가 날 수 없는 함수(또는 나면 종료가 맞는 함수)에만 붙여야 합니다. 성능보다 중요한 효과는 이동 생성자에 대한 것으로, 아래 “이동과 swap은 noexcept로”에서 다룹니다.


예외와 에러 코드 선택 기준

예외가 잘 맞는 경우는 다음과 같습니다. 생성자는 반환값이 없으므로 실패를 알릴 방법이 사실상 예외뿐입니다. 호출 스택 깊은 곳에서 난 오류를 상위 한 곳에서 처리하고 싶을 때, 예외를 쓰면 중간 함수마다 에러를 검사하고 전달하는 코드가 사라집니다. 그리고 드문 오류라면 던질 때의 비용이 전체 성능에 거의 영향을 주지 않습니다.

반환값(에러 코드, optional, expected)이 잘 맞는 경우도 분명합니다. 실패가 자주 일어나는 경로, 루프나 실시간 시스템처럼 지연 시간의 예측 가능성이 중요한 코드, C API와의 경계, 그리고 -fno-exceptions로 예외를 끈 임베디드·게임 환경입니다.

같은 “파일 없음”이라도 맥락에 따라 분류가 달라집니다. 사용자가 입력한 경로를 여는 함수에서 파일이 없는 것은 흔하고 예상된 결과이므로 반환값이 맞고, 프로그램이 설치 시 함께 배포한 설정 파일이 없는 것은 환경이 망가졌다는 뜻이므로 예외가 맞습니다. “호출자가 이 실패를 매번 처리해야 하는가, 아니면 상위 어딘가에서 한 번만 처리하면 되는가”를 기준으로 판단하면 대부분 답이 나옵니다.

하이브리드 접근

// "없을 수 있음"이 정상인 조회: optional
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 {
    // 없는 것도 정상
}

같은 조회라도 “없음”이 정상인 호출자는 findUser를, 없으면 프로그램 상태가 잘못된 호출자는 getUser를 쓰게 하는 방식입니다. 표준 라이브러리의 map::find와 map::at이 같은 관계입니다.


자주 하는 실수

what()에서 댕글링 포인터 반환

// ❌ 임시 문자열의 c_str() 반환
const char* what() const noexcept override {
    return ("Error: " + message_).c_str();  // 문장이 끝나면 임시가 소멸
}
// ✅ 멤버에 저장된 문자열 반환
const char* what() const noexcept override {
    return message_.c_str();
}

예외 슬라이싱

catch (std::exception e)처럼 값으로 잡으면 예외 객체가 std::exception 부분만 복사되어 파생 타입 정보가 사라집니다. e.what()도 파생 클래스의 오버라이드가 아니라 std::exception::what()(구현 정의 문자열)을 호출하게 됩니다.

// ❌ 값으로 잡기: NetworkException이 std::exception으로 잘림
catch (std::exception e) {
    logError(e.what());  // 원래 메시지가 아님
}
// ✅ 파생 타입을 먼저, 모두 참조로
catch (const NetworkException& e) {
    retry();
} catch (const std::exception& e) {
    logError(e.what());
}

소멸자에서 예외 발생

C++11부터 소멸자는 기본적으로 noexcept이므로, 소멸자 밖으로 예외가 나가면 언와인딩 중이든 아니든 std::terminate가 호출됩니다.

// ❌ close()가 예외를 던지면 terminate
~Resource() {
    if (!closed_) {
        close();
    }
}
// ✅ 소멸자 안에서 잡아서 처리
~Resource() {
    try {
        if (!closed_) close();
    } catch (...) {
        std::cerr << "Cleanup failed\n";  // 로그만 남기고 삼킴
    }
}

정리 실패를 호출자가 알아야 한다면, 소멸자에 맡기지 말고 예외를 던질 수 있는 close()를 명시적으로 호출하게 하고 소멸자는 그 뒤의 안전망 역할만 하게 합니다.

what() 안에서 메시지를 늦게 조합

// ⚠️ 위험: what()에서 mutable 멤버를 수정
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();
}

같은 예외 객체(std::exception_ptr로 공유된 경우 등)의 what()을 여러 스레드가 동시에 처음 부르면 데이터 경쟁이 됩니다. 문제가 하나 더 있습니다. 문자열 연결은 메모리를 할당하므로 std::bad_alloc을 던질 수 있는데, what()은 noexcept라서 그 예외가 밖으로 나가는 순간 std::terminate가 호출됩니다. 메모리 부족으로 발생한 예외를 로깅하려고 what()을 부르다가 프로그램이 종료되는 경우가 이것입니다. 생성자에서 미리 조합하면 할당 실패가 예외 생성 시점의 일반 예외로 바뀌어 두 문제가 함께 사라집니다.

예외로 인한 리소스 누수

// ❌ parse가 던지면 fclose가 호출되지 않음
void process() {
    FILE* f = fopen("data.txt", "r");
    parse(f);
    fclose(f);
}
// ✅ RAII: 예외가 나도 소멸자가 닫음
void process() {
    std::ifstream f("data.txt");
    parse(f);
}

커스텀 예외를 쓸 때 지킬 규칙

정상 흐름에 예외를 쓰지 않기

// ❌ "못 찾음"은 흔한 결과인데 예외로 처리
int findIndex(const std::vector<int>& vec, int value) {
    for (size_t i = 0; i < vec.size(); ++i) {
        if (vec[i] == value) return static_cast<int>(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;
}

파싱처럼 실패 이유를 함께 돌려줘야 하는 경우에는 C++23의 std::expected가 맞습니다.

std::expected<User, std::string> parseUser(const std::string& json) {
    if (!isValidJson(json)) {
        return std::unexpected("Invalid JSON");
    }
    // ...
}

std::expected는 C++23(<expected>)부터 제공되므로, 그 이전 표준에서는 tl::expected 같은 호환 라이브러리를 쓰거나 std::variant<User, Error>로 비슷하게 표현합니다.

이동과 swap은 noexcept로

class MyClass {
public:
    MyClass(MyClass&& other) noexcept : data(other.data) {
        other.data = nullptr;
    }
    void swap(MyClass& other) noexcept {
        std::swap(data, other.data);
    }
private:
    int* data = nullptr;
};
static_assert(std::is_nothrow_move_constructible_v<MyClass>);

이 규칙을 어겼을 때의 증상은 에러가 아니라 조용한 성능 저하입니다. std::vector가 용량을 늘릴 때 원소의 이동 생성자가 noexcept가 아니면, 이동 도중 예외가 나면 원래 상태로 되돌릴 수 없으므로 std::move_if_noexcept 규칙에 따라 복사를 선택합니다. 이동 생성자를 직접 작성하면서 noexcept를 빠뜨린 클래스를 벡터에 담으면, 코드는 멀쩡히 동작하는데 재할당마다 모든 원소가 깊은 복사되어 느려집니다. 위처럼 static_assert를 클래스 정의 아래에 두면 이런 실수를 컴파일 단계에서 잡을 수 있습니다.

다시 던질 때는 throw;

void wrapper() {
    try {
        riskyOperation();
    } catch (const std::exception& e) {
        logError(e.what());
        throw;      // ✅ 원래 예외 객체를 그대로 재전파
        // throw e; // ❌ e의 정적 타입(std::exception)으로 복사되어 슬라이싱
    }
}

throw e;는 e의 정적 타입으로 새 예외 객체를 복사해 던지므로, 원래 NetworkException이었더라도 상위에서는 std::exception으로만 잡힙니다.


예외를 로그와 HTTP 응답으로 바꾸는 경계 설계

예외 → 로그 → 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()}
        }.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());
    }
}

what()은 내부 컨텍스트를 포함하므로 로그에만 남기고, 응답에는 에러 종류와 코드만 넣었습니다.

C API 경계

C 코드나 예외를 쓰지 않는 코드와의 경계에서는 예외가 밖으로 새지 않도록 모두 잡아 에러 코드로 바꿉니다. C 함수 프레임을 통과해 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;
    }
}

네트워크 오류만 재시도

template<typename Func>
auto retryOnNetworkError(Func&& f, int maxRetries = 3) {
    if (maxRetries < 1) maxRetries = 1;
    for (int i = 0; ; ++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));
        }
    }
}

마지막 시도에서 실패하면 throw;로 원래 예외를 그대로 올려 보냅니다. maxRetries가 1 미만이면 루프를 한 번도 돌지 않는 경우를 막기 위해 첫 줄에서 보정합니다. 이 템플릿은 NetworkException이면 무조건 재시도하므로, 재시도해도 소용없는 인증 실패 같은 예외가 NetworkException의 하위 타입으로 설계되어 있으면 쓸데없이 대기만 늘어납니다. 재시도 여부는 예외 타입보다 e.code() 같은 명시적 정보로 판단하는 편이 의도가 분명합니다.

예외 타입별 메트릭 수집

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 같은 고정 이름을 두는 편이 플랫폼 간에 일관된 메트릭을 만듭니다.

참고 자료


같이 보면 좋은 글