C++ Exception Performance | "예외 성능" 가이드

C++ Exception Performance | "예외 성능" 가이드

이 글의 핵심

C++ Exception Performance에 대한 실전 가이드입니다.

들어가며

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


1. Zero-Cost Exception 모델

정상 경로 vs 예외 경로

#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

Zero-Cost 원리

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

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

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

2. 예외 비용 분석

비용 구성 요소

#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

3. 오류 코드 vs 예외

성능 비교

#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

4. 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

move와 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;
}

5. 자주 발생하는 문제

문제 1: 예외 남용 (제어 흐름)

#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

문제 2: 빈번한 예외

#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

문제 3: 큰 예외 객체

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

문제 4: 예외 비활성화

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

6. 최적화 전략

전략 1: 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;
};

전략 2: 예외 최소화

// ✅ 예외는 진짜 예외 상황만
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;
        }
    }
}

전략 3: 예외 사양 명시

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

7. 실전 예제: 파일 처리

#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. 예외: 드문 오류 상황에 적합

예외 vs 오류 코드 선택

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

실전 팁

사용 원칙:

  • 예외는 진짜 예외 상황만
  • 빈번한 오류는 오류 코드
  • noexcept로 최적화 힌트
  • 소멸자는 항상 noexcept

성능:

  • 정상 경로에서 예외 없으면 비용 없음
  • 예외 발생 시 비용 큼 (100-1000배)
  • 깊은 호출 스택은 비용 증가
  • 작은 예외 객체 사용

주의사항:

  • 제어 흐름으로 예외 사용 금지
  • 예외 빈도 고려
  • 프로파일링으로 측정
  • 임베디드는 -fno-exceptions 고려

다음 단계

  • C++ noexcept
  • C++ Exception Safety
  • C++ Stack Unwinding

관련 글

  • C++ Cache Optimization |
  • C++ noexcept 키워드 |
  • C++ Profiling |
  • C++ Algorithm Sort |
  • C++ Benchmarking |