C++ Stack Unwinding | "스택 되감기" 가이드

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 블록없음없음 (소멸자 사용)

정리

핵심 요약

  1. 스택 되감기: 예외 발생 시 스택 프레임 정리
  2. 소멸자 호출: 지역 객체 소멸자 역순 호출
  3. RAII: 소멸자에서 자원 정리 (자동)
  4. 소멸자 예외: 절대 안됨 (std::terminate)
  5. 성능: 예외 없으면 비용 거의 없음
  6. 자원 안전성: 스마트 포인터, 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 |