C++/Concepts

C++ Copy Elision과 RVO/NRVO 제대로 이해하기

Enchantée 2026. 7. 30. 22:28
728x90
반응형

C++에서 함수가 객체를 반환할 때마다 copy나 move가 발생한다고 생각하기 쉽습니다.

그래서 성능을 걱정한 나머지 return 문에 std::move를 붙이는 코드를 종종 보게 됩니다.

하지만 C++17 이후에는 prvalue 반환에서 copy elision이 보장되는 경우가 있고, named local object 반환에서도 compiler가 NRVO를 적용할 수 있습니다.

오히려 return std::move(local)처럼 작성하면 NRVO가 막히고 move가 강제로 발생할 수 있습니다.

 

객체를 값으로 반환할 때는 먼저 copy elision과 NRVO 가능성을 이해하고, local object에 습관적으로 std::move를 붙이지 않는 것이 중요합니다.

 

copy elision은 값 반환 코드를 더 단순하게 유지하면서도 불필요한 copy와 move를 줄여주는 C++의 중요한 최적화 규칙입니다.

 


1. 들어가며

Modern C++에서는 객체를 값으로 반환하는 코드가 자연스럽습니다.

예전 C++ 경험이 있으면 “큰 객체를 반환하면 copy가 비싸지 않을까?”라는 걱정을 먼저 할 수 있습니다.

그래서 output parameter를 쓰거나, pointer를 반환하거나, return 문에 std::move를 붙여 최적화하려는 시도를 하기도 합니다.

하지만 이런 코드는 오히려 의도를 흐리고 compiler가 해줄 수 있는 최적화를 방해할 수 있습니다.

 

오늘은 C++17 기준으로 copy elision, RVO, NRVO가 어떤 의미인지 정리하고, return std::move(local)이 왜 조심해야 할 코드인지 예제로 확인해보겠습니다.

 

반환 형태 예시 일반적인 결과
prvalue 반환 return Trace{"x"}; C++17에서 copy elision 보장
named local 반환 Trace value; return value; NRVO가 적용될 수 있음
local object를 std::move로 반환 return std::move(value); NRVO가 막히고 move가 발생할 수 있음
member 반환 return member_; 보통 copy 또는 move 대상

핵심은 반환 값을 무조건 최적화하려고 손대기보다, compiler가 객체를 어디에 직접 생성할 수 있는지 보는 것입니다.

 


2. 왜 중요한가?

값 반환은 API를 단순하게 만듭니다.

함수가 객체를 만들어 반환하면 호출자는 auto result = make_value();처럼 자연스럽게 받을 수 있습니다.

반면 output parameter나 raw pointer 반환은 ownership과 lifetime을 더 어렵게 만들 수 있습니다.

따라서 copy elision을 이해하면 성능 때문에 불필요하게 복잡한 API를 만드는 일을 줄일 수 있습니다.

 

  1. 값 반환 API를 더 자신 있게 사용할 수 있습니다.
  2. 불필요한 std::move 때문에 NRVO를 막는 실수를 줄일 수 있습니다.
  3. copy, move, destructor 호출 로그를 보고 객체 lifetime을 해석할 수 있습니다.
  4. 성능 최적화와 코드 가독성 사이에서 더 나은 기본값을 선택할 수 있습니다.
  5. 면접에서 move semantics를 단순 문법이 아니라 compiler 최적화와 연결해 설명할 수 있습니다.

실무에서는 함수가 vector, string, DTO, configuration object, parser result 같은 값을 반환하는 경우가 많습니다.

이때 “return by value는 느리다”는 막연한 생각만 갖고 있으면 오히려 C++다운 코드를 쓰기 어렵습니다.

 


3. 핵심 개념

copy elision은 copy나 move가 일어날 것처럼 보이는 상황에서 compiler가 그 중간 객체 생성을 생략하는 동작입니다.

RVO는 Return Value Optimization의 약자로, 함수의 반환 객체를 caller 쪽 저장 공간에 직접 생성하는 최적화로 이해할 수 있습니다.

NRVO는 Named Return Value Optimization입니다.

함수 안의 이름 있는 local object를 반환할 때 compiler가 그 object를 반환 위치에 직접 만들 수 있으면 copy나 move를 생략합니다.

 

개념 설명 주의할 점
copy elision copy나 move 대상인 임시 객체 생성을 생략 C++17에서 일부 상황은 최적화가 아니라 언어 규칙에 가까움
RVO 반환 객체를 caller의 결과 저장 위치에 직접 생성 return Type{...}; 같은 형태에서 이해하기 쉬움
NRVO 이름 있는 local object를 반환 위치에 직접 생성 가능한 최적화지만 모든 형태에서 항상 보장되는 것은 아님
std::move 객체를 xvalue로 cast해 move 대상임을 표현 local return에 붙이면 NRVO 기회를 없앨 수 있음

C++17에서 return Trace{"x"};처럼 prvalue를 반환하는 경우에는 반환 객체가 바로 만들어진다고 이해하는 편이 좋습니다.

반면 Trace value; return value;처럼 이름 있는 local object를 반환하는 경우에는 compiler가 NRVO를 적용할 수 있습니다.

여기에 return std::move(value);를 쓰면 반환식이 더 이상 NRVO가 기대하는 단순한 local object 이름이 아니게 됩니다.

그래서 move constructor가 호출될 수 있고, 그만큼 최적화 여지가 줄어듭니다.

 


4. 예제 코드

아래 예제는 생성자, copy constructor, move constructor, destructor가 언제 호출되는지 출력합니다.

make_prvalue(), make_named(), make_forced_move() 세 함수를 비교하면 prvalue 반환, NRVO 후보, std::move로 NRVO를 막는 경우의 차이를 볼 수 있습니다.

#include <iostream>
#include <string>
#include <utility>

class Trace {
public:
    explicit Trace(std::string name)
        : name_(std::move(name))
    {
        std::cout << "ctor " << name_ << '\n';
    }

    Trace(const Trace& other)
        : name_(other.name_ + ".copy")
    {
        std::cout << "copy " << other.name_ << " -> " << name_ << '\n';
    }

    Trace(Trace&& other) noexcept
        : name_(std::move(other.name_))
    {
        std::cout << "move " << name_ << '\n';
        other.name_ = "moved-from";
    }

    ~Trace()
    {
        std::cout << "dtor " << name_ << '\n';
    }

private:
    std::string name_;
};

Trace make_prvalue()
{
    std::cout << "make_prvalue\n";
    return Trace{"prvalue"};
}

Trace make_named()
{
    std::cout << "make_named\n";
    Trace value{"named"};
    return value;
}

Trace make_forced_move()
{
    std::cout << "make_forced_move\n";
    Trace value{"forced"};
    return std::move(value);
}

int main()
{
    std::cout << "[prvalue]\n";
    auto a = make_prvalue();

    std::cout << "[named local]\n";
    auto b = make_named();

    std::cout << "[return std::move(local)]\n";
    auto c = make_forced_move();

    std::cout << "end of main\n";
}

 

Trace 객체는 생성, copy, move, 소멸 시점을 모두 출력합니다.

실제 서비스 코드에서는 이런 logging type을 만들 일은 거의 없지만, 객체 lifetime을 관찰하는 학습용 예제로는 유용합니다.

 


5. 실행 결과 또는 예상 결과

위 코드를 clang++ -std=c++17로 컴파일하고 실행하면 다음과 같은 결과를 확인할 수 있습니다.

[prvalue]
make_prvalue
ctor prvalue
[named local]
make_named
ctor named
[return std::move(local)]
make_forced_move
ctor forced
move forced
dtor moved-from
end of main
dtor forced
dtor named
dtor prvalue

 

make_prvalue()에서는 Trace{"prvalue"}가 반환 위치에 직접 생성되므로 copy나 move 출력이 없습니다.

make_named()에서는 이름 있는 local object를 반환하지만, 이 실행에서는 compiler가 NRVO를 적용해 copy와 move가 보이지 않습니다.

make_forced_move()에서는 return std::move(value); 때문에 move가 실제로 발생합니다.

이 결과만 봐도 local object 반환에 습관적으로 std::move를 붙이는 것이 항상 좋은 최적화가 아니라는 점을 확인할 수 있습니다.

 

상태 변화

return Trace{"prvalue"}
반환 객체가 결과 저장 위치에 직접 생성됨
copy 없음
move 없음

Trace value{"named"}; return value;
compiler가 NRVO를 적용할 수 있음
적용되면 copy 없음
move 없음

Trace value{"forced"}; return std::move(value);
value를 xvalue로 cast
NRVO 기회가 사라짐
move constructor 호출 가능

 

NRVO는 compiler 최적화이므로 조건과 compiler 설정에 따라 결과가 다를 수 있습니다.

하지만 C++17의 prvalue 반환과 return std::move(local)의 차이는 코드 리뷰에서 안정적으로 짚을 수 있는 포인트입니다.

 


6. 그림으로 이해하기

값 반환의 차이를 그림으로 보면 다음과 같습니다.

핵심은 반환 객체가 caller의 저장 위치에 직접 생성될 수 있는지, 아니면 local object에서 결과 객체로 move해야 하는지입니다.

 

prvalue 반환과 NRVO는 반환 객체를 결과 위치에 직접 만들 수 있지만, return std::move(local)은 local object에서 결과 객체로 move하는 경로를 만들 수 있습니다.

 

그림의 위쪽 PRVALUE 흐름은 return Trace{"prvalue"}처럼 임시 객체를 반환할 때 결과 객체가 caller의 저장 위치에 바로 만들어지는 경우입니다.

가운데 NRVO 흐름은 Trace value{"named"}; return value;처럼 이름 있는 local object를 반환하지만, compiler가 local object와 result를 같은 저장 위치로 취급할 수 있는 경우입니다.

아래쪽 STD MOVE 흐름은 return std::move(value);처럼 local object를 xvalue로 바꾸면서 NRVO 기회를 잃고, 별도의 result 객체로 move가 발생할 수 있는 경우입니다.

따라서 이 그림에서 핵심은 좌우 방향이 아니라 세 줄의 차이입니다. 위쪽과 가운데는 move가 필요 없는 경로이고, 아래쪽만 실제 move 경로를 보여줍니다.

 


7. 자주 하는 오해

  1. 값으로 반환하면 항상 copy가 발생한다.
    C++17 이후 prvalue 반환에서는 copy elision이 보장되는 경우가 있습니다. named local 반환도 NRVO가 적용되면 copy와 move가 생략될 수 있습니다.
  2. return std::move(local)은 항상 더 빠르다.
    local object를 반환할 때 std::move를 붙이면 NRVO가 막혀 오히려 move가 추가될 수 있습니다.
  3. move는 비용이 0이다.
    move는 resource pointer 교환처럼 저렴한 경우가 많지만, 타입에 따라 상태 정리, 작은 buffer 처리, invariant 유지 비용이 있을 수 있습니다.
  4. NRVO는 반드시 항상 일어난다.
    NRVO는 가능한 최적화입니다. 여러 local object 중 조건에 따라 반환하는 구조처럼 복잡한 흐름에서는 적용되지 않을 수 있습니다.
  5. 최적화를 위해 output parameter를 기본으로 써야 한다.
    값 반환이 더 명확한 API가 되는 경우가 많고, compiler가 copy elision을 적용할 수 있으므로 먼저 값 반환을 기본값으로 검토하는 편이 좋습니다.

이 오해들은 대부분 std::move를 “성능을 올리는 함수”처럼 기억해서 생깁니다.

std::move는 실제로 move를 수행하는 함수가 아니라, move 대상으로 취급하라는 cast라는 점을 다시 떠올려야 합니다.

 


8. 실무에서는 어떻게 볼까?

실무 코드 리뷰에서 가장 자주 볼 패턴은 local object를 만든 뒤 마지막에 return std::move(result);를 쓰는 코드입니다.

대부분의 경우 이 코드는 return result;로 두는 편이 좋습니다.

Compiler가 NRVO를 적용할 수 있는 기회를 남겨두기 때문입니다.

 

상황 권장 표현 이유
임시 객체를 바로 반환 return Result{...}; C++17에서 결과 위치에 직접 생성되는 형태가 명확함
local result 하나를 만들어 반환 return result; NRVO 기회를 유지함
함수 parameter를 반환 return std::move(param); parameter는 NRVO 대상 local object가 아니므로 move 의도를 표현할 수 있음
member를 반환 상황에 따라 copy 또는 move 명확화 객체 상태를 비우는 API인지 확인해야 함
여러 local object 중 하나 반환 단순 구조로 재검토 NRVO 적용 가능성이 낮아질 수 있음

즉, local object 반환에서는 std::move를 줄이고, parameter나 member처럼 실제로 ownership 이전 의도가 있는 곳에서만 조심해서 쓰는 편이 좋습니다.

또 성능이 정말 중요하다면 추측하지 말고 compiler warning, benchmark, profiler를 함께 봐야 합니다.

Clang과 GCC는 return std::move(local) 같은 pessimized move를 경고해주는 경우가 있으므로 warning level을 높여두는 것도 도움이 됩니다.

 


9. 정리

C++17의 prvalue 반환에서는 copy elision이 보장되는 경우가 있습니다.

이름 있는 local object를 반환할 때는 compiler가 NRVO를 적용할 수 있습니다.

return std::move(local)은 NRVO를 막고 move를 발생시킬 수 있으므로 습관적으로 쓰지 않는 편이 좋습니다.

값 반환은 성능 때문에 피해야 할 패턴이 아니라, Modern C++에서 자연스럽고 명확한 API 설계 방식입니다.

최적화가 걱정된다면 코드 모양을 단순하게 유지하고, 실제 컴파일러 출력과 측정 결과를 확인해야 합니다.

 

값으로 반환할 때의 기본 판단은 “return result;로 충분한가?”입니다. std::move는 자동으로 빨라지는 주문이 아니라, compiler의 선택지를 바꾸는 표현입니다.

 


728x90
반응형