C++ RVO와 NRVO: C++17 보장 복사 생략과 return std::move가 NRVO를 막는 경우
이 글의 핵심
RVO는 반환할 임시 객체를 호출자의 자리에 바로 만들어 복사를 없애고, C++17부터는 언어 규칙으로 보장됩니다. 이름 있는 지역 변수를 반환하는 NRVO는 여전히 컴파일러 재량이라, 반환 경로가 여럿이거나 삼항 연산자·std::move를 쓰면 적용되지 않습니다. 어떤 코드에서 복사·이동·생략이 일어나는지 GCC로 직접 확인한 결과와, return std::move가 느려지는 경우와 아닌 경우를 정리합니다.
들어가며: “return에 std::move를 써야 하나요?”
C++에서 함수 반환 시 복사가 생략되는 것은 RVO(Return Value Optimization) 덕분입니다.
// ❌ std::move 불필요
std::string foo() {
std::string result = "Hello";
return std::move(result); // ❌ RVO 방해
}
// ✅ RVO 활용
std::string foo() {
std::string result = "Hello";
return result; // ✅ 복사 생략
}
이 글에서 다루는 것:
- RVO와 NRVO
- C++17 복사 생략 보장
- std::move 실수
- 성능 측정
RVO란?
RVO (Return Value Optimization)
RVO는 함수 반환 시 복사를 생략하는 최적화입니다.
struct Data {
std::vector<int> vec;
Data() {
std::cout << "Constructor\n";
}
Data(const Data&) {
std::cout << "Copy Constructor\n";
}
Data(Data&&) noexcept {
std::cout << "Move Constructor\n";
}
};
// RVO 예시
Data createData() {
return Data(); // 임시 객체 반환
}
int main() {
Data d = createData();
// 출력: Constructor (복사/이동 없음!)
}
RVO 조건:
- 임시 객체 (prvalue) 반환
- C++17부터 보장
원리는 호출 규약에 있습니다. 반환 타입이 레지스터에 담기지 않는 크기의 객체라면, 호출자가 반환값이 놓일 메모리를 미리 마련해 그 주소를 숨은 인자로 넘기고, 함수는 그 주소에 결과를 바로 생성합니다. return Data();에서 임시 객체를 따로 만들었다가 옮길 이유가 없으니 생성이 한 번으로 끝나는 것입니다. C++17은 여기서 한 걸음 더 나아가, prvalue를 “아직 만들어지지 않은 객체의 초기화 방법”으로 정의를 바꿨습니다. 그래서 C++17의 return Data();는 “복사를 생략한 것”이 아니라 애초에 복사할 임시 객체가 존재하지 않는 코드이고, 아래 NonMovable 예제처럼 복사·이동 생성자가 삭제된 타입도 반환할 수 있게 됩니다.
NRVO란?
NRVO (Named Return Value Optimization)
NRVO는 이름 있는 지역 변수 반환 시 복사를 생략합니다.
// NRVO 예시
Data createData() {
Data result; // 이름 있는 변수
result.vec.push_back(42);
return result; // NRVO 가능
}
int main() {
Data d = createData();
// 출력: Constructor (복사/이동 없음!)
}
NRVO 조건:
- 이름 있는 지역 변수 반환
- 모든 return문이 같은 변수 반환
- C++17에서도 선택적 (보장 안 됨)
NRVO가 여전히 선택적인 이유는, 컴파일러가 지역 변수 result를 처음부터 호출자의 반환 공간에 만들 수 있는지 함수 전체를 분석해야 알 수 있기 때문입니다. 반환 후보가 하나로 정해지면 GCC와 Clang은 최적화 옵션과 관계없이(-O0에서도) 거의 항상 적용합니다. MSVC는 과거에 디버그 빌드에서 NRVO를 하지 않아 “릴리스에서는 이동이 안 찍히는데 디버그에서는 찍힌다”는 차이가 흔했고, Visual Studio 2022 17.4부터 /Zc:nrvo 옵션이 생겨 /O2나 /permissive-, /std:c++20 이상에서는 기본으로 켜집니다. 따라서 NRVO에 의미상 의존하는 코드(생성자·소멸자 부작용이 한 번만 일어나야 하는 코드)는 이식성이 없습니다. 생성 횟수에 정확성이 달린 타입이라면 prvalue 반환으로 구조를 바꾸는 것이 안전합니다.
C++17 복사 생략 보장
C++17 이전: 선택적
// C++14
Data createData() {
return Data(); // RVO 가능 (선택적)
}
// 컴파일러가 RVO를 안 하면:
// 1. 이동 생성자 호출
// 2. 이동 생성자도 없으면 복사 생성자 호출
C++17 이후: 보장
// C++17
Data createData() {
return Data(); // RVO 보장
}
// 이동/복사 생성자가 없어도 OK
struct NonMovable {
NonMovable() = default;
NonMovable(const NonMovable&) = delete;
NonMovable(NonMovable&&) = delete;
};
NonMovable createNonMovable() {
return NonMovable(); // ✅ C++17: OK
}
std::move 실수
실수 1: return에 std::move
// ❌ RVO 방해
std::string foo() {
std::string result = "Hello";
return std::move(result); // ❌ RVO 불가
}
// ✅ RVO 활용
std::string foo() {
std::string result = "Hello";
return result; // ✅ NRVO 가능
}
이유: std::move는 rvalue 참조로 변환하므로 NRVO 조건 위반.
조금 더 정확히 말하면, NRVO는 return 문의 피연산자가 지역 변수의 이름 그 자체일 때만 허용됩니다. std::move(result)는 result를 가리키는 std::string&&를 돌려주는 함수 호출 표현식이라, 컴파일러 입장에서는 “지역 변수를 반환”하는 것이 아니라 “어떤 참조가 가리키는 객체로 반환값을 초기화”하는 것이 됩니다. 그래서 생략이 불가능해지고 반드시 이동 생성자가 한 번 호출됩니다. 게다가 return result;만 써도 C++11부터 이미 암시적 이동이 적용되므로, std::move를 붙여서 얻는 것은 없고 잃는 것(생략 기회)만 있습니다. std::move가 의미 있는 드문 예외는 멤버 변수(return std::move(member_);)처럼 지역 변수가 아닌 것을 넘겨주고 싶을 때입니다.
실수 2: 여러 return문
// ❌ NRVO 불가
std::string foo(bool flag) {
std::string a = "A";
std::string b = "B";
if (flag) {
return a; // a 반환
} else {
return b; // b 반환 (다른 변수!)
}
}
// NRVO 조건 위반: 여러 변수 반환
// 이동 생성자 호출
이 코드를 NRVO가 되도록 고치는 방법은 반환 후보를 하나로 모으는 것입니다. std::string result = flag ? "A" : "B"; return result;처럼 결과 변수를 하나만 두면 모든 경로가 같은 객체를 반환하게 됩니다. a와 b가 무거운 계산의 결과라서 둘 다 만들어야 한다면, 선택되지 않은 쪽은 어차피 버려지므로 계산 자체를 조건 안으로 옮기는 편이 생략 여부보다 훨씬 큰 이득입니다.
실수 3: 참조 반환
// ❌ RVO 불가
std::string& foo() {
std::string result = "Hello";
return result; // ❌ 댕글링 참조
}
// ✅ 값 반환
std::string foo() {
std::string result = "Hello";
return result; // ✅ NRVO 가능
}
성능 측정
벤치마크
#include <benchmark/benchmark.h>
struct Data {
std::vector<int> vec;
Data() : vec(1000000, 42) {}
};
// RVO
static void BM_RVO(benchmark::State& state) {
for (auto _ : state) {
Data d = []() { return Data(); }();
benchmark::DoNotOptimize(d);
}
}
BENCHMARK(BM_RVO);
// std::move (RVO 방해)
static void BM_Move(benchmark::State& state) {
for (auto _ : state) {
Data d = []() {
Data result;
return std::move(result);
}();
benchmark::DoNotOptimize(d);
}
}
BENCHMARK(BM_Move);
결과를 해석할 때 주의할 점: 이 벤치마크로 “std::move 쪽이 몇 % 느리다”는 결론을 내기는 어렵습니다. 반복 시간의 거의 전부는 원소 100만 개를 채우는 Data 생성자(메모리 할당과 4MB 쓰기)에 쓰이고, NRVO가 막혀서 추가되는 것은 vector 내부 포인터 세 개를 옮기는 이동 생성자 한 번뿐이기 때문입니다. 두 경우의 차이는 할당기 상태나 캐시 같은 측정 잡음보다 작아, 실행할 때마다 순위가 바뀌어도 이상하지 않습니다. 그러니 return std::move(local)의 문제는 “느리다”가 아니라 아무 이득 없이 생략을 막는다는 것이고, 이동 생성자가 없거나 비싼 타입(큰 std::array, 이동이 복사와 같은 POD 구조체)에서만 실제 비용이 됩니다. 이 차이를 벤치마크로 보고 싶다면 이동이 비싼 타입으로 재야 하고, 그보다는 위 4-1절처럼 생성자에 로그를 넣어 확인하는 편이 정확합니다.
4-1. NRVO가 되는 코드와 안 되는 코드: 직접 확인
생성자·복사·이동에 출력문을 넣은 타입으로 GCC 10(-O2 -std=c++17)에서 확인한 결과입니다.
struct T {
T() { std::puts("ctor"); }
T(const T&) { std::puts("copy"); }
T(T&&) noexcept { std::puts("move"); }
};
T nrvo() { T t; return t; }
T moved() { T t; return std::move(t); }
T twoPaths(bool f) { T a, b; if (f) return a; return b; }
T ternary(bool f) { T a, b; return f ? a : b; }
T param(T p) { return p; }
T fromOther() { T a; T b = a; return b; }
| 함수 | 출력 (반환 부분) | 결과 |
|---|---|---|
nrvo() | ctor | NRVO 적용 (복사·이동 없음) |
moved() | ctor, move | std::move가 NRVO를 막음 |
twoPaths() | ctor, ctor, move | 반환 후보가 둘이라 NRVO 불가 → 암시적 이동 |
ternary() | ctor, ctor, copy | 조건 연산자 결과는 변수 이름이 아니라서 암시적 이동도 안 됨 |
param() | ctor, move | 함수 매개변수는 NRVO 대상이 아님 → 암시적 이동 |
fromOther() | ctor, copy(b = a) | return b는 NRVO 적용. 지역 변수가 둘이어도 반환되는 것이 하나면 된다 |
가장 의외인 줄은 ternary()입니다. if로 나눠 쓴 twoPaths()는 적어도 이동이 일어나지만, 같은 의미를 삼항 연산자로 한 줄에 쓰면 복사가 됩니다. std::string이나 std::vector처럼 복사가 비싼 타입에서 이 차이는 그대로 성능 차이가 되므로, 서로 다른 지역 변수를 조건에 따라 반환해야 한다면 if로 나누는 편이 낫습니다. 반대로 fromOther()는 “지역 변수가 여럿이면 NRVO가 안 된다”는 흔한 설명과 달리 잘 적용됩니다. 기준은 선언된 변수 개수가 아니라 반환 문에 나오는 객체가 하나로 정해지는가입니다.
컴파일러에게 물어보기: GCC·Clang은 -Wall에 포함된 -Wpessimizing-move로 return std::move(local);을 경고합니다(moving a local object in a return statement prevents copy elision). 실수로 넣은 std::move는 이 경고로 대부분 잡힙니다. -fno-elide-constructors로 빌드하면 NRVO가 꺼져 nrvo()에서도 move가 출력되지만, C++17 이상에서는 return T(); 같은 prvalue 반환(RVO)은 언어 규칙이라 이 옵션으로도 꺼지지 않습니다.
C++20·C++23에서 넓어진 암시적 이동
C++11의 암시적 이동은 “반환 타입과 지역 변수 타입이 같을 때”로 좁게 정의되어 있어서, 예를 들어 std::unique_ptr<Derived> 지역 변수를 std::unique_ptr<Base>로 반환하거나 rvalue 참조 매개변수(T&& p)를 반환하면 복사가 시도되어 컴파일 에러나 불필요한 복사가 생겼습니다. C++20(P1825)은 이런 경우까지 암시적 이동을 확장했고, C++23(P2266)은 규칙을 단순화해 return 문에 쓴 지역 변수나 매개변수 이름은 xvalue로 취급하도록 바꿨습니다. 그 결과 옛 코드에서 “여기서는 std::move가 필요하다”고 적어 두었던 경우도 대부분 불필요해졌습니다. 다만 C++23의 변경 때문에 T& f(T&& x) { return x; }처럼 rvalue 참조 매개변수를 lvalue 참조로 반환하던 코드는 컴파일 에러가 나므로, 표준 버전을 올릴 때 이 경고를 확인해야 합니다. 위 표의 ternary()처럼 조건 연산자를 반환하는 경우는 C++23에서도 이름이 아닌 표현식이라 여전히 복사가 됩니다.
같이 보면 좋은 글
- C++ Move 시맨틱스
- C++ 복사 생략 | Copy Elision 가이드
- C++ rvalue와 lvalue
- C++ 복사·이동 생성자
- C++ 시리즈 전체 보기
- C++ return 문: 반환 타입, RVO, 여러 값 반환하기
자주 묻는 질문 (FAQ)
Q. 조건에 따라 서로 다른 지역 변수를 반환하면 왜 NRVO가 적용되지 않나요?
A. NRVO는 반환될 지역 객체를 처음부터 호출자가 마련한 반환 공간에 직접 생성하는 방식이라, 어떤 변수가 반환될지 컴파일 타임에 하나로 정해져야 합니다. if에 따라 a나 b를 반환하면 두 객체를 모두 그 자리에 둘 수 없으므로 컴파일러는 생략을 포기하고 이동 생성(없으면 복사)을 합니다. 본문의 여러 return문 실수처럼, 반환할 값을 하나의 result 변수에 모아 모든 경로에서 그 변수를 반환하도록 바꾸면 NRVO가 적용될 가능성이 높아집니다.