[2026] C++ Stack Unwinding | 스택 되감기 가이드
이 글의 핵심
C++ Stack Unwinding: 스택 되감기 가이드. 스택 되감기 기본·소멸 순서.
들어가며
스택 되감기(Stack Unwinding)는 C++ 예외 처리의 핵심 메커니즘입니다. 예외가 발생하면 스택을 거슬러 올라가며 지역 객체의 소멸자를 자동으로 호출하여 자원을 정리합니다.
실무에서 마주한 현실
개발을 배울 때는 모든 게 깔끔하고 이론적입니다. 하지만 실무는 다릅니다. 레거시 코드와 씨름하고, 급한 일정에 쫓기고, 예상치 못한 버그와 마주합니다. 이 글에서 다루는 내용도 처음엔 이론으로 배웠지만, 실제 프로젝트에 적용하면서 “아, 이래서 이렇게 설계하는구나” 하고 깨달은 것들입니다. 특히 기억에 남는 건 첫 프로젝트에서 겪은 시행착오입니다. 책에서 배운 대로 했는데 왜 안 되는지 몰라 며칠을 헤맸죠. 결국 선배 개발자의 코드 리뷰를 통해 문제를 발견했고, 그 과정에서 많은 걸 배웠습니다. 이 글에서는 이론뿐 아니라 실전에서 마주칠 수 있는 함정들과 해결 방법을 함께 다루겠습니다.
1. 스택 되감기 기본
작동 원리
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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;
}
출력: 아래 코드는 code를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
Widget1 생성
Widget2 생성
Widget3 생성
Widget3 소멸
Widget2 소멸
예외 처리: 에러 발생!
Widget1 소멸
핵심 개념:
- 예외 발생 시 catch 블록을 찾기 위해 스택을 거슬러 올라감
- 각 스택 프레임의 지역 객체 소멸자를 역순으로 호출
- 자원이 자동으로 정리됨 (RAII)
2. 소멸 순서
같은 스코프 내 순서
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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;
}
출력: 아래 코드는 code를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
Resource 1 생성
Resource 2 생성
Resource 3 생성
Resource 3 소멸
Resource 2 소멸
Resource 1 소멸
예외: 에러
3. RAII와 스택 되감기
RAII 패턴
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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
파일 닫힘
예외: 처리 중 에러
여러 자원 관리
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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: 자원 누수
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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: 소멸자에서 예외
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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: 부분 생성 객체
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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;
}
출력: 다음은 간단한 code 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
Resource 1 생성
Resource 2 생성
Resource 1 소멸
예외: 생성 실패
핵심: 생성된 멤버만 소멸자가 호출됩니다.
문제 4: catch 순서
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 (예외 없을 때)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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. 실전 예제: 트랜잭션 관리
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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;
}
출력 (성공 시): 다음은 간단한 code 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
[DataProcess] 트랜잭션 시작
INSERT: record1
UPDATE: record2
[DataProcess] 완료
출력 (실패 시, 예외 발생): 아래 코드는 code를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
[DataProcess] 트랜잭션 시작
INSERT: record1
UPDATE: record2
[DataProcess] 롤백
에러: 처리 실패
7. 중첩 예외 처리
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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;
}
출력: 아래 코드는 code를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
[LOG] level1 시작
[LOG] level2 시작
[LOG] level3 시작
[LOG] level3 종료
[level2] 예외 처리: level3 에러
[LOG] level2 종료
[level1] 최종 처리: level3 에러
[LOG] level1 종료
8. 스택 되감기 vs 정상 종료
| 특징 | 정상 종료 | 스택 되감기 |
|---|---|---|
| 소멸자 호출 | ✓ | ✓ |
| 소멸 순서 | 역순 | 역순 |
| 성능 | 빠름 | 느림 |
| 자원 정리 | 보장 | 보장 |
| finally 블록 | 없음 | 없음 (소멸자 사용) |
정리
핵심 요약
- 스택 되감기: 예외 발생 시 스택 프레임 정리
- 소멸자 호출: 지역 객체 소멸자 역순 호출
- RAII: 소멸자에서 자원 정리 (자동)
- 소멸자 예외: 절대 안됨 (
std::terminate) - 성능: 예외 없으면 비용 거의 없음
- 자원 안전성: 스마트 포인터, RAII 클래스 사용
스택 되감기 흐름
아래 코드는 code를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
예외 발생
↓
현재 스코프 지역 객체 소멸 (역순)
↓
catch 블록 있나?
├─ 있음 → 예외 처리
└─ 없음 → 상위 스택 프레임으로
↓
상위 스코프 지역 객체 소멸 (역순)
↓
반복...
실전 팁
안전성:
- 모든 자원은 RAII 클래스로 관리
- 소멸자는 절대 예외를 던지지 않음 (
noexcept) - 스마트 포인터 적극 활용 성능:
- 예외는 예외적 상황에만 사용
- 정상 흐름에서는 예외 사용 자제
- 예외 발생 시 스택 되감기 비용 고려 디버깅:
- 소멸자에 로깅 추가하여 호출 순서 확인
std::terminate_handler설정으로 소멸자 예외 추적- GDB로 스택 추적 (
backtrace)