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)
예외: 오류
비용:
- 예외 객체 생성
- 예외 테이블 검색
- 스택 되감기 (3개 프레임)
- 소멸자 호출 (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;
}
정리
핵심 요약
- Zero-Cost: 정상 경로 오버헤드 거의 없음
- 예외 비용: 스택 되감기, 소멸자 호출
- noexcept: 최적화 향상
- 오류 코드: 빈번한 오류에 적합
- 예외: 드문 오류 상황에 적합
예외 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 |