C++ 객체 수명과 저장 기간: 생성·소멸 순서, 임시 객체 수명, 댕글링 참조
이 글의 핵심
객체 수명(lifetime)과 자동·정적·동적·스레드 저장 기간의 차이, 생성과 소멸 순서 규칙을 정리합니다. 수명이 끝난 객체를 참조하는 댕글링, 임시 객체 수명 연장 규칙 같은 문제를 스마트 포인터와 RAII로 관리하는 방법도 다룹니다.
Lifetime이란?
객체가 생성부터 소멸까지 존재하는 기간
“수명”이라는 개념이 C++에서 중요한 이유는, 이 언어가 가비지 컬렉터 없이 결정론적인 시점(스코프 종료, 명시적 delete 등)에 객체를 소멸시키는 방식을 택했기 때문입니다 — 이는 곧 어떤 참조나 포인터가 유효한지 여부가 “지금 이 객체의 수명이 끝났는가”라는 질문에 전적으로 달려 있다는 뜻이며, 이 질문에 정확히 답하지 못하면 이 글의 뒷부분에서 다루는 댕글링 참조·포인터 문제로 직결됩니다. int x = 10;이 선언되는 순간 그 객체의 수명이 시작되고, 그 변수가 속한 블록의 닫는 중괄호에서 수명이 끝나는 것이 가장 단순한 형태의 수명 규칙이며, 이후 다룰 정적·동적·스레드 저장 기간은 모두 “이 객체가 정확히 언제 시작되고 언제 끝나는가”라는 규칙을 각기 다른 방식으로 정의한 변형입니다.
void func() {
int x = 10; // x 생성
// x의 수명: 여기서 사용 가능
} // x 소멸
표준의 정의를 조금 더 정확히 옮기면, 비자명한 초기화가 필요한 클래스 객체의 수명은 초기화(생성자 실행)가 끝난 순간 시작되고 소멸자 호출이 시작되는 순간 끝납니다. 그래서 생성자 본문 안이나 소멸자 본문 안은 “수명 밖이지만 아직 저장 공간은 살아 있는” 특수 구간이며, 이 구간에서 가상 함수를 호출하면 파생 클래스가 아니라 현재 실행 중인 클래스의 버전이 불린다는 규칙도 여기서 나옵니다. 또 수명(lifetime)과 저장 공간(storage)은 별개입니다. placement new로 같은 버퍼에 객체를 여러 번 만들고 없애거나, std::vector가 용량을 남긴 채 원소만 파괴하는 경우처럼 메모리는 그대로인데 그 위의 객체 수명만 끝나는 상황이 흔합니다. 메모리가 남아 있으니 읽어도 괜찮을 것 같지만, 수명이 끝난 객체에 접근하는 것은 정의되지 않은 동작(UB)입니다.
저장 기간 (Storage Duration)
네 가지 저장 기간의 핵심 차이는 “누가, 언제 수명의 끝을 결정하는가”입니다 — 자동 저장 기간은 그 변수가 선언된 블록의 스코프가 그 결정을 내리므로 프로그래머가 신경 쓸 필요가 거의 없는 반면, 동적 저장 기간은 프로그래머가 delete를 명시적으로 호출하기 전까지는 절대 끝나지 않아 그 책임이 전적으로 개발자에게 있습니다(또는 스마트 포인터에 위임할 수 있습니다). 정적 저장 기간은 전역 변수든 함수 내부의 static 변수든 상관없이 프로그램이 시작해서 끝날 때까지 정확히 한 번만 생성되고 한 번만 소멸되며, 스레드 저장 기간(thread_local)은 이 규칙을 스레드 단위로 좁힌 것으로 각 스레드가 자신만의 독립적인 사본을 갖고 그 스레드가 종료될 때 소멸됩니다. 이 네 가지를 구분해서 이해해야 하는 이유는, 뒤에서 다룰 모든 수명 관련 버그가 결국 “이 객체가 실제로 어떤 저장 기간을 갖는지 잘못 가정했다”는 하나의 원인으로 귀결되기 때문입니다.
// 1. 자동 저장 기간 (Automatic)
void func() {
int x = 10; // 함수 종료 시 소멸
}
// 2. 정적 저장 기간 (Static)
int global = 10; // 프로그램 종료 시 소멸
void func() {
static int count = 0; // 프로그램 종료 시 소멸
}
// 3. 동적 저장 기간 (Dynamic)
void func() {
int* ptr = new int(10); // 명시적 delete 필요
delete ptr;
}
// 4. 스레드 저장 기간 (Thread)
thread_local int x = 10; // 스레드 종료 시 소멸
실전 예시
예시 1: 자동 저장 기간
이 예시가 생성자·소멸자에 출력문을 넣어 보여주려는 핵심은 “소멸은 항상 생성의 역순”이라는 규칙입니다 — w1, w2, w3 순서로 생성되었지만, w2는 자신이 속한 if 블록이 먼저 끝나므로 가장 먼저 소멸되고, func() 전체가 끝날 때는 나중에 선언된 w3가 먼저, 그다음 w1이 소멸됩니다. 이 역순 규칙이 존재하는 이유는 스택이라는 자료구조 자체의 특성과 맞닿아 있습니다 — 지역 변수들은 스택에 쌓이는 순서대로 할당되므로(LIFO, 후입선출), 해제도 자연스럽게 가장 나중에 쌓인 것부터 이루어져야 스택 포인터를 정상적으로 되돌릴 수 있습니다. 이 규칙은 사소해 보이지만, 한 객체의 소멸자가 다른 객체를 참조해야 하는 경우(예: 로거 객체가 다른 리소스의 정리 로그를 남기는 경우) 어느 것이 먼저 소멸되는지가 실제 동작에 영향을 미치므로 반드시 기억해 두어야 합니다.
#include <iostream>
class Widget {
public:
Widget(int id) : id(id) {
std::cout << "Widget " << id << " 생성" << std::endl;
}
~Widget() {
std::cout << "Widget " << id << " 소멸" << std::endl;
}
private:
int id;
};
void func() {
Widget w1(1);
if (true) {
Widget w2(2);
// w2 수명: 이 블록 내
} // w2 소멸
Widget w3(3);
} // w3, w1 소멸 (역순)
int main() {
func();
}
예시 2: 정적 저장 기간
globalLogger(전역 객체)와 localLogger(함수 내부 static 객체)는 둘 다 정적 저장 기간을 갖지만 초기화 시점이 다릅니다 — 전역 객체는 main이 실행되기 전에 이미 생성되어 있는 반면, 함수 내부의 static 변수는 그 함수가 처음 호출되는 시점에야 지연 초기화됩니다. func()를 두 번 호출해도 “Logger 초기화” 메시지가 한 번만 출력되는 것이 이 지연 초기화의 증거이며, 두 번째 호출부터는 이미 존재하는 localLogger를 그대로 재사용합니다. C++11부터 이 지연 초기화가 스레드 안전하게 표준으로 보장되므로(여러 스레드가 동시에 func()를 처음 호출해도 Logger가 정확히 한 번만 생성됨), 함수 내부 static 변수는 별도의 락 없이도 안전한 지연 초기화 싱글톤을 구현하는 표준적인 방법으로 널리 쓰입니다.
#include <iostream>
class Logger {
public:
Logger() {
std::cout << "Logger 초기화" << std::endl;
}
~Logger() {
std::cout << "Logger 종료" << std::endl;
}
void log(const std::string& msg) {
std::cout << "[LOG] " << msg << std::endl;
}
};
// 전역 객체
Logger globalLogger;
void func() {
// 지역 정적 객체
static Logger localLogger;
localLogger.log("함수 호출");
}
int main() {
globalLogger.log("프로그램 시작");
func();
func();
globalLogger.log("프로그램 종료");
}
예시 3: 동적 저장 기간
동적 저장 기간의 근본적인 차이는 “이 객체의 수명을 결정하는 것이 스코프가 아니라 프로그래머의 명시적인 행동(delete, 또는 스마트 포인터의 소멸)“이라는 점입니다 — manualManagement()의 res는 함수가 끝나기 전에 delete res로 명시적으로 해제하지 않으면 함수가 반환된 뒤에도 그 Resource 객체는 계속 살아남아(포인터 자체는 사라져도 가리키던 메모리는 그대로 남아) 메모리 누수가 됩니다. smartPointer()의 unique_ptr는 겉보기엔 동적 할당(new가 내부에서 호출됨)이지만, 그 수명 관리 방식은 사실상 자동 저장 기간과 동일하게 동작합니다 — res가 스코프를 벗어나는 순간 unique_ptr의 소멸자가 자동으로 delete를 호출하므로, 동적 할당의 유연성(런타임에 크기·타입을 결정)과 자동 저장 기간의 안전성(스코프 종료 시 자동 정리)을 동시에 얻습니다.
#include <memory>
class Resource {
public:
Resource(int size) : size(size) {
data = new int[size];
std::cout << "Resource 할당: " << size << std::endl;
}
~Resource() {
delete[] data;
std::cout << "Resource 해제: " << size << std::endl;
}
private:
int* data;
int size;
};
void manualManagement() {
Resource* res = new Resource(100);
// 사용
delete res; // 명시적 해제
}
void smartPointer() {
auto res = std::make_unique<Resource>(100);
// 자동 해제
}
int main() {
manualManagement();
smartPointer();
}
예시 4: 수명 연장
createTemp(10)이 반환하는 것은 함수 호출이 끝나는 즉시 소멸될 임시 객체인데, 그것을 const Temp&에 바인딩하면 C++ 표준의 특별한 규칙에 의해 그 임시 객체의 수명이 참조 변수 ref의 수명까지 연장됩니다 — 이 규칙이 없었다면 ref는 생성되자마자 댕글링 참조가 되었을 것입니다. 다만 이 수명 연장 규칙에는 중요한 제약이 있는데, 정확히 그 참조 변수 하나에만 적용되며 함수 매개변수로 전달되는 임시 객체나, 클래스 멤버가 임시 객체를 참조로 저장하는 경우에는 이 연장이 적용되지 않습니다 — 이런 미묘한 경계 조건 때문에 실무에서는 이 규칙에 의존하기보다 필요한 값을 명확히 복사해 저장하는 편이 더 안전한 습관으로 여겨집니다.
#include <iostream>
class Temp {
public:
Temp(int val) : value(val) {
std::cout << "Temp 생성: " << value << std::endl;
}
~Temp() {
std::cout << "Temp 소멸: " << value << std::endl;
}
int getValue() const {
return value;
}
private:
int value;
};
Temp createTemp(int val) {
return Temp(val);
}
int main() {
// 임시 객체 수명 연장
const Temp& ref = createTemp(10);
std::cout << "값: " << ref.getValue() << std::endl;
// ref가 스코프 벗어날 때 소멸
}
실행하면 “Temp 생성: 10” → “값: 10” → “Temp 소멸: 10” 순서로 출력됩니다. C++17부터는 보장된 복사 생략(guaranteed copy elision) 덕분에 createTemp 안의 Temp(val)이 곧바로 반환될 임시 객체로 만들어지므로 중간 복사본의 생성·소멸 메시지도 나오지 않습니다. 사실 이 코드는 Temp ref = createTemp(10);처럼 값으로 받아도 비용 차이가 없습니다. 수명 연장 규칙은 “참조로 받아야 더 빠르다”는 오해를 낳기 쉬운데, C++17 이후 반환값을 받을 때는 값으로 받는 편이 더 단순하고 안전합니다.
수명 연장이 적용되지 않는 대표적인 경우는 다음과 같습니다. const std::string& s = std::max(std::string("a"), std::string("b"));처럼 함수가 참조를 받아 그대로 돌려주는 경우, 참조는 함수 인자로 바인딩된 것이라 연장되지 않고 문장이 끝나면 s가 댕글링이 됩니다. 생성자 초기화 리스트에서 참조 멤버를 임시 객체에 바인딩하는 경우도 마찬가지로, 생성자가 끝나는 순간 임시 객체가 사라집니다. GCC는 이런 일부 패턴에 -Wdangling-reference(GCC 13+) 경고를 내 주므로 켜 두는 것이 좋습니다.
생성/소멸 순서
이 예시는 서로 다른 저장 기간을 가진 객체들이 뒤섞여 있을 때 소멸 순서가 어떻게 결정되는지를 보여줍니다 — local2(가장 나중에 생성된 자동 변수)가 가장 먼저 소멸되고, 그다음 local1, 그리고 함수 내부 static 변수인 static1은 main이 끝나기 전까지는 아직 소멸되지 않으므로 자동 변수들이 다 정리된 뒤에 소멸되며, 전역 객체인 global1은 가장 나중에(프로그램 전체가 종료될 때) 소멸됩니다. 이 순서를 요약하면 “가장 좁은 스코프에 속한 것부터, 가장 늦게 생성된 것부터” 소멸된다는 규칙으로 정리할 수 있으며, 여러 전역 객체 사이의 소멸 순서(서로 다른 번역 단위에 있는 경우)는 표준에 의해 정의되지 않는다는 점도 함께 기억해 둘 필요가 있습니다 — 이는 앞서 다룬 “정적 초기화 순서” 문제의 소멸 버전이며, 한 전역 객체의 소멸자가 이미 소멸되었을 수도 있는 다른 전역 객체를 참조하면 위험할 수 있습니다.
#include <iostream>
class A {
public:
A(int id) : id(id) {
std::cout << "A" << id << " 생성" << std::endl;
}
~A() {
std::cout << "A" << id << " 소멸" << std::endl;
}
private:
int id;
};
A global1(1); // 전역 객체 (먼저 생성)
int main() {
A local1(2);
static A static1(3);
A local2(4);
// 소멸 순서: local2 -> local1 -> static1 -> global1
}
자주 발생하는 문제
문제 1: 댕글링 레퍼런스
이는 앞서 “저장 기간” 절에서 설명한 자동 저장 기간의 규칙이 함수 반환값에 적용될 때 나타나는 전형적인 실패 사례입니다 — s는 func()의 지역 변수이므로 함수가 반환되는 순간 소멸되는데, 그 소멸된 객체를 참조로 돌려주면 호출자는 이미 끝난 수명을 가진 객체를 참조하게 됩니다. 값 반환이 이 문제를 해결하는 이유는 반환값 자체가 호출자 쪽에 새로 만들어지는 독립적인 객체이기 때문이며, 이 주제는 댕글링 레퍼런스 가이드에서 훨씬 다양한 사례와 함께 자세히 다룹니다.
// ❌ 댕글링 레퍼런스
const std::string& func() {
std::string s = "Hello";
return s; // s는 함수 종료 시 소멸
}
int main() {
const std::string& ref = func();
// ref는 소멸된 객체 참조 (위험!)
}
// ✅ 값 반환
std::string func() {
std::string s = "Hello";
return s; // 복사 또는 이동
}
문제 2: 댕글링 포인터
포인터 버전도 근본 원인은 참조 버전과 동일합니다 — &x로 지역 변수의 주소를 얻어 반환해도, 그 지역 변수 x가 소멸되면 그 주소는 더 이상 유효한 객체를 가리키지 않는 무효한 값이 됩니다. new int(10)으로 동적 할당하면 그 객체의 수명이 함수 스코프가 아니라 명시적 delete 시점까지 이어지므로 이 문제를 피할 수 있지만, 그 대신 그 메모리를 언제 누가 해제할 것인지에 대한 새로운 책임이 생긴다는 점을 함께 고려해야 합니다 — 결국 값 반환이 두 문제(댕글링과 수동 해제 책임) 모두를 피할 수 있는 가장 단순한 해법인 경우가 많습니다.
// ❌ 댕글링 포인터
int* func() {
int x = 10;
return &x; // x는 함수 종료 시 소멸
}
int main() {
int* ptr = func();
// *ptr은 위험!
}
// ✅ 동적 할당 또는 값 반환
int* func() {
return new int(10); // 동적 할당
}
int func() {
return 10; // 값 반환
}
문제 3: 정적 초기화 순서
이는 “정적 초기화 순서 대란(Static Initialization Order Fiasco)“이라 불리는 C++의 유명한 함정입니다 — 서로 다른 번역 단위(다른 .cpp 파일)에 있는 전역 객체들 사이의 동적 초기화 순서는 표준에 의해 정해져 있지 않으며, 오직 같은 파일 안에서 선언된 순서만 보장됩니다. 여기서 “동적”이라는 단어가 중요합니다. int globalA = 10;처럼 컴파일 타임 상수로 초기화되는 변수는 상수 초기화(constant initialization) 대상이라 프로그램이 실행되기 전에 이미 값이 들어 있으므로 순서 문제가 생기지 않습니다. 문제는 아래처럼 std::string 생성자 호출 같은 런타임 코드가 필요한 전역 객체입니다. globalB의 초기화가 먼저 실행되면, 아직 생성자가 돌지 않은 globalA(0으로 채워진 메모리)를 읽게 되어 빈 문자열이 나오거나 크래시가 납니다. 어느 쪽이 먼저인지는 링크 순서에 따라 달라지므로, 오브젝트 파일 순서만 바뀌어도 멀쩡하던 프로그램이 시작하자마자 죽을 수 있습니다.
함수 내부 static 변수로 바꾸면 이 문제가 사라지는 이유는, 그 변수가 “처음 그 함수가 호출되는 시점”에 지연 초기화되기 때문입니다 — getGlobalA()가 호출되어야 비로소 문자열이 생성되므로, 초기화 순서가 프로그램의 실제 호출 순서에 의해 결정되어 항상 예측 가능해집니다. C++20에서는 constinit 지정자로 “이 변수는 반드시 상수 초기화되어야 한다”를 컴파일러에게 검사시킬 수도 있습니다.
// file1.cpp
#include <string>
std::string globalA = "hello"; // 동적 초기화 (생성자 호출)
// file2.cpp
#include <string>
extern std::string globalA;
std::string globalB = globalA + " world"; // globalA가 아직 생성 전일 수 있음
// ✅ 함수 내 정적 변수
std::string& getGlobalA() {
static std::string globalA = "hello";
return globalA;
}
std::string globalB = getGlobalA() + " world"; // 안전
처음 이 함정을 만나면 증상이 이상하게 보입니다. 디버그 빌드에서는 멀쩡하다가 릴리스 빌드나 다른 링커 설정에서만 main 진입 전에 크래시가 나고, 스택 트레이스는 __libc_start_main이나 _GLOBAL__sub_I_... 같은 낯선 초기화 함수를 가리킵니다. 이런 흔적이 보이면 전역 객체 간 의존을 가장 먼저 의심해야 합니다. 소멸 쪽에도 대칭적인 문제가 있어서, 함수 내부 static으로 만든 로거가 다른 전역 객체의 소멸자보다 먼저 파괴되면 종료 시점에 로그를 남기려다 크래시가 납니다. 이 때문에 일부 코드베이스는 static Logger* p = new Logger;처럼 일부러 해제하지 않는 싱글톤을 씁니다.
문제 4: 임시 객체 수명
getName().c_str()이 위험한 이유는, getName()이 반환하는 std::string 임시 객체가 c_str() 호출이 끝나는 이 표현식 전체의 세미콜론 지점에서 소멸되기 때문입니다 — c_str()이 반환한 const char*는 그 임시 문자열의 내부 버퍼를 가리키는데, 문자열 자체가 소멸되면 그 버퍼도 함께 해제되어 ptr은 즉시 댕글링 상태가 됩니다. const std::string& name = getName();처럼 먼저 참조에 바인딩하면 앞서 “수명 연장” 절에서 다룬 규칙에 의해 그 임시 객체의 수명이 name의 수명까지 늘어나므로, 그 이후에 name.c_str()을 호출하는 것은 name이 살아있는 한 안전합니다 — 이는 “임시 객체로부터 얻은 포인터/참조를 별도로 저장하기 전에, 먼저 그 임시 객체 자체를 참조나 변수에 붙잡아 두어야 한다”는 일반적인 원칙을 보여주는 사례입니다.
// ❌ 임시 객체 즉시 소멸
std::string getName() {
return "Alice";
}
const char* ptr = getName().c_str(); // 위험!
// getName()의 임시 객체 소멸
// ptr은 댕글링
// ✅ 수명 연장
const std::string& name = getName();
const char* ptr = name.c_str(); // 안전
같은 계열의 함정이 std::string_view에서 더 자주 나옵니다. std::string_view sv = getName();은 컴파일 경고 없이 통과하지만, string_view는 참조가 아니라 포인터와 길이를 담은 값 타입이라 수명 연장 규칙이 적용되지 않습니다. 문장이 끝나면 getName()의 임시 문자열이 사라지고 sv는 해제된 버퍼를 가리킵니다. 짧은 문자열은 SSO(Small String Optimization) 때문에 스택 버퍼에 있어서 한동안 값이 멀쩡히 읽히는 경우가 많고, 그래서 테스트를 통과한 뒤 긴 문자열이 들어오는 순간에야 깨진 문자가 보입니다. string_view, std::span, 반복자처럼 “빌려 쓰는” 타입은 원본보다 오래 살 수 없다는 규칙을 항상 의식해야 합니다.
range-based for도 비슷한 함정이 있습니다. for (auto& x : makeObj().items())에서 makeObj()가 만든 임시 객체는 C++20까지는 범위 표현식이 평가된 직후 소멸하므로, items()가 돌려준 멤버 참조는 루프를 도는 동안 댕글링입니다. C++23(P2718)에서 범위 초기화식 안의 임시 객체 수명을 루프 끝까지 연장하도록 바뀌었지만, 컴파일러 지원 상황을 확인하기 전까지는 임시 객체를 먼저 지역 변수에 담는 것이 안전합니다.
이런 버그를 잡는 데는 AddressSanitizer가 가장 실용적입니다. -fsanitize=address로 빌드하면 해제된 힙 메모리 접근은 heap-use-after-free로, 끝난 스코프의 지역 변수 접근은 stack-use-after-scope로 보고되며, 함수 반환 후 지역 변수 접근은 ASAN_OPTIONS=detect_stack_use_after_return=1을 켜야 잡힙니다(최신 Clang은 기본 활성).
스마트 포인터와 수명
unique_ptr와 shared_ptr는 동적 저장 기간의 객체를 자동 저장 기간처럼 다룰 수 있게 해 주는 다리 역할을 합니다 — unique_ptr는 정확히 하나의 소유자만 허용해 그 소유자가 스코프를 벗어나는 순간 무조건 소멸시키는 반면, shared_ptr는 참조 카운트를 유지해 “마지막 소유자가 사라지는 시점”까지 수명을 연장합니다. sharedPtr() 예시에서 res2가 안쪽 스코프를 벗어나도 Resource가 소멸되지 않는 이유는 res1이 여전히 그 객체를 가리키고 있어 참조 카운트가 0이 되지 않았기 때문이며, res1마저 스코프를 벗어나 카운트가 0이 되어야 비로소 실제 소멸이 일어납니다 — 즉 shared_ptr를 쓰면 “이 객체의 수명이 정확히 언제 끝나는가”라는 질문의 답이 특정 변수의 스코프가 아니라 “그 객체를 참조하는 모든 shared_ptr 중 마지막 것이 사라지는 시점”으로 바뀝니다.
#include <memory>
class Resource {
public:
Resource() {
std::cout << "Resource 생성" << std::endl;
}
~Resource() {
std::cout << "Resource 소멸" << std::endl;
}
};
void uniquePtr() {
auto res = std::make_unique<Resource>();
// 스코프 벗어나면 자동 소멸
}
void sharedPtr() {
auto res1 = std::make_shared<Resource>();
{
auto res2 = res1; // 참조 카운트 증가
// res2 스코프 벗어남
}
// res1 스코프 벗어나면 소멸
}
int main() {
uniquePtr();
sharedPtr();
}
RAII 패턴
RAII(Resource Acquisition Is Initialization)는 이 글 전체에서 다룬 “수명” 개념을 자원 관리에 그대로 적용한 것입니다 — 파일 핸들이나 뮤텍스 락처럼 명시적으로 해제해야 하는 자원을, C++ 객체의 생성자·소멸자에 획득·해제 로직을 각각 심어 두면 “이 자원의 수명 = 이 객체의 수명”이라는 등식이 성립합니다. ofstream file(filename)이 생성자에서 파일을 열고 소멸자에서 자동으로 닫는 것, lock_guard가 생성자에서 뮤텍스를 잠그고 소멸자에서 자동으로 해제하는 것 모두 이 원리의 적용 사례이며, 이 접근의 진짜 강점은 함수가 예외로 중간에 빠져나가거나 여러 반환 경로를 갖더라도 스코프를 벗어나는 모든 경로에서 소멸자가 반드시 호출되어 자원 해제가 보장된다는 것입니다 — try/catch나 여러 return문마다 수동으로 해제 코드를 반복할 필요가 없어, 이 글에서 다룬 모든 수명 관련 버그(누수, 이중 해제, 해제 누락)를 구조적으로 예방합니다.
#include <fstream>
#include <mutex>
// 파일 자동 관리
void writeFile(const std::string& filename) {
std::ofstream file(filename); // 생성 시 열림
file << "Hello";
// 소멸 시 자동 닫힘
}
// 뮤텍스 자동 관리
std::mutex mtx;
void criticalSection() {
std::lock_guard<std::mutex> lock(mtx); // 생성 시 잠금
// 임계 영역
// 소멸 시 자동 해제
}
FAQ
Q1: 객체의 수명은 정확히 언제 시작하고 끝나나요?
A: 클래스 객체는 생성자가 정상적으로 끝난 시점에 수명이 시작되고, 소멸자 호출이 시작되는 시점에 끝납니다. 생성자가 예외를 던지면 그 객체의 수명은 시작되지 않은 것으로 보므로 소멸자도 호출되지 않고, 그때까지 완성된 멤버와 기반 클래스만 역순으로 정리됩니다. 생성자 안에서 new로 잡은 자원이 새는 흔한 원인이 이것이며, 멤버를 스마트 포인터로 두면 해결됩니다.
Q2: 람다에서 참조 캡처를 쓰면 왜 위험한가요?
A: [&]로 캡처한 지역 변수는 람다 객체가 아니라 원래 스코프가 수명을 결정합니다. 람다를 std::thread, 비동기 콜백, 멤버 변수 등에 저장해 함수가 끝난 뒤 실행하면 이미 소멸한 변수를 참조하게 됩니다. 나중에 실행될 람다는 값으로 캡처하거나 shared_ptr로 공유 소유권을 넘겨야 합니다.
Q3: 댕글링을 컴파일 타임에 잡을 방법이 있나요?
A: 완전한 방법은 없지만 도움이 되는 도구는 있습니다. GCC의 -Wdangling-reference·-Wreturn-local-addr, Clang의 -Wdangling·-Wreturn-stack-address는 명백한 경우를 경고하고, clang-tidy의 bugprone-dangling-handle은 string_view로 임시 문자열을 받는 패턴을 찾아 줍니다. 나머지는 AddressSanitizer를 켠 테스트로 런타임에 잡는 것이 현실적입니다.