C++ 임시 객체: 생성·소멸 시점, const 참조 수명 연장 규칙, 댕글링 참조
이 글의 핵심
임시 객체는 보통 전체 표현식이 끝나면 사라지기 때문에, 참조로 잡아 둔 값이 다음 줄에서 이미 소멸된 상태인 버그가 자주 생깁니다. 이 글은 수명 연장이 적용되는 경우와 적용되지 않는 경우, 비const 참조에 임시 객체를 넘길 수 없는 이유, 복사·이동 생성자 호출을 추적해 불필요한 임시 객체를 찾는 법을 설명합니다.
임시 객체란?
임시 객체(temporary object)는 표현식을 평가하는 중에 만들어지는 이름 없는 객체입니다. 보통 그 표현식이 포함된 문장이 끝나면 소멸하지만, const 참조나 우측값 참조에 직접 바인딩하면 수명이 참조의 수명만큼 연장됩니다.
std::string s = std::string("Hello"); // C++17부터는 임시 없이 s를 직접 초기화
void func(std::string s);
func("Hello"); // const char* → std::string 변환 결과
임시 객체를 이해해야 하는 이유는 두 가지입니다. 하나는 안전성입니다. 임시를 가리키는 참조, 포인터, 반복자를 변수에 저장하면 다음 줄에서 이미 소멸된 객체를 가리키는 댕글링이 됩니다. 다른 하나는 성능입니다. 필요 없는 임시가 생기면 그만큼 생성·복사·소멸 비용이 들고, RVO와 이동 의미론은 이 비용을 없애거나 줄이는 장치입니다.
| 특징 | 이름 있는 객체 | 임시 객체 |
|---|---|---|
| 이름 | 있음 | 없음 |
| 수명 | 스코프 끝 | 전체 표현식 끝 |
| 수명 연장 | 해당 없음 | const 참조·우측값 참조에 직접 바인딩할 때 |
| 이동 대상 | std::move로 명시해야 함 | 우측값이므로 자동으로 이동 대상 |
임시 객체가 생기는 곳
// 1. 함수의 값 반환
std::string getName() {
return "Alice";
}
// 2. 타입 변환
void func(std::string s) {}
func("Hello"); // const char* -> std::string
// 3. 연산자
std::string s = "Hello" + std::string(" World");
// 4. 명시적 생성
Widget(10);
C++17부터는 용어가 조금 더 정밀해졌습니다. getName()이나 std::string("Hello") 같은 표현식은 prvalue라고 부르며, prvalue는 그 자체로는 아직 객체가 아니라 “객체를 초기화하는 방법”에 가깝습니다. 실제 임시 객체는 prvalue를 참조에 바인딩하거나, 멤버에 접근하거나, 결과를 버릴 때처럼 메모리가 필요한 순간에 만들어집니다(temporary materialization). 그래서 std::string s = std::string("Hello");는 C++17부터 임시 객체를 만든 뒤 복사·이동하는 것이 아니라 s 자체를 바로 초기화하도록 보장됩니다. 복사·이동 생성자가 삭제된 타입도 이런 코드가 컴파일되는 이유입니다. int x = 1 + 2;의 1 + 2도 prvalue지만, 클래스 타입이 아닌 값은 객체로 구체화될 일이 없으므로 보통 임시 객체라고 하지 않습니다.
반면 2번과 3번에서는 실제 임시 객체가 생깁니다. func("Hello")는 const char*에서 std::string으로 변환한 결과로 매개변수를 초기화하고, "Hello" + std::string(" World")는 std::string(" World") 임시와 operator+의 결과가 만들어집니다. 긴 문자열 연결 a + b + c + d는 중간 결과마다 임시 문자열을 만드는데, C++11 이후에는 operator+의 우측값 오버로드가 임시의 버퍼를 재사용해 비용이 많이 줄었습니다.
임시 객체의 수명
기본 규칙: 전체 표현식이 끝나면 소멸
#include <iostream>
class Temp {
public:
Temp(int v) : value(v) {
std::cout << "생성 " << value << '\n';
}
~Temp() {
std::cout << "소멸 " << value << '\n';
}
int get() const { return value; }
private:
int value;
};
int main() {
std::cout << "=== 시작 ===\n";
int x = Temp(10).get(); // 문장 끝에서 소멸
std::cout << "=== 끝 ===\n";
}
// 출력:
// === 시작 ===
// 생성 10
// 소멸 10
// === 끝 ===
임시 객체는 자신을 만든 전체 표현식(full-expression)이 끝날 때 소멸합니다. 보통은 세미콜론까지의 한 문장이라고 생각하면 됩니다. 이 규칙 덕분에 Temp(10).get()처럼 임시 객체의 멤버 함수를 호출하고 결과를 쓰는 것은 안전합니다. get()이 끝날 때까지 임시가 살아 있고, 반환된 int는 값으로 복사되어 x에 들어가기 때문입니다. 문제는 반환값이 값이 아니라 참조나 포인터일 때입니다. 그 참조가 가리키는 대상이 임시 객체 안에 있다면, 세미콜론이 지나는 순간 가리킬 대상이 사라집니다. 이 글의 댕글링 예제는 전부 이 한 가지 패턴의 변형입니다.
Temp(10);처럼 이름 없이 만든 객체가 그 줄에서 바로 소멸한다는 것은 RAII 가드에서 심각한 버그가 됩니다. 대표적인 예가 std::lock_guard<std::mutex>{mtx};입니다. 의도는 스코프 끝까지 락을 잡는 것이지만, 이름을 주지 않았으므로 임시 객체가 되어 세미콜론에서 바로 락이 풀리고, 그 아래 코드는 락 없이 실행됩니다. 컴파일 에러도 없어서 데이터 레이스로 뒤늦게 발견됩니다. 소괄호로 std::lock_guard<std::mutex>(mtx);라고 쓰면 구문상 mtx라는 이름의 변수 선언으로 해석되어 전혀 다른 컴파일 에러가 납니다. 가드는 반드시 std::lock_guard<std::mutex> lock(mtx);처럼 이름을 붙여야 합니다. C++20부터는 생성자에 [[nodiscard]]를 붙일 수 있어서, 직접 만든 가드 클래스라면 이런 이름 없는 생성에 컴파일러 경고를 받을 수 있습니다.
수명 연장: 참조에 직접 바인딩할 때
#include <iostream>
int main() {
std::cout << "=== 시작 ===\n";
const Temp& ref = Temp(10); // 수명 연장
std::cout << "중간\n";
std::cout << ref.get() << '\n';
std::cout << "=== 끝 ===\n";
}
// 출력:
// === 시작 ===
// 생성 10
// 중간
// 10
// === 끝 ===
// 소멸 10
수명 연장은 참조가 임시 객체에 직접 바인딩될 때 일어나며, 연장된 임시는 그 참조 변수가 스코프를 벗어날 때 소멸합니다. Temp&& ref = Temp(10);처럼 우측값 참조로 받아도 같습니다. 연장 규칙은 편리하지만 “이 참조가 임시를 가리킨다”는 사실이 코드에 드러나지 않아 읽는 사람을 헷갈리게 하므로, const Temp& ref = Temp(10); 대신 그냥 Temp t(10);이나 auto t = Temp(10);으로 값을 받는 편이 명확합니다. C++17 이후에는 값으로 받아도 추가 복사가 없습니다.
수명이 연장되지 않는 경우
데이터 멤버에 직접 접근한 식에 바인딩하면 연장되지만, 멤버 함수가 반환한 참조나 함수 인자를 거쳐 돌아온 참조는 연장되지 않습니다.
struct Inner {
int value = 42;
};
struct Outer {
Inner inner;
const Inner& getInner() const { return inner; }
};
Outer getOuter() {
return Outer();
}
int main() {
// ✅ 데이터 멤버에 직접 접근: 임시 Outer 전체의 수명이 연장됨
const Inner& inner = getOuter().inner;
// ❌ 댕글링: 멤버 함수가 반환한 참조는 연장되지 않음
const Inner& inner3 = getOuter().getInner();
// getOuter()의 임시 객체는 문장 끝에서 소멸, inner3은 댕글링
// ✅ 전체 객체를 받아 둔 뒤 멤버를 참조
const Outer& outer = getOuter();
const Inner& inner2 = outer.inner;
}
이 부분은 흔히 거꾸로 알려져 있습니다. getOuter().inner처럼 데이터 멤버에 직접 접근하는 식에 참조를 바인딩하면, 표준은 멤버가 속한 임시 객체 전체의 수명을 연장합니다. 참조가 임시의 일부를 가리킨다는 것을 식의 모양만 보고 알 수 있기 때문입니다. 반면 getOuter().getInner()는 함수 호출이라, 컴파일러 입장에서는 반환된 참조가 임시 안을 가리키는지 전역 변수를 가리키는지 알 수 없습니다. 그래서 연장이 일어나지 않고 inner3은 문장이 끝나자마자 소멸된 객체를 가리킵니다. std::optional의 value(), std::map의 at(), 일반적인 게터 함수가 모두 이 경우에 해당해 const auto& v = makeOptional().value(); 같은 코드가 대표적인 댕글링 버그가 됩니다.
같은 이유로 const std::string& s = std::max(a, b + "x");처럼 참조를 받아 참조를 반환하는 함수에 임시를 넘기는 것도 위험합니다. std::max는 인자 중 하나의 참조를 그대로 돌려주므로, 임시 쪽이 선택되면 문장 끝에서 사라집니다. 이런 버그는 AddressSanitizer의 “stack-use-after-scope”나 “heap-use-after-free” 보고로 가장 확실하게 잡을 수 있고, GCC 13부터는 -Wdangling-reference 경고가 일부 경우를 컴파일 단계에서 알려 줍니다.
자주 만나는 댕글링 패턴
c_str()을 변수에 저장하기
#include <string>
std::string getName() {
return "Alice";
}
int main() {
// ❌ 문장 끝에서 임시 문자열이 소멸, ptr은 댕글링
const char* ptr = getName().c_str();
// ✅ 문자열을 값으로 받아 둔 뒤 포인터를 꺼냄
std::string name = getName();
const char* ptr2 = name.c_str(); // name이 살아 있는 동안 유효
}
getName().c_str()은 가장 흔한 임시 객체 버그입니다. c_str()이 돌려준 포인터는 문자열 내부 버퍼를 가리키는데, 그 문자열이 임시라 문장 끝에서 해제됩니다. 짧은 문자열은 SSO(작은 문자열 최적화) 때문에 버퍼가 문자열 객체 안, 즉 스택에 있어서 해제된 뒤에도 한동안 원래 값이 그대로 남아 있는 경우가 많습니다. 그래서 짧은 값으로 하는 테스트에서는 멀쩡하다가, 긴 경로처럼 힙 버퍼를 쓰는 문자열에서만 깨진 문자가 나오거나 C API가 가끔 파일을 찾지 못하는 식으로 증상이 간헐적으로 나타납니다. 반대로 printf("%s", getName().c_str());처럼 같은 문장 안에서 포인터를 다 쓰는 것은 안전합니다. 포인터를 변수에 저장하는 순간이 위험 신호입니다.
참조를 멤버로 보관하기
#include <iostream>
class Widget {
public:
Widget(int val) : value(val) {}
int getValue() const { return value; }
private:
int value;
};
void process(const Widget& w) {
std::cout << "처리: " << w.getValue() << std::endl;
}
int main() {
process(Widget(10)); // 임시는 process 호출을 포함한 문장 끝까지 유효
}
함수 인자로 넘긴 임시는 함수 호출을 포함한 전체 문장이 끝날 때까지 살아 있으므로, process 안에서 w를 쓰는 것은 안전합니다. 위험해지는 것은 함수가 그 참조를 어딘가에 저장할 때입니다. 클래스 생성자가 const Widget&를 받아 멤버 참조로 보관하면, Holder h(Widget(10)); 이후 h가 가진 참조는 이미 소멸된 객체를 가리킵니다. 생성자 매개변수를 거쳐 참조 멤버에 바인딩하는 것은 수명을 연장하지 않습니다. 받은 값을 보관해야 하는 생성자라면 참조가 아니라 값으로 받아 이동(Holder(Widget w) : w_(std::move(w)) {})하는 것이 안전한 기본값입니다.
임시 컨테이너의 반복자와 뷰
#include <vector>
std::vector<int> getVector() {
return {1, 2, 3};
}
int main() {
// ❌ 임시 벡터는 문장 끝에서 소멸
// auto it = getVector().begin();
// ✅ 컨테이너를 값으로 받아 둠
auto vec = getVector();
auto it = vec.begin();
}
반복자는 컨테이너 내부를 가리키는 일종의 포인터라서, 임시 컨테이너에서 얻은 반복자는 c_str() 포인터와 똑같이 문장 끝에서 댕글링이 됩니다. 이 패턴은 범위 기반 for문과 결합될 때 특히 헷갈립니다. for (auto x : getVector())는 범위 식의 결과가 내부의 auto&& 변수에 바인딩되어 수명이 연장되므로 안전하지만, for (auto x : getConfig().items())처럼 임시의 멤버 함수가 돌려준 참조를 순회하면 C++20까지는 getConfig()의 임시가 루프 시작 전에 소멸해 댕글링입니다. C++23(P2718)부터는 범위 식 안의 임시가 루프가 끝날 때까지 연장됩니다. std::string_view와 std::span도 같은 부류로, 임시 문자열이나 벡터에서 뷰를 만들어 변수에 담으면 즉시 댕글링이 됩니다.
체이닝 API를 참조로 받기
class StringBuilder {
std::string buffer_;
public:
StringBuilder& append(const std::string& str) {
buffer_ += str;
return *this;
}
std::string build() const {
return buffer_;
}
};
auto result = StringBuilder()
.append("Hello")
.append(" ")
.append("World")
.build();
이 체이닝이 안전한 이유는 StringBuilder() 임시가 전체 문장이 끝날 때까지 살아 있고, 마지막 build()가 문자열을 값으로 복사해 내보내기 때문입니다. 같은 빌더를 auto& sb = StringBuilder().append("Hello");처럼 참조로 받아 두면, append가 돌려준 *this 참조는 함수가 반환한 참조라 수명 연장을 일으키지 않으므로 다음 줄에서 sb는 댕글링입니다. 체이닝 API는 한 문장 안에서 끝내는 것이 원칙입니다.
build() const가 buffer_를 복사하는 것도 개선할 수 있습니다. 임시 빌더에서 호출될 때는 버퍼를 복사할 필요 없이 가져가면 되므로, std::string build() && { return std::move(buffer_); }처럼 우측값 참조 한정자(&&)를 붙인 오버로드를 추가하면 임시에서 호출할 때는 이동, 이름 있는 빌더에서 호출할 때는 복사하도록 나눌 수 있습니다. 이때 기존 함수에도 std::string build() const& { return buffer_; }처럼 & 한정자를 붙여야 두 오버로드가 함께 선언됩니다.
비const 참조에는 임시를 넘길 수 없는 이유
void func(std::string& s) {}
// func("Hello"); // 에러
void func1(const std::string& s) {}
void func2(std::string&& s) {}
func1("Hello"); // OK
func2("Hello"); // OK
비const 참조에 임시를 바인딩할 수 없는 이유는 수정 결과가 사라지기 때문입니다. func("Hello")가 허용된다면 함수는 const char*에서 변환된 임시 std::string을 수정하고, 그 임시는 문장이 끝나면 버려지므로 호출자는 수정 결과를 볼 수 없습니다. 호출자가 “내 변수가 바뀌었을 것”이라고 착각하는 버그를 막으려고 언어가 금지한 것입니다. GCC는 “cannot bind non-const lvalue reference of type ‘std::string&’ to an rvalue of type ‘std::string’” 에러를 냅니다. MSVC는 오래전부터 이를 확장으로 허용해 왔지만 /permissive-(최신 프로젝트 기본값)에서는 표준대로 에러가 나므로, MSVC에서만 컴파일되던 코드가 다른 컴파일러에서 깨지는 원인이 되기도 합니다.
const std::string&는 “읽기만 하겠다”는 약속이라 임시를 받아도 문제가 없고, std::string&&는 “버려질 값을 받아 가져가겠다”는 뜻이라 임시를 위해 만들어진 타입입니다. 둘 다 오버로드해 두면 호출자가 넘긴 것이 임시인지 아닌지에 따라 복사와 이동을 나눠 처리할 수 있습니다. 이름 있는 객체의 주소를 취하는 것과 달리, &getData()처럼 prvalue의 주소를 취하는 것도 컴파일 에러입니다.
반환과 복사 생략
// prvalue 반환: C++17부터 복사 생략 보장
std::string func1() {
return std::string("Hello");
}
// 이름 있는 지역 변수 반환: NRVO (허용되지만 보장되지 않음)
std::string func2() {
std::string result = "Hello";
return result;
}
// 분기마다 다른 변수를 반환: NRVO가 어려워 이동으로 처리
std::string func3(bool flag) {
std::string a = "Hello";
std::string b = "World";
if (flag) return a;
return b;
}
세 함수는 보장 수준이 다릅니다. func1처럼 prvalue를 바로 반환하는 경우는 C++17부터 복사 생략이 보장되어 호출자의 변수가 직접 초기화됩니다. func2처럼 이름 있는 지역 변수를 반환하는 NRVO는 허용되지만 보장되지는 않는 최적화로, 주요 컴파일러는 이런 단순한 경우에 대부분 적용합니다. func3처럼 분기마다 다른 변수를 반환하면 생략하기 어렵지만, 그때도 반환되는 지역 변수는 자동으로 우측값으로 취급되어 이동 생성자가 호출되므로 깊은 복사는 일어나지 않습니다. 값으로 받은 함수 매개변수를 반환하는 경우도 NRVO 대상은 아니지만 같은 규칙으로 이동됩니다.
그래서 return std::move(result);는 오히려 해롭습니다. std::move를 붙이면 반환 식이 더 이상 지역 변수 이름이 아니게 되어 NRVO 대상에서 빠지고, 생략될 수 있었던 이동이 반드시 한 번 실행됩니다. Clang과 GCC 9 이상은 이 경우 -Wpessimizing-move 경고를 냅니다. 자세한 조건은 C++ RVO와 NRVO: C++17 보장 복사 생략과 return std::move가 NRVO를 막는 경우에서 다룹니다.
복사·이동을 눈으로 추적하기
그림: 실제 실행 결과 — 임시 객체는 그 문장이 끝나는 순간 소멸한다. const 참조에 묶으면 참조가 사라질 때(main 끝)까지 수명이 늘어난다. (환경: Ubuntu 24.04 · g++ 13.3)
#include <iostream>
class Tracker {
public:
Tracker() {
std::cout << "기본 생성자" << std::endl;
}
Tracker(const Tracker&) {
std::cout << "복사 생성자" << std::endl;
}
Tracker(Tracker&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
~Tracker() {
std::cout << "소멸자" << std::endl;
}
};
Tracker getTracker() {
return Tracker();
}
int main() {
std::cout << "=== 시작 ===" << std::endl;
auto t = getTracker();
std::cout << "=== 끝 ===" << std::endl;
}
특수 멤버 함수마다 출력을 넣은 추적 클래스는 어디서 임시 객체와 복사가 생기는지 확인하는 가장 확실한 방법입니다. C++17 이상에서 이 코드를 실행하면 “기본 생성자 → === 끝 === → 소멸자”만 출력되고 복사·이동 생성자는 한 번도 호출되지 않습니다. getTracker()가 prvalue를 반환하고 그것으로 t를 초기화하므로 복사 생략이 보장되기 때문입니다. C++14 이하 표준으로 -fno-elide-constructors(GCC/Clang)를 주고 컴파일하면 생략이 꺼져 이동 생성자 호출이 두 번 보이는데, 이를 비교하면 복사 생략이 무엇을 없애 주는지 알 수 있습니다. 자기 클래스에 이런 출력을 잠시 넣어 두고 vector::push_back이나 함수 인자 전달을 시험해 보면, 예상하지 못한 복사를 빠르게 찾아낼 수 있습니다.
불필요한 임시와 복사 줄이기
// 임시 문자열을 만든 뒤 대입
std::string s = "Hello";
s = s + " World";
// 기존 버퍼에 바로 덧붙임
std::string s2 = "Hello";
s2 += " World";
s = s + " World"는 결과를 담을 새 문자열을 만든 뒤 s에 이동 대입하지만, +=는 s의 버퍼에 바로 덧붙이므로 용량이 충분하면 새 할당이 없습니다. 반복문 안에서 문자열을 계속 이어 붙일 때 차이가 커집니다.
함수가 받은 객체를 복사해서 보관하거나 반환해야 한다면, 매개변수를 값으로 받는 방식이 호출자에게 선택권을 줍니다.
class LargeObject {
std::string data_;
public:
explicit LargeObject(std::string data) : data_(std::move(data)) {}
};
// const 참조로 받아 내부에서 복사: 호출자가 임시를 넘겨도 항상 복사
LargeObject process1(const LargeObject& obj) {
LargeObject result = obj;
// 처리
return result; // NRVO 또는 이동
}
// 값으로 받기: 호출자가 임시나 std::move를 넘기면 복사 없이 이동
LargeObject process2(LargeObject obj) {
// 처리
return obj; // 매개변수는 NRVO 대상이 아니지만 자동으로 이동됨
}
process2(LargeObject("data"))처럼 임시를 넘기면 매개변수가 그 임시로 바로 초기화되고, 반환할 때는 이동만 일어납니다. 반면 process1은 호출자가 무엇을 넘기든 내부에서 한 번 복사합니다. 이름 있는 객체를 넘기면서 원본이 더 필요 없다면 process2(std::move(obj))로 호출합니다.
참고 자료
- cppreference: Lifetime
- cppreference: Copy elision
- Scott Meyers, More Effective C++, Item 19 (임시 객체가 생기는 곳 이해하기)