C++ Stack Unwinding | "스택 되감기" 가이드
이 글의 핵심
C++ Stack Unwinding에 대한 실전 가이드입니다.
들어가며
스택 되감기(Stack Unwinding)는 C++ 예외 처리의 핵심 메커니즘입니다. 예외가 발생하면 스택을 거슬러 올라가며 지역 객체의 소멸자를 자동으로 호출하여 자원을 정리합니다.
1. 스택 되감기 기본
작동 원리
#include <iostream>
#include <stdexcept>
class Widget {
std::string name;
public:
Widget(const std::string& n) : name(n) {
std::cout << name << " 생성" << std::endl;
}
~Widget() {
std::cout << name << " 소멸" << std::endl;
}
};
void func3() {
Widget w3("Widget3");
throw std::runtime_error("에러 발생!");
// w3 소멸자 호출
}
void func2() {
Widget w2("Widget2");
func3();
// w2 소멸자 호출
}
void func1() {
Widget w1("Widget1");
try {
func2();
} catch (const std::exception& e) {
std::cout << "예외 처리: " << e.what() << std::endl;
}
// w1 소멸자 호출
}
int main() {
func1();
return 0;
}
출력:
Widget1 생성
Widget2 생성
Widget3 생성
Widget3 소멸
Widget2 소멸
예외 처리: 에러 발생!
Widget1 소멸
핵심 개념:
- 예외 발생 시 catch 블록을 찾기 위해 스택을 거슬러 올라감
- 각 스택 프레임의 지역 객체 소멸자를 역순으로 호출
- 자원이 자동으로 정리됨 (RAII)
2. 소멸 순서
같은 스코프 내 순서
#include <iostream>
class Resource {
int id;
public:
Resource(int i) : id(i) {
std::cout << "Resource " << id << " 생성" << std::endl;
}
~Resource() {
std::cout << "Resource " << id << " 소멸" << std::endl;
}
};
int main() {
try {
Resource r1(1);
Resource r2(2);
Resource r3(3);
throw std::runtime_error("에러");
// 소멸 순서: r3 -> r2 -> r1 (역순)
} catch (const std::exception& e) {
std::cout << "예외: " << e.what() << std::endl;
}
return 0;
}
출력:
Resource 1 생성
Resource 2 생성
Resource 3 생성
Resource 3 소멸
Resource 2 소멸
Resource 1 소멸
예외: 에러
3. RAII와 스택 되감기
RAII 패턴
#include <iostream>
#include <fstream>
#include <memory>
class FileHandler {
std::ofstream file;
public:
FileHandler(const std::string& path) : file(path) {
if (!file.is_open()) {
throw std::runtime_error("파일 열기 실패");
}
std::cout << "파일 열림: " << path << std::endl;
}
~FileHandler() {
if (file.is_open()) {
file.close();
std::cout << "파일 닫힘" << std::endl;
}
}
void write(const std::string& data) {
file << data;
}
};
void processFile() {
FileHandler fh("output.txt");
fh.write("데이터");
throw std::runtime_error("처리 중 에러");
// fh 소멸자 자동 호출 -> 파일 자동 닫힘
}
int main() {
try {
processFile();
} catch (const std::exception& e) {
std::cout << "예외: " << e.what() << std::endl;
}
return 0;
}
출력:
파일 열림: output.txt
파일 닫힘
예외: 처리 중 에러
여러 자원 관리
#include <iostream>
#include <memory>
class Database {
public:
Database() { std::cout << "DB 연결" << std::endl; }
~Database() { std::cout << "DB 종료" << std::endl; }
};
class Connection {
public:
Connection() { std::cout << "Connection 열림" << std::endl; }
~Connection() { std::cout << "Connection 닫힘" << std::endl; }
};
void process() {
auto db = std::make_unique<Database>();
auto conn = std::make_unique<Connection>();
throw std::runtime_error("에러");
// 소멸 순서: conn -> db (역순)
}
int main() {
try {
process();
} catch (const std::exception& e) {
std::cout << "예외: " << e.what() << std::endl;
}
return 0;
}
4. 자주 발생하는 문제
문제 1: 자원 누수
#include <iostream>
// ❌ 수동 메모리 관리 (위험)
void badFunction() {
int* ptr = new int(10);
process(); // 예외 발생 시 누수!
delete ptr; // 실행 안됨
}
// ✅ RAII (안전)
void goodFunction() {
auto ptr = std::make_unique<int>(10);
process(); // 예외 발생해도 자동 정리
}
해결책: 스마트 포인터나 RAII 클래스를 사용하세요.
문제 2: 소멸자에서 예외
#include <iostream>
#include <stdexcept>
// ❌ 소멸자에서 예외 (위험)
class BadResource {
public:
~BadResource() {
throw std::runtime_error("소멸자 에러"); // std::terminate!
}
};
// ✅ 소멸자는 noexcept
class GoodResource {
public:
~GoodResource() noexcept {
try {
cleanup(); // 예외 발생 가능
} catch (const std::exception& e) {
// 예외 삼킴 또는 로깅
std::cerr << "정리 중 에러: " << e.what() << std::endl;
}
}
private:
void cleanup() {
// 정리 작업
}
};
해결책: 소멸자는 항상 noexcept이어야 하며, 예외를 내부에서 처리하세요.
문제 3: 부분 생성 객체
#include <iostream>
#include <stdexcept>
class Resource {
public:
Resource(int id) : id_(id) {
std::cout << "Resource " << id_ << " 생성" << std::endl;
if (id_ == 2) {
throw std::runtime_error("생성 실패");
}
}
~Resource() {
std::cout << "Resource " << id_ << " 소멸" << std::endl;
}
private:
int id_;
};
class Widget {
Resource r1;
Resource r2;
Resource r3;
public:
Widget() : r1(1), r2(2), r3(3) {
// r2 생성 중 예외 발생
// r1은 자동 소멸, r3는 생성 안됨
}
};
int main() {
try {
Widget w;
} catch (const std::exception& e) {
std::cout << "예외: " << e.what() << std::endl;
}
return 0;
}
출력:
Resource 1 생성
Resource 2 생성
Resource 1 소멸
예외: 생성 실패
핵심: 생성된 멤버만 소멸자가 호출됩니다.
문제 4: catch 순서
#include <iostream>
#include <stdexcept>
int main() {
try {
throw std::runtime_error("런타임 에러");
// ❌ 잘못된 순서
// } catch (const std::exception& e) {
// // 모든 예외를 여기서 잡음
// } catch (const std::runtime_error& e) {
// // 도달 불가!
// }
// ✅ 올바른 순서 (구체적 -> 일반)
} catch (const std::runtime_error& e) {
std::cout << "런타임 에러: " << e.what() << std::endl;
} catch (const std::exception& e) {
std::cout << "일반 예외: " << e.what() << std::endl;
} catch (...) {
std::cout << "알 수 없는 예외" << std::endl;
}
return 0;
}
해결책: 구체적인 예외를 먼저, 일반 예외를 나중에 배치하세요.
5. 성능 영향
Zero-Cost Exception (예외 없을 때)
#include <iostream>
#include <chrono>
class Widget {
public:
Widget() {}
~Widget() {}
};
void normalPath() {
Widget w;
// 정상 실행 (예외 없음)
}
void exceptionPath() {
Widget w;
throw std::runtime_error("에러");
}
int main() {
// 정상 경로: 거의 비용 없음
auto start1 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 1000000; ++i) {
normalPath();
}
auto end1 = std::chrono::high_resolution_clock::now();
// 예외 경로: 비용 있음
auto start2 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 1000; ++i) { // 횟수 줄임
try {
exceptionPath();
} catch (...) {}
}
auto end2 = std::chrono::high_resolution_clock::now();
auto duration1 = std::chrono::duration_cast<std::chrono::microseconds>(end1 - start1).count();
auto duration2 = std::chrono::duration_cast<std::chrono::microseconds>(end2 - start2).count();
std::cout << "정상 경로: " << duration1 << " μs" << std::endl;
std::cout << "예외 경로: " << duration2 << " μs" << std::endl;
return 0;
}
핵심:
- 예외 없을 때: 거의 비용 없음 (Zero-Cost Abstraction)
- 예외 발생 시: 스택 되감기 비용 발생 (느림)
6. 실전 예제: 트랜잭션 관리
#include <iostream>
#include <stdexcept>
#include <string>
class Transaction {
std::string name;
bool committed = false;
public:
Transaction(const std::string& n) : name(n) {
std::cout << "[" << name << "] 트랜잭션 시작" << std::endl;
}
~Transaction() {
if (!committed) {
std::cout << "[" << name << "] 롤백" << std::endl;
} else {
std::cout << "[" << name << "] 완료" << std::endl;
}
}
void commit() {
committed = true;
}
};
class Database {
public:
void insert(const std::string& data) {
std::cout << "INSERT: " << data << std::endl;
}
void update(const std::string& data) {
std::cout << "UPDATE: " << data << std::endl;
}
};
void processData(Database& db) {
Transaction tx("DataProcess");
db.insert("record1");
db.update("record2");
// 여기서 예외 발생하면 자동 롤백
// throw std::runtime_error("처리 실패");
tx.commit(); // 성공 시 커밋
}
int main() {
Database db;
try {
processData(db);
} catch (const std::exception& e) {
std::cout << "에러: " << e.what() << std::endl;
}
return 0;
}
출력 (성공 시):
[DataProcess] 트랜잭션 시작
INSERT: record1
UPDATE: record2
[DataProcess] 완료
출력 (실패 시, 예외 발생):
[DataProcess] 트랜잭션 시작
INSERT: record1
UPDATE: record2
[DataProcess] 롤백
에러: 처리 실패
7. 중첩 예외 처리
#include <iostream>
#include <stdexcept>
class Logger {
public:
Logger(const std::string& msg) : message(msg) {
std::cout << "[LOG] " << message << " 시작" << std::endl;
}
~Logger() {
std::cout << "[LOG] " << message << " 종료" << std::endl;
}
private:
std::string message;
};
void level3() {
Logger log("level3");
throw std::runtime_error("level3 에러");
}
void level2() {
Logger log("level2");
try {
level3();
} catch (const std::exception& e) {
std::cout << "[level2] 예외 처리: " << e.what() << std::endl;
throw; // 재던지기
}
}
void level1() {
Logger log("level1");
try {
level2();
} catch (const std::exception& e) {
std::cout << "[level1] 최종 처리: " << e.what() << std::endl;
}
}
int main() {
level1();
return 0;
}
출력:
[LOG] level1 시작
[LOG] level2 시작
[LOG] level3 시작
[LOG] level3 종료
[level2] 예외 처리: level3 에러
[LOG] level2 종료
[level1] 최종 처리: level3 에러
[LOG] level1 종료
8. 스택 되감기 vs 정상 종료
| 특징 | 정상 종료 | 스택 되감기 |
|---|---|---|
| 소멸자 호출 | ✓ | ✓ |
| 소멸 순서 | 역순 | 역순 |
| 성능 | 빠름 | 느림 |
| 자원 정리 | 보장 | 보장 |
| finally 블록 | 없음 | 없음 (소멸자 사용) |
정리
핵심 요약
- 스택 되감기: 예외 발생 시 스택 프레임 정리
- 소멸자 호출: 지역 객체 소멸자 역순 호출
- RAII: 소멸자에서 자원 정리 (자동)
- 소멸자 예외: 절대 안됨 (
std::terminate) - 성능: 예외 없으면 비용 거의 없음
- 자원 안전성: 스마트 포인터, RAII 클래스 사용
스택 되감기 흐름
예외 발생
↓
현재 스코프 지역 객체 소멸 (역순)
↓
catch 블록 있나?
├─ 있음 → 예외 처리
└─ 없음 → 상위 스택 프레임으로
↓
상위 스코프 지역 객체 소멸 (역순)
↓
반복...
실전 팁
안전성:
- 모든 자원은 RAII 클래스로 관리
- 소멸자는 절대 예외를 던지지 않음 (
noexcept) - 스마트 포인터 적극 활용
성능:
- 예외는 예외적 상황에만 사용
- 정상 흐름에서는 예외 사용 자제
- 예외 발생 시 스택 되감기 비용 고려
디버깅:
- 소멸자에 로깅 추가하여 호출 순서 확인
std::terminate_handler설정으로 소멸자 예외 추적- GDB로 스택 추적 (
backtrace)
다음 단계
- C++ Exception Safety
- C++ jthread
- C++ Move Constructor
관련 글
- C++ Exception Safety |
- C++ shared_ptr vs unique_ptr |
- C++ malloc vs new vs make_unique | 메모리 할당 완벽 비교
- C++ 복사/이동 생성자 |
- C++ Custom Deleters |