C++ 예외 비용: Zero-Cost 모델의 의미, throw 비용, noexcept와 이동 생성자

이 글의 핵심

C++ 예외 처리는 'Zero-Cost' 모델을 표방하지만, 이는 정상 실행 경로에 한정된 이야기이고 실제로 예외가 던져지면 스택 되감기와 소멸자 호출 비용이 상당히 큽니다. 이 글은 예외 비용의 실체를 벤치마크로 확인하고, 오류 코드 vs 예외 선택 기준과 noexcept 최적화 효과를 정리합니다.

들어가며

C++ 예외 처리는 Zero-Cost Exception 모델을 사용합니다. 예외가 발생하지 않는 정상 경로에서는 거의 비용이 없지만, 예외가 발생하면 스택 되감기 비용이 큽니다. 이 글에서는 예외의 성능 특성과 최적화 전략을 다룹니다.

먼저 이 글의 벤치마크 출력 숫자에 대해 짚어 둡니다. 예시로 보여 주는 ms·μs 값은 경향을 설명하기 위한 대표값이며, 실제 수치는 컴파일러(GCC·Clang·MSVC), 최적화 수준, 표준 라이브러리, CPU에 따라 몇 배씩 달라집니다. 특히 -O2 이상에서는 아무 일도 하지 않는 루프를 컴파일러가 통째로 지워 버려 “0ms”가 나오는 경우가 흔합니다. 직접 측정할 때는 Google Benchmark 같은 도구와 benchmark::DoNotOptimize로 결과를 사용한 것처럼 만들어야 의미 있는 숫자가 나옵니다. 숫자의 절댓값보다 “정상 경로는 비슷하고, 예외 경로는 수백~수천 배 느리다”는 비율을 기억하는 것이 중요합니다.


정상 경로는 공짜인 Zero-Cost 모델

예외가 없을 때와 던져질 때의 실행 경로

#include <iostream>
#include <chrono>

// 정상 경로: 오버헤드 거의 없음
void normalPath() {
    try {
        int x = 42;
        int y = x * 2;
    } catch (...) {
        // 실행 안 됨
    }
}

// 예외 경로: 비용 있음
void exceptionPath() {
    try {
        throw std::runtime_error("오류");
    } catch (...) {
        // 스택 되감기 비용
    }
}

int main() {
    // 정상 경로 벤치마크
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000000; ++i) {
        normalPath();
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto normal_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    // 예외 경로 벤치마크
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000; ++i) {  // 적은 반복
        exceptionPath();
    }
    end = std::chrono::high_resolution_clock::now();
    auto exception_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    std::cout << "정상 경로 (10M): " << normal_time.count() << "ms" << std::endl;
    std::cout << "예외 경로 (1K): " << exception_time.count() << "ms" << std::endl;
    
    return 0;
}

출력:

정상 경로 (10M): 15ms
예외 경로 (1K): 250ms

예외 테이블로 비용을 미루는 원리

// 컴파일러 최적화:
// 1. 정상 경로: 예외 처리 코드 생성 안함
// 2. 예외 테이블: 별도 메타데이터로 관리
// 3. 예외 발생 시: 테이블 검색 후 스택 되감기

// 정상 실행 시:
// - try-catch 블록 오버헤드 없음
// - 레지스터 저장 없음
// - 점프 없음

// 예외 발생 시:
// - 예외 테이블 검색
// - 스택 되감기
// - 소멸자 호출

이 모델이 “Zero-Cost”라고 불리는 이유는 예외 처리 정보가 실행 경로에 끼워 넣어지는 것이 아니라 별도의 테이블(대부분의 플랫폼에서 DWARF 기반 언와인드 테이블)로 컴파일 시점에 미리 만들어지기 때문입니다. 과거 일부 구현(setjmp/longjmp 기반)은 try 블록에 진입할 때마다 복귀 지점을 저장하는 비용이 있었지만, 테이블 기반 모델은 예외가 실제로 발생했을 때만 그 테이블을 참조해 스택을 거슬러 올라가므로 정상 경로에는 추가 비용이 붙지 않습니다.

다만 “Zero-Cost”는 정상 경로에서 실행되는 명령어가 늘지 않는다는 뜻이지, 비용이 전혀 없다는 뜻은 아닙니다. 남는 비용은 세 가지입니다. 첫째, 언와인드 테이블과 랜딩 패드(소멸자를 호출하고 catch로 넘어가는 코드)가 바이너리에 들어가 실행 파일이 커집니다. 둘째, 예외를 던질 수 있는 함수 호출은 최적화의 장벽이 됩니다. 컴파일러는 호출 뒤에 예외가 날 수 있는 경로를 고려해야 하므로, 명령어 재배치나 일부 인라인·벡터화가 제한될 수 있습니다. 셋째, 플랫폼 차이입니다. Windows의 32비트 x86 MSVC는 SEH 기반으로 함수 진입 시 예외 처리 프레임을 등록하는 비용이 있어 엄밀히 zero-cost가 아니고, x64부터 테이블 기반으로 바뀌었습니다.

예외 경로가 비싼 이유도 구체적으로 보면 이해가 쉽습니다. throw가 실행되면 (1) __cxa_allocate_exception으로 예외 객체를 힙(또는 비상용 버퍼)에 할당하고, (2) 언와인더가 현재 명령어 주소로 언와인드 테이블을 검색해 각 프레임의 정보를 해석하며, (3) 일치하는 catch를 찾기 위해 RTTI로 타입을 비교하는 1단계 탐색을 한 뒤, (4) 다시 처음부터 프레임을 되감으며 소멸자를 호출하는 2단계를 수행합니다. 테이블 검색은 일반 코드처럼 캐시에 올라 있지 않은 메모리를 읽는 경우가 많아, 명령어 수보다 실제 시간이 더 길게 나옵니다. 여러 스레드가 동시에 예외를 던지면 일부 구현에서는 언와인드 정보 조회에 전역 락을 잡아 확장성이 떨어진다는 보고도 있습니다.


throw 한 번에 드는 비용 분해

할당·RTTI 매칭·스택 되감기

#include <iostream>
#include <vector>

class Resource {
public:
    Resource() { std::cout << "생성" << std::endl; }
    ~Resource() { std::cout << "소멸" << std::endl; }
};

void func3() {
    Resource r3;
    throw std::runtime_error("오류");
}

void func2() {
    Resource r2;
    func3();
}

void func1() {
    Resource r1;
    func2();
}

int main() {
    try {
        func1();
    } catch (const std::exception& e) {
        std::cout << "예외: " << e.what() << std::endl;
    }
    
    return 0;
}

출력:

생성  (r1)
생성  (r2)
생성  (r3)
소멸  (r3)
소멸  (r2)
소멸  (r1)
예외: 오류

비용:

  1. 예외 객체 생성
  2. 예외 테이블 검색
  3. 스택 되감기 (3개 프레임)
  4. 소멸자 호출 (3개 객체)

호출 스택이 깊을 때

#include <iostream>
#include <chrono>

void deepFunc(int depth) {
    if (depth == 0) {
        throw std::runtime_error("오류");
    }
    deepFunc(depth - 1);
}

int main() {
    // 얕은 스택
    auto start = std::chrono::high_resolution_clock::now();
    try {
        deepFunc(10);
    } catch (...) {}
    auto end = std::chrono::high_resolution_clock::now();
    auto shallow_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    
    // 깊은 스택
    start = std::chrono::high_resolution_clock::now();
    try {
        deepFunc(1000);
    } catch (...) {}
    end = std::chrono::high_resolution_clock::now();
    auto deep_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    
    std::cout << "얕은 스택 (10): " << shallow_time.count() << "μs" << std::endl;
    std::cout << "깊은 스택 (1000): " << deep_time.count() << "μs" << std::endl;
    
    return 0;
}

출력:

얕은 스택 (10): 15μs
깊은 스택 (1000): 450μs

비용은 대략 되감는 프레임 수에 비례해 늘어납니다. 프레임마다 언와인드 테이블을 조회하고 해석해야 하기 때문입니다. 이 측정은 한 번만 실행하므로 첫 번째 throw의 초기화 비용(언와인드 테이블 페이지를 처음 읽어 들이는 비용 등)이 섞여 있다는 점도 감안해야 합니다. 첫 예외가 두 번째 이후보다 눈에 띄게 느린 것은 흔한 현상입니다.

실무적으로 중요한 결론은 “예외를 던지는 지점과 잡는 지점 사이의 거리”를 의식하라는 것입니다. 요청 하나를 처리하다 실패하면 최상위 핸들러까지 수십 프레임을 되감는 설계는 드문 오류라면 괜찮지만, 요청의 상당 비율이 실패하는 경로라면 예외 비용이 처리량을 직접 깎습니다.


오류 코드와 예외의 성능 비교

#include <iostream>
#include <optional>
#include <chrono>

// 오류 코드 방식
std::optional<int> divideErrorCode(int a, int b) {
    if (b == 0) {
        return std::nullopt;
    }
    return a / b;
}

// 예외 방식
int divideException(int a, int b) {
    if (b == 0) {
        throw std::invalid_argument("0으로 나눌 수 없음");
    }
    return a / b;
}

int main() {
    const int iterations = 1000000;
    
    // 오류 코드 (정상 경로)
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < iterations; ++i) {
        auto result = divideErrorCode(100, 10);
        if (result) {
            volatile int x = *result;
        }
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto error_code_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    // 예외 (정상 경로)
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < iterations; ++i) {
        try {
            volatile int x = divideException(100, 10);
        } catch (...) {}
    }
    end = std::chrono::high_resolution_clock::now();
    auto exception_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    std::cout << "오류 코드 (정상): " << error_code_time.count() << "ms" << std::endl;
    std::cout << "예외 (정상): " << exception_time.count() << "ms" << std::endl;
    
    // 오류 코드 (오류 경로)
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000; ++i) {
        auto result = divideErrorCode(100, 0);
        if (!result) {
            // 오류 처리
        }
    }
    end = std::chrono::high_resolution_clock::now();
    auto error_code_error_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    // 예외 (오류 경로)
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000; ++i) {
        try {
            divideException(100, 0);
        } catch (...) {
            // 오류 처리
        }
    }
    end = std::chrono::high_resolution_clock::now();
    auto exception_error_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    std::cout << "\n오류 코드 (오류): " << error_code_error_time.count() << "ms" << std::endl;
    std::cout << "예외 (오류): " << exception_error_time.count() << "ms" << std::endl;
    
    return 0;
}

출력:

오류 코드 (정상): 8ms
예외 (정상): 8ms

오류 코드 (오류): 2ms
예외 (오류): 180ms

정상 경로에서 두 방식의 시간이 같은 것이 zero-cost 모델의 핵심입니다. 오히려 std::optional을 반환하는 쪽이 호출자에서 매번 if (result) 분기를 해야 하므로, 오류가 거의 없는 코드에서는 예외 방식이 미세하게 빠른 경우도 있습니다. 차이는 오류 경로에서만 벌어집니다.

여기서 나오는 판단 기준은 오류 빈도입니다. 경험적으로 많이 쓰는 기준은 “호출 1,000번에 한 번 이상 실패할 수 있는 경로라면 예외 대신 반환값으로 표현하라”는 것인데, 이 수치는 규칙이라기보다는 감각에 가깝습니다. 사용자 입력 검증, 캐시 미스, 키 조회 실패처럼 실패가 정상적인 결과 중 하나인 경우는 반환값(std::optional, C++23의 std::expected)으로, 설정 파일이 손상되었거나 DB 연결이 끊어진 것처럼 현재 작업을 더 진행할 수 없는 경우는 예외로 표현하는 것이 자연스럽습니다.


noexcept가 만드는 차이

noexcept가 코드 생성에 주는 효과

#include <iostream>
#include <vector>
#include <chrono>

// 예외 가능
void mayThrow(std::vector<int>& v) {
    v.push_back(42);
}

// 예외 없음
void noThrow(std::vector<int>& v) noexcept {
    v.push_back(42);
}

int main() {
    std::vector<int> v;
    v.reserve(10000000);
    
    // mayThrow 벤치마크
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000000; ++i) {
        mayThrow(v);
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto may_throw_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    v.clear();
    v.reserve(10000000);
    
    // noThrow 벤치마크
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000000; ++i) {
        noThrow(v);
    }
    end = std::chrono::high_resolution_clock::now();
    auto no_throw_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    std::cout << "mayThrow: " << may_throw_time.count() << "ms" << std::endl;
    std::cout << "noThrow: " << no_throw_time.count() << "ms" << std::endl;
    
    return 0;
}

출력:

mayThrow: 125ms
noThrow: 118ms

이 차이는 측정 오차 수준이며, 여기서 “noexcept를 붙이면 7% 빨라진다”는 결론을 내리면 안 됩니다. 두 함수 모두 인라인되면 생성되는 코드가 사실상 같아지는 경우가 대부분입니다. 더 중요한 점은 noThrow가 거짓말을 하고 있다는 것입니다. push_back은 메모리가 부족하면 std::bad_alloc을 던질 수 있는데, noexcept 함수 밖으로 예외가 나가려 하면 C++은 되감기를 하지 않고 즉시 std::terminate()를 호출해 프로그램을 종료합니다. noexcept는 최적화 힌트가 아니라 “이 함수는 절대 던지지 않는다”는 계약이고, 계약을 어기면 복구할 기회 없이 죽습니다.

noexcept로 얻는 실질적인 성능 이득은 대부분 함수 자체가 아니라 라이브러리가 noexcept 여부를 보고 다른 알고리즘을 고를 때 생깁니다. 대표적인 것이 아래의 이동 생성자입니다.

vector 재할당과 noexcept 이동 생성자

#include <vector>
#include <iostream>

class Widget {
    int* data;
    
public:
    Widget() : data(new int(42)) {}
    
    // ❌ 예외 가능 move (복사 생성자가 있으면 재할당 시 복사로 폴백)
    // 같은 시그니처의 생성자는 하나만 둘 수 있으므로 비교할 때 둘 중 하나만 사용
    // Widget(Widget&& other) {
    //     data = other.data;
    //     other.data = nullptr;
    //     std::cout << "move (예외 가능)" << std::endl;
    // }
    
    // ✅ noexcept move (최적화)
    Widget(Widget&& other) noexcept {
        data = other.data;
        other.data = nullptr;
        std::cout << "move (noexcept)" << std::endl;
    }
    
    ~Widget() { delete data; }
};

int main() {
    std::vector<Widget> v;
    
    // vector 재할당 시 move 사용
    v.reserve(10);
    v.emplace_back();
    v.emplace_back();
    
    // reserve 초과 시 재할당
    v.emplace_back();  // move 호출
    
    return 0;
}

참고로 이 예제는 reserve(10)으로 공간을 넉넉히 잡아 두었기 때문에 세 번째 emplace_back에서도 실제로는 재할당이 일어나지 않습니다. 재할당을 관찰하려면 reserve(2)로 줄이거나 reserve를 빼고 요소를 계속 추가해 보십시오.

std::vector가 재할당할 때 기존 요소를 옮기는 방식은 강한 예외 보장(실패하면 원래 상태 그대로)을 지키기 위해 정해져 있습니다. 요소를 하나씩 새 버퍼로 이동하다가 중간에 이동 생성자가 예외를 던지면, 이미 옮겨진 요소들은 원본이 비워진 상태라 되돌릴 방법이 없습니다. 반면 복사하다가 실패하면 원본은 그대로 남아 있으므로 새 버퍼만 버리면 됩니다. 그래서 vector는 std::move_if_noexcept를 써서 이동 생성자가 noexcept일 때만 이동하고, 그렇지 않으면 복사 생성자가 있는 한 복사를 선택합니다.

이 차이는 Widget이 문자열이나 큰 버퍼를 가진 타입일 때 극적으로 드러납니다. 이동은 포인터 몇 개를 옮기는 O(1)이지만 복사는 요소마다 깊은 복사와 힙 할당을 합니다. 제가 성능 문제를 추적하다가 가장 허탈했던 경우가 이런 유형인데, 직접 작성한 이동 생성자에 noexcept를 빠뜨려서 vector가 커질 때마다 모든 요소를 복사하고 있었습니다. 컴파일러가 자동 생성하는 이동 생성자(= default)는 멤버들이 모두 nothrow 이동 가능하면 자동으로 noexcept가 되므로, 가능하면 직접 쓰지 않고 기본 생성을 쓰는 것이 이런 실수를 피하는 가장 쉬운 방법입니다. static_assert(std::is_nothrow_move_constructible_v<Widget>);를 넣어 두면 누군가 멤버를 추가해 이 속성이 깨질 때 컴파일 단계에서 알 수 있습니다.


예외를 잘못 쓸 때 생기는 성능 문제

예외를 제어 흐름으로 사용

#include <iostream>
#include <chrono>

// ❌ 제어 흐름으로 예외 사용
void badControlFlow() {
    for (int i = 0; i < 100; ++i) {
        try {
            if (i == 50) {
                throw i;
            }
        } catch (int x) {
            break;
        }
    }
}

// ✅ 일반 제어 흐름
void goodControlFlow() {
    for (int i = 0; i < 100; ++i) {
        if (i == 50) {
            break;
        }
    }
}

int main() {
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 100000; ++i) {
        badControlFlow();
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto bad_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 100000; ++i) {
        goodControlFlow();
    }
    end = std::chrono::high_resolution_clock::now();
    auto good_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    std::cout << "예외 제어 흐름: " << bad_time.count() << "ms" << std::endl;
    std::cout << "일반 제어 흐름: " << good_time.count() << "ms" << std::endl;
    
    return 0;
}

출력:

예외 제어 흐름: 1850ms
일반 제어 흐름: 12ms

자주 실패하는 경로에서 예외를 던짐

#include <optional>
#include <iostream>
#include <chrono>

// ❌ 빈번한 예외 (느림)
int parseIntException(const std::string& s) {
    try {
        return std::stoi(s);
    } catch (const std::invalid_argument&) {
        throw std::runtime_error("파싱 실패");
    }
}

// ✅ 오류 코드 (빠름)
std::optional<int> parseIntErrorCode(const std::string& s) {
    try {
        return std::stoi(s);
    } catch (const std::invalid_argument&) {
        return std::nullopt;
    }
}

int main() {
    std::vector<std::string> inputs = {"123", "abc", "456", "xyz", "789"};
    
    // 예외 방식
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 100000; ++i) {
        for (const auto& s : inputs) {
            try {
                parseIntException(s);
            } catch (...) {}
        }
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto exception_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    // 오류 코드 방식
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 100000; ++i) {
        for (const auto& s : inputs) {
            auto result = parseIntErrorCode(s);
        }
    }
    end = std::chrono::high_resolution_clock::now();
    auto error_code_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    std::cout << "예외 방식: " << exception_time.count() << "ms" << std::endl;
    std::cout << "오류 코드 방식: " << error_code_time.count() << "ms" << std::endl;
    
    return 0;
}

출력:

예외 방식: 3450ms
오류 코드 방식: 1850ms

두 방식의 차이가 생각보다 작은 이유는 parseIntErrorCode도 내부에서 std::stoi가 던지는 예외를 잡고 있기 때문입니다. 즉 “오류 코드 방식”도 실패할 때마다 예외를 한 번 던지고, 예외 방식은 한 번 더 다시 던질 뿐입니다. 예외 비용을 정말로 없애려면 예외를 던지지 않는 API를 써야 합니다. C++17의 std::from_chars는 예외 없이 std::errc로 결과를 돌려주고, 로캘도 참조하지 않아 stoi보다 훨씬 빠릅니다.

#include <charconv>
std::optional<int> parseIntFast(std::string_view s) {
    int value{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
    return value;
}

stoi에는 또 하나의 함정이 있습니다. "123abc"는 예외 없이 123을 반환하고, "99999999999"는 std::invalid_argument가 아니라 std::out_of_range를 던집니다. 위 예제처럼 invalid_argument만 잡으면 범위 초과 입력에서 예외가 그대로 빠져나갑니다. 위 parseIntFast는 ptr 위치까지 확인해 뒤에 남은 문자가 있는 입력도 실패로 처리합니다.

큰 예외 객체를 던짐

#include <vector>
#include <string>
#include <iostream>

// ❌ 큰 예외 객체
class LargeException : public std::exception {
    std::vector<int> data;  // 큰 데이터
    std::string message;
    
public:
    LargeException(const std::string& msg, const std::vector<int>& d) 
        : message(msg), data(d) {}
    
    const char* what() const noexcept override {
        return message.c_str();
    }
};

// ✅ 작은 예외 객체
class SmallException : public std::exception {
    const char* message;
    
public:
    SmallException(const char* msg) : message(msg) {}
    
    const char* what() const noexcept override {
        return message;
    }
};

void testLargeException() {
    try {
        std::vector<int> data(10000, 42);
        throw LargeException("오류", data);  // 복사 비용
    } catch (const LargeException& e) {
        std::cout << e.what() << std::endl;
    }
}

void testSmallException() {
    try {
        throw SmallException("오류");  // 작은 비용
    } catch (const SmallException& e) {
        std::cout << e.what() << std::endl;
    }
}

예외 객체는 throw 시점에 전용 저장소로 복사(또는 이동)되므로, 큰 데이터를 담으면 그만큼 복사 비용이 듭니다. LargeException 생성자가 const std::vector<int>&로 받아 멤버에 복사하는 것도 한 번의 복사입니다. 값으로 받아 std::move하면 줄일 수 있습니다. 또 LargeException은 멤버를 data, message 순서로 선언했는데 초기화 목록은 message, data 순서로 적었습니다. 멤버는 항상 선언 순서대로 초기화되므로 동작에는 문제가 없지만 -Wreorder 경고가 나고, 한 멤버가 다른 멤버에 의존하는 경우에는 실제 버그가 됩니다.

반대로 SmallException처럼 const char*만 저장하는 방식은 문자열 리터럴에만 안전합니다. std::string의 c_str()을 넘기면 원래 문자열이 파괴된 뒤 what()이 dangling 포인터를 반환합니다. 동적 메시지가 필요하면 std::runtime_error를 상속하는 것이 가장 무난합니다. 표준 예외 클래스는 메시지를 참조 카운트 방식의 문자열로 보관해, 예외 객체를 복사할 때 예외가 나지 않도록 설계되어 있기 때문입니다.

-fno-exceptions로 예외를 끈 경우

// -fno-exceptions 컴파일 옵션
// 예외 완전 비활성화 (임베디드, 성능 중요)

#include <optional>

// 예외 대신 optional 사용
std::optional<int> divide(int a, int b) {
    if (b == 0) {
        return std::nullopt;
    }
    return a / b;
}

// 예외 대신 pair 사용
std::pair<bool, int> divideWithError(int a, int b) {
    if (b == 0) {
        return {false, 0};
    }
    return {true, a / b};
}

int main() {
    auto result = divide(10, 0);
    if (result) {
        // 성공
    } else {
        // 실패
    }
    
    return 0;
}

-fno-exceptions는 Google, LLVM, 많은 게임 엔진과 임베디드 프로젝트가 채택하는 옵션이지만, 켜는 순간 언어의 일부가 바뀐다는 점을 알아야 합니다. throw와 try를 쓰면 컴파일 에러가 나고, 표준 라이브러리가 내부에서 던지던 예외(new의 bad_alloc, vector::at의 out_of_range)는 대부분 abort()로 바뀝니다. 즉 메모리 부족이나 범위 초과가 복구 가능한 오류가 아니라 즉시 종료가 됩니다. 예외를 쓰는 서드파티 라이브러리와 섞으면 링크나 동작이 깨질 수도 있습니다.

std::pair<bool, int>는 호출자가 first를 확인하지 않고 second를 써도 컴파일러가 아무 말도 하지 않는다는 약점이 있습니다. 반환 타입에 [[nodiscard]]를 붙이거나, 실패 이유까지 담을 수 있는 std::expected<int, Error>(C++23)를 쓰는 편이 안전합니다.


예외 비용을 줄이는 방법

이동·swap·소멸자에 noexcept

#include <vector>

class Buffer {
    std::vector<int> data;
    
public:
    // noexcept move
    Buffer(Buffer&& other) noexcept 
        : data(std::move(other.data)) {}
    
    // noexcept swap
    void swap(Buffer& other) noexcept {
        data.swap(other.data);
    }
    
    // noexcept 소멸자 (기본)
    ~Buffer() noexcept = default;
};

흔한 실패는 오류 코드로 처리

// ✅ 예외는 정말 예외 상황만
void processFile(const std::string& filename) {
    // 파일 없음: 예외 (드문 상황)
    std::ifstream file(filename);
    if (!file) {
        throw std::runtime_error("파일 열기 실패");
    }
    
    // 파싱 오류: 오류 코드 (빈번할 수 있음)
    std::string line;
    while (std::getline(file, line)) {
        auto result = parseLine(line);
        if (!result) {
            // 오류 처리 (예외 아님)
            continue;
        }
    }
}

조건부 noexcept로 예외 사양 명시

// noexcept 함수
void safeFunction() noexcept {
    // 예외 던지지 않음 보장
}

// 조건부 noexcept
template<typename T>
void swap(T& a, T& b) noexcept(std::is_nothrow_move_constructible_v<T>) {
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}

파일 처리에서 예외와 오류 코드 나누기

#include <fstream>
#include <string>
#include <optional>
#include <vector>
#include <iostream>

class FileProcessor {
public:
    // 예외: 파일 열기 실패 (드문 상황)
    std::vector<std::string> readLines(const std::string& filename) {
        std::ifstream file(filename);
        if (!file) {
            throw std::runtime_error("파일 열기 실패: " + filename);
        }
        
        std::vector<std::string> lines;
        std::string line;
        
        while (std::getline(file, line)) {
            lines.push_back(line);
        }
        
        return lines;
    }
    
    // 오류 코드: 라인 파싱 (빈번할 수 있음)
    std::optional<int> parseLine(const std::string& line) noexcept {
        try {
            return std::stoi(line);
        } catch (...) {
            return std::nullopt;
        }
    }
    
    // 통합 처리
    std::vector<int> processFile(const std::string& filename) {
        auto lines = readLines(filename);  // 예외 가능
        
        std::vector<int> numbers;
        for (const auto& line : lines) {
            auto num = parseLine(line);  // 오류 코드
            if (num) {
                numbers.push_back(*num);
            }
        }
        
        return numbers;
    }
};

int main() {
    FileProcessor processor;
    
    try {
        auto numbers = processor.processFile("data.txt");
        std::cout << "파싱된 숫자: " << numbers.size() << "개" << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "오류: " << e.what() << std::endl;
    }
    
    return 0;
}

예외 성능 정리

핵심 요약

  1. Zero-Cost: 정상 경로 오버헤드 거의 없음
  2. 예외 비용: 스택 되감기, 소멸자 호출
  3. noexcept: 최적화 향상
  4. 오류 코드: 빈번한 오류에 적합
  5. 예외: 드문 오류 상황에 적합

상황별 예외와 오류 코드 선택

상황권장 방식이유
파일 열기 실패예외드문 상황
네트워크 오류예외드문 상황
파싱 오류오류 코드빈번할 수 있음
입력 검증오류 코드빈번함
메모리 할당 실패예외드물고 치명적
범위 초과예외 또는 assert프로그래밍 오류

마지막 행은 논쟁이 있는 부분입니다. 인덱스 범위 초과처럼 호출자의 버그로 생기는 오류는 예외로 잡아서 복구할 대상이 아니라 고쳐야 할 대상이므로, 디버그 빌드의 assert나 계약 검사로 즉시 드러내는 것이 낫다는 입장도 많습니다. 예외는 “코드는 맞지만 환경이 기대와 다를 때”(파일 없음, 네트워크 단절), 반환값은 “실패가 정상적인 결과 중 하나일 때”, assert는 “코드가 틀렸을 때”로 나누면 선택이 쉬워집니다.

같이 보면 좋은 글