C++ 예외 처리, try-catch와 Error Code 중 뭐가 빠를까

try 안에서 생길 수 있는 오류는 throw로 올리고, 프로그램의 경계(예: main이나 요청 처리 루프)에서 catch (const std::exception& e)로 한 번에 처리합니다. catch는 참조로 잡고, 다시 던질 때는 throw;만 씁니다(throw e;는 예외 객체를 잘라 낼 수 있습니다).

에러 코드 vs 예외

설정 파일을 읽는 함수를 생각해 봅니다. 파일 열기, 내용 읽기, JSON 파싱, 값 검증, 설정 객체 생성까지 다섯 단계가 이어지고, 어느 단계에서든 실패할 수 있습니다. 에러 코드 방식은 각 단계가 실패하면 숫자(코드)를 돌려보내고 호출한 쪽이 매번 확인하는 방식이고, 예외는 한 번 throw하면 catch를 만날 때까지 호출 스택을 타고 올라가므로 중간 단계에서는 신경 쓰지 않아도 되는 방식입니다.

C 스타일 에러 코드 방식에서는 열거형으로 성공(0)과 실패 종류를 표현하고, 각 단계에서 if (err != OK) return err;로 에러를 위로 전달합니다.

enum ErrorCode {
    OK = 0,
    FILE_NOT_FOUND = 1,
    PARSE_ERROR = 2,
    VALIDATION_ERROR = 3
};
ErrorCode loadConfig(const std::string& path, Config& out) {
    std::ifstream file(path);
    if (!file) return FILE_NOT_FOUND;

    std::string content;
    ErrorCode err = readFile(file, content);
    if (err != OK) return err;

    JsonValue json;
    err = parseJson(content, json);
    if (err != OK) return err;

    err = validateJson(json);
    if (err != OK) return err;

    err = createConfig(json, out);
    return err;
}
int main() {
    Config config;
    ErrorCode err = loadConfig("config.json", config);
    if (err != OK) {
        std::cerr << "Error: " << err << "\n";
        return 1;
    }
    // ...
}

호출 깊이가 깊어질수록 모든 중간 함수가 에러 코드를 반환해야 하고, 호출하는 쪽에서 매번 검사해야 해서 같은 코드가 반복됩니다. 숫자 코드만으로는 “어느 파일의 몇 번째 줄에서 파싱이 실패했는지” 같은 원인을 담기 어렵고, 호출자가 반환값 검사를 빼먹어도 기본적으로는 컴파일러가 알려 주지 않습니다(C++17의 [[nodiscard]]를 붙이면 반환값을 버릴 때 경고를 받을 수 있습니다).

예외로 바꾸면 loadConfig는 Config를 곧바로 반환하고, 실패하면 std::runtime_error를 던집니다. 중간 함수에서 예외가 나도 loadConfig는 따로 처리하지 않으며, 예외는 호출 스택을 타고 main의 catch까지 올라갑니다.

Config loadConfig(const std::string& path) {
    std::ifstream file(path);
    if (!file) {
        throw std::runtime_error("Cannot open file: " + path);
    }

    std::string content = readFile(file);        // 실패 시 예외
    JsonValue json = parseJson(content);         // 실패 시 예외
    validateJson(json);                          // 실패 시 예외
    return createConfig(json);                   // 실패 시 예외
}
int main() {
    try {
        Config config = loadConfig("config.json");
        // 정상 로직만 작성
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
        return 1;
    }
}

아래 시퀀스 다이어그램은 createConfig 안에서 throw가 발생했을 때, loadConfig가 처리하지 않고 main의 catch까지 올라가는 모습을 단순화한 것입니다.

sequenceDiagram
  participant main as main()
  participant loadConfig as loadConfig()
  participant createConfig as createConfig()
  main->>loadConfig: loadConfig(path)
  loadConfig->>createConfig: createConfig(json)
  createConfig-->>main: throw runtime_error
  Note over main: catch에서 e.what() 출력

그렇다고 예외가 항상 나은 것은 아닙니다. 실패가 자주 일어나는 정상 경로(예: map에서 키가 없음)는 예외보다 std::optional이나 find 결과 검사가 맞고, 임베디드나 실시간 시스템처럼 예외를 끄고 빌드하는 환경(-fno-exceptions)에서는 에러 코드를 씁니다. 드물게 일어나고 가까운 곳에서 처리할 수 없는 오류에는 예외를, 자주 나오는 분기에는 반환값을 쓰는 식으로 나누면 됩니다. 이 기준이 성능과 어떻게 연결되는지는 아래 “예외의 비용” 절에서 다룹니다.

예외 처리 실수가 드러나는 증상과 원인

시나리오증상원인해결 방향
에러 코드 검사 누락프로그램이 이상한 상태로 계속 실행됨if (err != OK) 검사를 빼먹음예외는 아무도 잡지 않으면 프로그램이 종료되어 실패가 드러남
깊은 호출에서 에러 전달단계마다 return err 반복에러 코드는 매 단계 전달 필요예외는 throw 한 번으로 상위까지 전파
생성자 실패반환값이 없어 실패를 알릴 수 없음생성자는 반환값이 없음예외로 실패를 알림
리소스 누수예외 발생 시 메모리·파일 핸들 누수예외 경로에서 delete/close 미실행RAII로 리소스 관리
예외를 제어 흐름으로 사용성능 저하, 의도 불명확try { getNext(); return true; } catch (...) { return false; }optional·hasNext() 등 명시적 검사

두 방식의 기본형

에러 코드 방식은 성공·실패를 숫자로 반환하고, 결과는 참조 인자로 받습니다.

// g++ -std=c++17 -o errcode errcode.cpp && ./errcode
#include <iostream>
int divide(int a, int b, int& result) {
    if (b == 0) return -1;  // 에러 코드
    result = a / b;
    return 0;  // 성공
}
int main() {
    int result;
    if (divide(10, 0, result) != 0) {
        std::cerr << "Division error\n";
    }
    return 0;
}

실행하면 Division error가 stderr에 출력됩니다. 호출 측이 반환값 검사를 빼먹으면 초기화되지 않은 result를 그대로 쓰게 되고, 에러 메시지를 담으려면 별도 버퍼나 전역 변수가 필요합니다.

예외 방식은 에러가 나면 throw로 예외 객체를 던지고, 호출부에서 try-catch로 잡습니다.

// g++ -std=c++17 -o except_divide except_divide.cpp && ./except_divide
#include <iostream>
#include <stdexcept>
int divide(int a, int b) {
    if (b == 0) throw std::invalid_argument("Division by zero");
    return a / b;
}
int main() {
    try {
        int result = divide(10, 0);
        std::cout << result << "\n";
    } catch (const std::invalid_argument& e) {
        std::cerr << "Error: " << e.what() << "\n";
    }
    return 0;
}

실행하면 Error: Division by zero가 stderr에 출력됩니다. 반환값은 결과만 담고, 실패는 별도 경로로 전달됩니다.


try-catch-throw 기본 문법

throw: 예외 던지기

void checkAge(int age) {
    if (age < 0) {
        throw std::invalid_argument("Age cannot be negative");
    }
    if (age > 150) {
        throw std::out_of_range("Age too large");
    }
    std::cout << "Age: " << age << "\n";
}

throw가 실행되면 그 시점에서 함수 실행이 중단되고, 호출 스택을 거슬러 올라가며 타입이 맞는 catch를 찾을 때까지 전파됩니다. 그 과정에서 빠져나가는 각 스코프의 지역 객체 소멸자가 호출되는데, 이것을 스택 언와인딩(stack unwinding)이라고 합니다.

try-catch: 예외 잡기

int main() {
    try {
        checkAge(-5);
        std::cout << "This won't print\n";  // 실행 안 됨
    } catch (const std::invalid_argument& e) {
        std::cerr << "Invalid argument: " << e.what() << "\n";
    } catch (const std::out_of_range& e) {
        std::cerr << "Out of range: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cerr << "Other error: " << e.what() << "\n";
    }

    std::cout << "Program continues\n";
}

try 안에서 checkAge(-5)가 예외를 던지면 그다음 줄은 실행되지 않고 바로 맞는 catch로 넘어갑니다. catch 블록이 끝나면 try-catch 다음 문장부터 실행이 이어지므로 “Program continues”가 출력됩니다.

Invalid argument: Age cannot be negative
Program continues

catch 순서

catch 블록은 위에서부터 차례로 검사되어 처음 일치하는 블록이 선택됩니다. std::exception은 모든 표준 예외의 기반 클래스이므로, 이 catch를 먼저 두면 invalid_argument 같은 파생 타입도 여기서 다 잡혀 아래의 catch에는 도달하지 않습니다.

try {
    // ...
} catch (const std::exception& e) {
    // 모든 표준 예외가 여기서 잡힘
    std::cerr << "Generic: " << e.what() << "\n";
} catch (const std::invalid_argument& e) {
    // 도달 불가 (GCC·Clang이 경고)
    std::cerr << "Invalid argument\n";
}

GCC는 이 경우 “exception of type ‘std::invalid_argument’ will be caught by earlier handler for ‘std::exception’” 같은 경고를 냅니다. 구체적인 타입을 먼저, 일반적인 타입을 나중에 둡니다.

try {
    // ...
} catch (const std::invalid_argument& e) {
    std::cerr << "Invalid argument: " << e.what() << "\n";
} catch (const std::exception& e) {
    std::cerr << "Generic: " << e.what() << "\n";
}

catch(…): 모든 예외 잡기

try {
    riskyOperation();
} catch (const std::exception& e) {
    std::cerr << "Standard exception: " << e.what() << "\n";
} catch (...) {
    // std::exception을 상속하지 않은 예외 (int, 사용자 정의 타입 등)
    std::cerr << "Unknown exception\n";
}

catch(...)는 std::exception을 상속하지 않은 예외까지 모두 잡지만, 블록 안에서 예외 객체에 이름으로 접근할 수 없습니다. 그래서 로그를 남기고 throw;로 다시 던지거나 프로그램을 안전하게 종료하는 용도로 쓰고, 항상 catch 목록의 맨 마지막에 둡니다.


std::exception 계층과 표준 예외 고르기

예외 계층 구조

graph TB
    EXC["std::exception<br/>최상위 예외 클래스"]

    LOGIC["std::logic_error<br/>프로그램 논리 오류"]
    RUNTIME["std::runtime_error<br/>실행 환경에서 생기는 오류"]

    INV["invalid_argument<br/>잘못된 인자"]
    DOM["domain_error<br/>수학적 정의역 오류"]
    LEN["length_error<br/>최대 길이 초과"]
    OUT["out_of_range<br/>범위 벗어남"]

    RNG["range_error<br/>결과 범위 오류"]
    OVF["overflow_error<br/>오버플로"]
    UND["underflow_error<br/>언더플로"]
    SYS["system_error<br/>OS 오류 코드"]

    EXC --> LOGIC
    EXC --> RUNTIME

    LOGIC --> INV
    LOGIC --> DOM
    LOGIC --> LEN
    LOGIC --> OUT

    RUNTIME --> RNG
    RUNTIME --> OVF
    RUNTIME --> UND
    RUNTIME --> SYS

    style EXC fill:#e1f5fe,stroke:#0277bd,stroke-width:3px
    style LOGIC fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style RUNTIME fill:#fff3e0,stroke:#f57c00,stroke-width:2px

이 밖에 std::bad_alloc(메모리 할당 실패), std::bad_cast(참조에 대한 dynamic_cast 실패) 같은 예외는 logic_error나 runtime_error를 거치지 않고 std::exception을 직접 상속합니다. catch (const std::exception&) 하나로 이들까지 모두 잡을 수 있는 이유입니다.

상황별 표준 예외

상황예외 타입예
잘못된 인자 (범위, 형식)std::invalid_argument포트 번호가 1~65535 밖
인덱스·범위 초과std::out_of_rangevector::at()에 범위 밖 인덱스
사전 조건 위반std::logic_error빈 스택에서 pop
실행 환경 문제 (파일, 네트워크)std::runtime_errorDB 연결 실패
수학적 정의역 오류std::domain_error음수의 제곱근 요구
OS 호출 실패std::system_errorstd::thread 생성 실패

logic_error 계열은 원칙적으로 호출하는 쪽 코드를 고치면 사라지는 오류이고, runtime_error 계열은 코드가 옳아도 실행 환경 때문에 생길 수 있는 오류입니다. 예를 들어 빈 스택에서 pop을 호출하는 것은 “스택이 비어 있지 않아야 한다”는 사전 조건을 어긴 것이므로 logic_error가 맞습니다.

class Stack {
    std::vector<int> data;
public:
    int pop() {
        if (data.empty()) {
            throw std::logic_error("Cannot pop from empty stack");
        }
        int value = data.back();
        data.pop_back();
        return value;
    }
};

try-catch-throw와 표준 예외 통합 예제

// g++ -std=c++17 -o except_demo except_demo.cpp && ./except_demo
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
void validatePort(int port) {
    if (port < 1 || port > 65535) {
        throw std::invalid_argument("Port must be 1-65535, got: " + std::to_string(port));
    }
}
int safeAt(const std::vector<int>& vec, std::size_t idx) {
    if (idx >= vec.size()) {
        throw std::out_of_range("Index " + std::to_string(idx) + " out of range [0, " + std::to_string(vec.size()) + ")");
    }
    return vec[idx];
}
int main() {
    try {
        validatePort(0);  // 예외 발생
    } catch (const std::invalid_argument& e) {
        std::cerr << "Invalid: " << e.what() << "\n";
    }
    std::vector<int> data = {10, 20, 30};
    try {
        std::cout << safeAt(data, 5) << "\n";  // 예외 발생
    } catch (const std::out_of_range& e) {
        std::cerr << "Range: " << e.what() << "\n";
    }
    try {
        validatePort(8080);
        std::cout << "Port OK\n";
        std::cout << safeAt(data, 1) << "\n";
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
    }
    return 0;
}

실행 결과는 다음과 같습니다(앞의 두 줄은 stderr, 뒤의 두 줄은 stdout).

Invalid: Port must be 1-65535, got: 0
Range: Index 5 out of range [0, 3)
Port OK
20

예외가 나도 리소스가 풀리게 하는 RAII

예외가 던져지면 함수가 즉시 종료되고, 그 이후의 코드는 실행되지 않습니다. new로 할당한 메모리나 fopen으로 연 파일을 직접 해제하는 코드는 예외 경로에서 건너뛰어져 누수가 생깁니다. RAII(Resource Acquisition Is Initialization)는 객체 생성 시점에 리소스를 획득하고 소멸자에서 해제하는 패턴으로, 스택 언와인딩 중에도 소멸자는 호출되므로 예외가 나도 안전합니다.

// 나쁜 예: 예외 시 메모리 누수
void processData(const std::string& path) {
    char* buffer = new char[4096];
    std::ifstream file(path);
    if (!file) {
        throw std::runtime_error("Cannot open file");  // buffer가 해제되지 않음
    }
    // ... 처리 ...
    delete[] buffer;
}
// 좋은 예: RAII로 예외 안전
void processData(const std::string& path) {
    auto buffer = std::make_unique<char[]>(4096);
    std::ifstream file(path);
    if (!file) {
        throw std::runtime_error("Cannot open file");  // buffer, file 소멸자가 호출됨
    }
    // ... 처리 ...
}

클래스 멤버로 RAII 타입을 두면 생성자 도중 예외가 나도 정리가 보장됩니다.

#include <fstream>
#include <iostream>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
class FileProcessor {
    std::unique_ptr<char[]> buffer_;
    std::ifstream file_;
public:
    explicit FileProcessor(const std::string& path, std::size_t buf_size = 4096)
        : buffer_(std::make_unique<char[]>(buf_size)),
          file_(path) {
        if (!file_) {
            throw std::runtime_error("Cannot open: " + path);
        }
    }
    // 다음 줄을 반환하고, 파일 끝이면 std::nullopt
    std::optional<std::string> readLine() {
        std::string line;
        if (std::getline(file_, line)) return line;
        if (file_.eof()) return std::nullopt;
        throw std::runtime_error("Read failed");
    }
    // 소멸자에서 buffer_, file_ 자동 정리
};
int main() {
    try {
        FileProcessor fp("config.txt");
        while (auto line = fp.readLine()) {
            // *line 처리 (빈 줄도 정상적으로 처리됨)
        }
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
        return 1;
    }
    return 0;
}

생성자 본문에서 예외가 나면 객체는 생성되지 않은 것으로 취급되어 FileProcessor의 소멸자는 호출되지 않지만, 이미 생성이 끝난 멤버(buffer_, file_)의 소멸자는 호출됩니다. 그래서 멤버를 RAII 타입으로 두면 생성자에서 던져도 누수가 없습니다. readLine이 파일 끝을 빈 문자열이 아니라 std::nullopt로 알리는 것도 의도적입니다. 빈 문자열로 끝을 표시하면 파일 중간의 빈 줄에서 읽기가 멈춰 버립니다.


예외가 맞는 경우

생성자는 반환값이 없으므로 실패를 알리는 표준적인 방법이 예외입니다. “생성은 됐지만 쓸 수 없는” 객체를 만들고 isValid()로 확인하게 하는 대안도 있지만, 그러면 모든 멤버 함수가 유효성 검사를 해야 하고 호출자가 확인을 빠뜨리기 쉽습니다.

class File {
    std::ifstream stream;
public:
    explicit File(const std::string& path) : stream(path) {
        if (!stream) {
            throw std::runtime_error("Cannot open: " + path);
        }
    }
};

try {
    File f("data.txt");
} catch (const std::exception& e) {
    std::cerr << e.what() << "\n";
}

반드시 있어야 하는 설정 파일이 없는 것처럼 그 자리에서 복구할 수 없는 오류도 예외가 맞습니다. 에러 코드로 돌려보내면 호출자가 무시하고 계속 진행할 수 있지만, 예외는 누군가 명시적으로 잡지 않으면 프로그램을 종료시킵니다.

깊은 호출 스택에서 오류를 한 곳으로 모으는 경우에도 예외가 편합니다. 예를 들어 서버에서 요청 파싱이 몇 단계 아래에서 실패하더라도, 요청 처리 함수의 catch 한 곳에서 에러 응답을 보내면 됩니다.

void handleClient() {
    try {
        processRequest();  // 내부 어딘가에서 parseJson이 throw할 수 있음
    } catch (const std::exception& e) {
        sendErrorResponse(e.what());
    }
}

예외를 피할 곳

“다음 원소가 있는가”처럼 정상적인 제어 흐름을 예외로 표현하면 안 됩니다.

// 나쁜 예: 예외를 제어 흐름으로 사용
bool hasNext() {
    try {
        getNext();
        return true;
    } catch (...) {
        return false;
    }
}
// 좋은 예: 명시적인 검사
bool hasNext() {
    return currentIndex < data.size();
}

자주 실패하는 연산이 루프 안에 있을 때도 마찬가지입니다. map::at()은 키가 없으면 std::out_of_range를 던지는데, 키가 없는 것이 흔한 정상 상황이라면 매번 예외를 던지고 잡는 비용을 치르게 됩니다.

// 나쁜 예: 자주 발생하는 경우에 예외
for (const auto& key : keys) {
    try {
        int value = table.at(key);
        process(value);
    } catch (const std::out_of_range&) {
        // 키가 없을 수 있음 - 정상 상황
    }
}
// 좋은 예: find로 검사
for (const auto& key : keys) {
    auto it = table.find(key);
    if (it != table.end()) {
        process(it->second);
    }
}

“파일이 없을 수 있다”처럼 예상 가능한 상황은 반환값으로 표현하는 편이 낫습니다.

std::optional<std::string> readOptionalFile(const std::string& path) {
    std::ifstream file(path);
    if (!file) return std::nullopt;  // 없는 것이 정상일 수 있음
    std::ostringstream ss;
    ss << file.rdbuf();
    return ss.str();
}

소멸자에서는 예외를 밖으로 던지면 안 됩니다. C++11부터 소멸자는 따로 지정하지 않으면 암묵적으로 noexcept이므로, 소멸자에서 예외가 빠져나오려 하면 그 즉시 std::terminate가 호출됩니다. noexcept(false)로 선언하더라도, 다른 예외로 스택 언와인딩이 진행되는 중에 소멸자가 또 예외를 던지면 역시 std::terminate입니다. 정리 작업이 실패할 수 있다면 소멸자 안에서 잡아 로그만 남기고, 실패를 호출자에게 알려야 한다면 close() 같은 별도 함수를 제공해 호출자가 명시적으로 부르게 합니다.

class Resource {
public:
    ~Resource() {
        try {
            cleanup();
        } catch (const std::exception& e) {
            std::cerr << "Cleanup error: " << e.what() << "\n";  // 삼키고 로그만
        }
    }
};

예외의 비용

제목의 질문, 즉 “예외와 에러 코드 중 어느 쪽이 빠른가”에 대한 답은 실패가 얼마나 자주 일어나는가에 달려 있습니다.

GCC·Clang(Itanium C++ ABI)과 64비트 MSVC는 테이블 기반 예외 처리를 씁니다. 컴파일러는 각 함수의 어느 코드 범위에 어떤 catch와 정리 코드(소멸자 호출)가 있는지를 실행 코드와 분리된 테이블에 기록해 둡니다. 예외가 발생하지 않으면 이 테이블은 읽히지 않으므로 정상 경로에는 추가 명령이 거의 없고, 이것을 흔히 “zero-cost”라고 부릅니다. 반면 에러 코드 방식은 실패하지 않더라도 호출마다 반환값을 검사하는 분기가 들어갑니다.

비용은 throw가 실제로 일어날 때 몰려 있습니다. 런타임은 예외 객체를 할당하고, 호출 스택을 한 프레임씩 거슬러 올라가며 테이블을 조회하고, 각 프레임의 정리 코드를 실행합니다. 이 과정은 단순한 반환보다 훨씬 비싸고, 호출 스택이 깊을수록 더 오래 걸립니다. 그래서 실패가 드문 경로에서는 예외가 에러 코드보다 불리하지 않고, 실패가 자주 일어나는 경로에서는 에러 코드나 std::optional이 확실히 유리합니다. 테이블과 정리 코드 때문에 바이너리 크기는 늘어나며, 이것이 일부 임베디드 환경에서 예외를 끄는 이유이기도 합니다.

실패 이유까지 담아 반환하고 싶다면 C++23의 std::expected<T, E>가 에러 코드와 예외 사이의 선택지가 됩니다. 이 부분은 C++23 새 기능 글에서 다룹니다.


자주 하는 실수

재던지기에서 throw e; 사용

예외를 로그만 남기고 위로 전달하려 했는데, 위쪽 catch에서 원래 타입으로 잡히지 않는 경우가 있습니다. throw e;는 e의 정적 타입으로 새 예외 객체를 복사해서 던지므로, const std::exception& e로 받았다면 파생 타입 정보가 잘려 나간 std::exception이 던져집니다(slicing).

// 잘못된 재던지기: std::exception으로 잘려서 복사됨
try {
    riskyOperation();
} catch (const std::exception& e) {
    logError(e.what());
    throw e;
}
// 올바른 재던지기: 원래 예외 객체를 그대로 전파
try {
    riskyOperation();
} catch (const std::exception& e) {
    logError(e.what());
    throw;
}

어디서 던졌는지 알 수 없음

what()은 생성 시 넘긴 문자열만 돌려주므로, 메시지만으로는 어디서 던졌는지 알기 어렵습니다. catch 블록에서 __FILE__과 __LINE__을 출력하면 던진 위치가 아니라 잡은 위치가 찍힌다는 점에 주의해야 합니다. 던진 위치를 남기려면 던지는 쪽에서 위치를 메시지에 넣어야 하며, C++20의 std::source_location을 기본 인자로 받으면 호출 위치를 자동으로 얻을 수 있습니다.

#include <source_location>
#include <stdexcept>
#include <string>

[[noreturn]] void throwError(const std::string& msg,
                             std::source_location loc = std::source_location::current()) {
    throw std::runtime_error(std::string(loc.file_name()) + ":" +
                             std::to_string(loc.line()) + " " + msg);
}

// 사용: throwError("Cannot open config");  → "app.cpp:42 Cannot open config"

호출 스택 전체가 필요하다면 C++23의 std::stacktrace를 사용자 정의 예외에 담는 방법이 있습니다.

catch(…)에서 예외 객체를 쓰려고 함

catch(...)에는 예외 객체의 이름이 없으므로 e.what()을 쓰면 컴파일 에러가 납니다. 로그만 남기고 throw;로 다시 던지거나, std::current_exception()으로 예외를 보관해 나중에 처리합니다.

} catch (...) {
    std::cerr << "Unknown exception\n";
    throw;  // 원래 예외를 그대로 다시 던짐
}

값으로 catch

// 나쁜 예: 복사가 일어나고, 파생 예외가 std::exception으로 잘림
} catch (std::exception e) { }
// 좋은 예: const 참조로 catch
} catch (const std::exception& e) {
    std::cerr << e.what() << "\n";
}

값으로 받으면 e는 std::exception 타입의 복사본이 되므로, what()이 파생 클래스의 메시지 대신 구현 정의 문자열(예: “std::exception”)을 반환할 수 있습니다.


예외 경계 설계

최상위 catch에서 로깅 및 종료

main이나 스레드의 진입 함수처럼 예외가 더 이상 올라갈 곳이 없는 경계에는 마지막 catch를 둡니다. 어떤 catch도 예외를 잡지 않으면 std::terminate가 호출되는데, 이때 스택 언와인딩이 일어날지는 구현에 따라 다르므로 소멸자의 정리 작업(로그 플러시, 임시 파일 삭제 등)이 실행된다는 보장이 없습니다.

int main() {
    try {
        return runApplication();
    } catch (const std::exception& e) {
        logError("Fatal", e.what());
        return 1;
    } catch (...) {
        logError("Fatal", "Unknown exception");
        return 1;
    }
}

std::thread의 함수에서 빠져나온 예외도 같은 이유로 std::terminate를 부르므로, 스레드 함수 안에도 최상위 catch가 필요합니다.

std::exception_ptr로 예외 보관

예외를 잡은 자리에서 바로 처리할 수 없을 때(다른 스레드로 넘기거나 작업이 끝난 뒤 처리할 때)는 std::current_exception()으로 예외를 보관했다가 std::rethrow_exception으로 다시 던집니다. std::promise와 std::future가 스레드 사이에서 예외를 전달할 때 내부적으로 쓰는 방식입니다.

std::exception_ptr stored;
try {
    riskyOperation();
} catch (...) {
    stored = std::current_exception();
}
// ... 다른 작업 ...
if (stored) {
    std::rethrow_exception(stored);
}

컨테이너에 담은 RAII 객체

void openAll(const std::vector<Request>& requests) {
    std::vector<std::unique_ptr<Connection>> connections;
    for (const auto& req : requests) {
        connections.push_back(std::make_unique<Connection>(req.host));
    }
    // ... connections 사용 ...
}

세 번째 Connection 생성자가 예외를 던지면, 그 시점까지 만든 두 연결은 connections의 소멸자가 정리합니다. 각 연결을 unique_ptr로 감싸 컨테이너에 넣는 것만으로 “중간에 실패하면 이미 연 것을 닫는다”는 코드를 따로 쓰지 않아도 됩니다.

재시도와 예외

template<typename Func>
auto retry(Func&& f, int max_attempts = 3) {
    for (int i = 0; i < max_attempts; ++i) {
        try {
            return f();
        } catch (const std::exception& e) {
            if (i == max_attempts - 1) throw;  // 마지막 시도는 원래 예외를 그대로 전파
            logError("Retry", e.what());
        }
    }
    throw std::invalid_argument("max_attempts must be positive");
}

재시도는 일시적인 실패(네트워크 타임아웃 등)에만 의미가 있습니다. 잘못된 인자처럼 다시 해도 똑같이 실패할 오류까지 재시도하지 않도록, 실제 코드에서는 재시도할 예외 타입을 좁혀 잡는 편이 좋습니다.

참고 자료


같이 보면 좋은 글