Skip to content

C++ 이동 의미론 & 완벽 전달

개요 — 왜 이동 의미론이 필요한가

Section titled “개요 — 왜 이동 의미론이 필요한가”

C++03까지는 객체를 다른 위치로 전달할 때 항상 **복사(Copy)**가 일어났습니다. 크기가 큰 벡터, 문자열, 버퍼를 함수에 넘기거나 반환할 때마다 전체 데이터를 복제했고, 이는 심각한 성능 낭비였습니다.

// C++03 — 반환 시 전체 복사 발생 (이론상)
std::vector<int> CreateLargeVector()
{
std::vector<int> Result(1000000, 42);
return Result; // 백만 개 원소 전체 복사
}

C++11의 **이동 의미론(Move Semantics)**은 복사 대신 소유권을 이전해 성능을 극적으로 개선합니다.

비교복사 (Copy)이동 (Move)
동작데이터 전체 복제소유권(포인터) 이전
원본 상태변경 없음유효하지만 비어있음
비용O(n)O(1)
사용 시점원본이 계속 필요할 때원본이 더 필요 없을 때

이동 의미론을 이해하려면 먼저 lvalue와 rvalue를 구분해야 합니다.

int x = 10; // x는 lvalue (이름이 있고, 주소를 취할 수 있음)
int y = x + 5; // (x + 5)는 rvalue (임시값, 주소를 취할 수 없음)
int&& r = 10; // rvalue 참조 — rvalue를 참조로 붙잡기
// lvalue 참조: 이름 있는 변수만 바인딩
int& lref = x; // OK
// int& lref2 = 10; // 오류 — rvalue를 lvalue 참조에 바인딩 불가
// const lvalue 참조: 예외적으로 rvalue도 바인딩 가능 (C++ 전통)
const int& clref = 10; // OK (임시 수명 연장)
// rvalue 참조: 임시 객체(rvalue)만 바인딩
int&& rref = 10; // OK
// int&& rref2 = x; // 오류 — lvalue를 rvalue 참조에 바인딩 불가
표현식
├─ glvalue (generalized lvalue) — 주소를 취할 수 있음
│ ├─ lvalue — 이름 있는 변수, 참조
│ └─ xvalue (eXpiring) — std::move로 변환된 lvalue
└─ rvalue — 주소를 취할 수 없는 임시값
├─ prvalue — 리터럴, 임시 객체
└─ xvalue

2. 이동 생성자와 이동 대입 연산자

Section titled “2. 이동 생성자와 이동 대입 연산자”
class Buffer
{
public:
explicit Buffer(size_t Size)
: Data(new int[Size])
, Size(Size)
{
std::fill(Data, Data + Size, 0);
std::cout << "생성자 호출 (" << Size << ")\n";
}
// 소멸자
~Buffer()
{
delete[] Data;
std::cout << "소멸자 호출\n";
}
// 복사 생성자 — 데이터 전체 복제
Buffer(const Buffer& Other)
: Data(new int[Other.Size])
, Size(Other.Size)
{
std::copy(Other.Data, Other.Data + Other.Size, Data);
std::cout << "복사 생성자 호출 (비용: " << Size << ")\n";
}
// 복사 대입 연산자
Buffer& operator=(const Buffer& Other)
{
if (this != &Other)
{
delete[] Data;
Data = new int[Other.Size];
Size = Other.Size;
std::copy(Other.Data, Other.Data + Other.Size, Data);
std::cout << "복사 대입 호출\n";
}
return *this;
}
// 이동 생성자 — 포인터 소유권만 이전 (O(1))
Buffer(Buffer&& Other) noexcept
: Data(Other.Data) // 포인터 가져오기
, Size(Other.Size)
{
Other.Data = nullptr; // 원본을 비움 (소멸자에서 delete 방지)
Other.Size = 0;
std::cout << "이동 생성자 호출 (비용: O(1))\n";
}
// 이동 대입 연산자
Buffer& operator=(Buffer&& Other) noexcept
{
if (this != &Other)
{
delete[] Data; // 기존 리소스 해제
Data = Other.Data; // 소유권 이전
Size = Other.Size;
Other.Data = nullptr;
Other.Size = 0;
std::cout << "이동 대입 호출\n";
}
return *this;
}
private:
int* Data;
size_t Size;
};

이동 생성자/대입 연산자에 noexcept를 붙이지 않으면 std::vector가 내부 재할당 시 이동 대신 복사를 사용합니다.

// noexcept 없으면 vector가 복사를 선택
Buffer(Buffer&& Other) // vector는 복사 사용 (느림)
Buffer(Buffer&& Other) noexcept // vector는 이동 사용 (빠름) ← 올바른 선언

std::move는 실제로 이동을 수행하지 않습니다. lvalue를 rvalue 참조로 캐스팅해 컴파일러가 이동 생성자/대입을 선택하도록 힌트를 줄 뿐입니다.

Buffer A(1000000); // 생성자 호출
Buffer B = A; // 복사 생성자 (A는 그대로 유지)
Buffer C = std::move(A); // 이동 생성자 (A는 비워짐)
// std::move 이후 A는 "유효하지만 비어있는(valid but unspecified)" 상태
// A를 다시 사용하기 전에 재초기화 필요
A = Buffer(500); // OK — 재초기화 후 안전하게 사용 가능
// <utility> 내부 구현 (단순화)
template<typename T>
typename std::remove_reference<T>::type&& move(T&& arg) noexcept
{
return static_cast<typename std::remove_reference<T>::type&&>(arg);
}
std::string Greet()
{
std::string Message = "Hello";
return std::move(Message); // 잘못된 사용!
// RVO(Return Value Optimization)을 방해해 오히려 느려질 수 있음
// return Message; 가 올바름 — 컴파일러가 자동으로 이동 선택
}
// std::move 후 원본 사용 — 위험
std::vector<int> Vec = {1, 2, 3};
auto Moved = std::move(Vec);
// Vec.size()는 0 (비어있음) — 사용 시 버그 발생 가능

소멸자, 복사 생성자, 복사 대입 중 하나라도 정의하면 나머지 다섯 개를 모두 명시적으로 정의해야 합니다.

class ManagedResource
{
public:
ManagedResource();
~ManagedResource(); // 1. 소멸자
ManagedResource(const ManagedResource&); // 2. 복사 생성자
ManagedResource& operator=(const ManagedResource&); // 3. 복사 대입
ManagedResource(ManagedResource&&) noexcept; // 4. 이동 생성자
ManagedResource& operator=(ManagedResource&&) noexcept; // 5. 이동 대입
};
class NonCopyable
{
public:
NonCopyable() = default;
~NonCopyable() = default;
// 복사 금지
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
// 이동만 허용
NonCopyable(NonCopyable&&) noexcept = default;
NonCopyable& operator=(NonCopyable&&) noexcept = default;
};
// std::unique_ptr이 이 패턴을 사용함

5. 보편 참조 (Universal Reference / Forwarding Reference)

Section titled “5. 보편 참조 (Universal Reference / Forwarding Reference)”

T&&가 항상 rvalue 참조인 것은 아닙니다. 타입 추론이 일어나는 맥락에서 T&&는 lvalue도 rvalue도 받을 수 있는 보편 참조가 됩니다.

// (1) rvalue 참조 — T가 이미 알려진 경우
void Process(std::string&& s); // rvalue 참조만 받음
// (2) 보편 참조 — 타입 추론이 일어나는 경우
template<typename T>
void Forward(T&& arg) // lvalue도 rvalue도 받음 (보편 참조)
{
// arg의 타입:
// - lvalue std::string 전달 → T = std::string&, arg = std::string&
// - rvalue std::string 전달 → T = std::string, arg = std::string&&
}
// auto&&도 보편 참조
auto&& x = SomeExpression(); // lvalue면 lvalue 참조, rvalue면 rvalue 참조

참조 축약 규칙 (Reference Collapsing)

Section titled “참조 축약 규칙 (Reference Collapsing)”
TT&& 결과
intint&&
int&int& &&int&
int&&int&& &&int&&

std::forward는 보편 참조로 받은 인수의 값 카테고리(lvalue/rvalue)를 유지하면서 다음 함수로 전달합니다.

// 완벽 전달 없이 — 항상 lvalue로 전달
template<typename T>
void WrapperBad(T&& Arg)
{
Process(Arg); // Arg는 이미 이름이 있으므로 lvalue — 이동 생성자 선택 불가
}
// 완벽 전달 — 원래 값 카테고리 유지
template<typename T>
void WrapperGood(T&& Arg)
{
Process(std::forward<T>(Arg));
// lvalue로 넘어왔으면 lvalue로, rvalue로 넘어왔으면 rvalue로 전달
}
// 완벽 전달로 인수를 그대로 생성자에 전달
template<typename T, typename... Args>
std::unique_ptr<T> MakeObject(Args&&... args)
{
return std::make_unique<T>(std::forward<Args>(args)...);
}
// 사용
auto Obj1 = MakeObject<Buffer>(1000); // Buffer(1000) 호출
std::string Name = "Player";
auto Obj2 = MakeObject<Entity>(Name); // 복사 버전 생성자 호출
auto Obj3 = MakeObject<Entity>(std::move(Name)); // 이동 버전 생성자 호출

template<typename T>
void Demonstrate(T&& Arg)
{
// std::move — 항상 rvalue로 캐스팅 (이동 강제)
auto A = std::move(Arg); // 항상 이동
// std::forward<T> — 원래 카테고리 유지
auto B = std::forward<T>(Arg); // lvalue → 복사, rvalue → 이동
}
std::movestd::forward<T>
역할무조건 rvalue 캐스팅원래 값 카테고리 유지
사용 위치소유권을 포기할 때보편 참조를 다음 함수로 전달할 때
이동 발생항상T가 rvalue 참조일 때만

Return Value Optimization(RVO) / **Named RVO(NRVO)**는 컴파일러가 반환값의 복사·이동 자체를 생략하는 최적화입니다. C++17에서는 특정 경우 의무화됩니다.

Buffer CreateBuffer(size_t Size)
{
Buffer Result(Size); // NRVO: Result를 호출부의 변수에 직접 생성
return Result; // 이동도 복사도 발생 안 함 (컴파일러가 생략)
}
Buffer B = CreateBuffer(1000); // 생성자 1회만 호출
// RVO를 방해하는 패턴 — 피해야 함
Buffer CreateBufferBad(size_t Size)
{
Buffer Result(Size);
return std::move(Result); // std::move 사용 시 NRVO 불가 → 이동 발생
}

UE5는 표준 std::move / std::forward 대신 자체 래퍼를 제공합니다.

// UE5 대응 함수
MoveTemp(Obj) // std::move(Obj)와 동일
Forward<T>(Arg) // std::forward<T>(Arg)와 동일 (UE namespace)
MoveTempIfPossible(Obj) // 조건부 이동 (const lvalue는 복사)
// 실전 사용
TArray<FString> Names = { TEXT("Alice"), TEXT("Bob") };
TArray<FString> Moved = MoveTemp(Names); // Names는 비워짐
// 함수 인수 전달
template<typename T>
void AddToContainer(T&& Item)
{
Container.Add(Forward<T>(Item)); // 완벽 전달
}

#include <chrono>
#include <vector>
#include <string>
void BenchmarkCopyVsMove()
{
constexpr size_t DataSize = 10'000'000;
std::vector<int> Source(DataSize, 42);
// 복사 측정
auto Start = std::chrono::high_resolution_clock::now();
std::vector<int> Copied = Source; // O(n) 복사
auto CopyTime = std::chrono::high_resolution_clock::now() - Start;
// 이동 측정
Start = std::chrono::high_resolution_clock::now();
std::vector<int> Moved = std::move(Source); // O(1) 이동
auto MoveTime = std::chrono::high_resolution_clock::now() - Start;
// 결과: 이동이 복사보다 수십~수백 배 빠름
std::cout << "복사: " << std::chrono::duration_cast<std::chrono::microseconds>(CopyTime).count() << "μs\n";
std::cout << "이동: " << std::chrono::duration_cast<std::chrono::microseconds>(MoveTime).count() << "μs\n";
}

개념핵심
rvalue 참조 (T&&)임시 객체(rvalue) 바인딩 — 이동 가능 신호
이동 생성자/대입포인터 소유권 이전으로 O(1) 복사 대체
std::movelvalue → rvalue 캐스팅 (실제 이동은 생성자/대입이 수행)
보편 참조타입 추론 맥락의 T&& — lvalue·rvalue 모두 수용
std::forward<T>보편 참조의 값 카테고리를 다음 함수로 유지 전달
noexcept이동 함수에 필수 — vector 재할당 시 이동 선택 보장
RVO/NRVO반환값 복사·이동 자체를 컴파일러가 생략

황금 규칙:

  • 소유권을 포기할 때 → std::move (UE5: MoveTemp)
  • 보편 참조를 전달할 때 → std::forward<T> (UE5: Forward<T>)
  • 반환 지역변수에 std::move 금지 → RVO를 방해
  • 이동 후 원본은 재초기화 전 사용 금지