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) |
| 사용 시점 | 원본이 계속 필요할 때 | 원본이 더 필요 없을 때 |
1. lvalue와 rvalue — 기초 개념
Section titled “1. lvalue와 rvalue — 기초 개념”이동 의미론을 이해하려면 먼저 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 참조에 바인딩 불가lvalue / rvalue / xvalue 분류
Section titled “lvalue / rvalue / xvalue 분류”표현식├─ glvalue (generalized lvalue) — 주소를 취할 수 있음│ ├─ lvalue — 이름 있는 변수, 참조│ └─ xvalue (eXpiring) — std::move로 변환된 lvalue└─ rvalue — 주소를 취할 수 없는 임시값 ├─ prvalue — 리터럴, 임시 객체 └─ xvalue2. 이동 생성자와 이동 대입 연산자
Section titled “2. 이동 생성자와 이동 대입 연산자”2.1 복사 vs 이동 비교
Section titled “2.1 복사 vs 이동 비교”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;};2.2 noexcept가 중요한 이유
Section titled “2.2 noexcept가 중요한 이유”이동 생성자/대입 연산자에 noexcept를 붙이지 않으면 std::vector가 내부 재할당 시 이동 대신 복사를 사용합니다.
// noexcept 없으면 vector가 복사를 선택Buffer(Buffer&& Other) // vector는 복사 사용 (느림)Buffer(Buffer&& Other) noexcept // vector는 이동 사용 (빠름) ← 올바른 선언3. std::move — 이동 강제
Section titled “3. std::move — 이동 강제”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 — 재초기화 후 안전하게 사용 가능std::move 구현
Section titled “std::move 구현”// <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::move 잘못된 사용 예시
Section titled “std::move 잘못된 사용 예시”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 (비어있음) — 사용 시 버그 발생 가능4. 5의 법칙 (Rule of Five)
Section titled “4. 5의 법칙 (Rule of Five)”소멸자, 복사 생성자, 복사 대입 중 하나라도 정의하면 나머지 다섯 개를 모두 명시적으로 정의해야 합니다.
class ManagedResource{public: ManagedResource(); ~ManagedResource(); // 1. 소멸자 ManagedResource(const ManagedResource&); // 2. 복사 생성자 ManagedResource& operator=(const ManagedResource&); // 3. 복사 대입 ManagedResource(ManagedResource&&) noexcept; // 4. 이동 생성자 ManagedResource& operator=(ManagedResource&&) noexcept; // 5. 이동 대입};= default / = delete 활용
Section titled “= default / = delete 활용”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)”| T | T&& 결과 |
|---|---|
int | int&& |
int& | int& && → int& |
int&& | int&& && → int&& |
6. std::forward — 완벽 전달
Section titled “6. std::forward — 완벽 전달”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로 전달}실전 예시 — 팩토리 함수
Section titled “실전 예시 — 팩토리 함수”// 완벽 전달로 인수를 그대로 생성자에 전달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)); // 이동 버전 생성자 호출7. std::move vs std::forward 비교
Section titled “7. std::move vs std::forward 비교”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::move | std::forward<T> | |
|---|---|---|
| 역할 | 무조건 rvalue 캐스팅 | 원래 값 카테고리 유지 |
| 사용 위치 | 소유권을 포기할 때 | 보편 참조를 다음 함수로 전달할 때 |
| 이동 발생 | 항상 | T가 rvalue 참조일 때만 |
8. RVO / NRVO — 컴파일러 최적화
Section titled “8. RVO / NRVO — 컴파일러 최적화”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 불가 → 이동 발생}9. 언리얼 엔진과의 연결
Section titled “9. 언리얼 엔진과의 연결”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)); // 완벽 전달}10. 성능 측정 예시
Section titled “10. 성능 측정 예시”#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";}11. 정리
Section titled “11. 정리”| 개념 | 핵심 |
|---|---|
rvalue 참조 (T&&) | 임시 객체(rvalue) 바인딩 — 이동 가능 신호 |
이동 생성자/대입 | 포인터 소유권 이전으로 O(1) 복사 대체 |
std::move | lvalue → rvalue 캐스팅 (실제 이동은 생성자/대입이 수행) |
보편 참조 | 타입 추론 맥락의 T&& — lvalue·rvalue 모두 수용 |
std::forward<T> | 보편 참조의 값 카테고리를 다음 함수로 유지 전달 |
noexcept | 이동 함수에 필수 — vector 재할당 시 이동 선택 보장 |
RVO/NRVO | 반환값 복사·이동 자체를 컴파일러가 생략 |
황금 규칙:
- 소유권을 포기할 때 →
std::move(UE5:MoveTemp) - 보편 참조를 전달할 때 →
std::forward<T>(UE5:Forward<T>) - 반환 지역변수에
std::move금지 → RVO를 방해 - 이동 후 원본은 재초기화 전 사용 금지