본문으로 건너뛰기 C++ Move 시맨틱스 | '복사 vs 이동' 완벽 이해

C++ Move 시맨틱스 | '복사 vs 이동' 완벽 이해

C++ Move 시맨틱스 | '복사 vs 이동' 완벽 이해

이 글의 핵심

C++ Move 시맨틱스: "복사 vs 이동" 완벽 이해. Lvalue vs Rvalue·복사 vs 이동.

들어가며

Move 시맨틱스는 C++11에서 도입된 기능으로, 객체의 리소스를 복사하지 않고 이동할 수 있게 합니다. 복사 비용이 큰 객체(벡터, 문자열 등)를 효율적으로 전달할 수 있습니다.

왜 필요한가?:

  • 성능: 복사 대신 이동으로 성능 향상
  • 소유권 이전: 리소스 소유권을 명시적으로 이전
  • 복사 불가 타입: unique_ptr 같은 복사 불가 타입 전달
  • 임시 객체 최적화: 임시 객체의 리소스 재사용

C/C++ 예제 코드입니다.

// ❌ 복사: 느림 (깊은 복사)
std::vector<int> v1(1000000);  // 100만 개 요소 할당
std::vector<int> v2 = v1;      // 100만 개 요소 전부 복사
// 동작:
// 1. v2용 새 메모리 할당 (100만 * sizeof(int))
// 2. v1의 모든 요소를 v2로 복사
// 3. v1, v2 모두 독립적인 메모리 소유
// 비용: O(n) 시간, O(n) 메모리

// ✅ 이동: 빠름 (얕은 복사)
std::vector<int> v1(1000000);       // 100만 개 요소 할당
std::vector<int> v2 = std::move(v1); // 포인터만 복사 (내부 버퍼 이동)
// 동작:
// 1. v1의 내부 포인터를 v2로 복사 (포인터 3개: data, size, capacity)
// 2. v1의 포인터를 nullptr로 설정 (무효화)
// 3. v2가 v1의 메모리를 소유
// 비용: O(1) 시간, 추가 메모리 없음

1. Lvalue vs Rvalue

기본 개념

C/C++ 예제 코드입니다.

int x = 10;      // x는 lvalue (이름이 있으며, 주소를 가짐)
                 // 여러 번 참조 가능, 메모리 위치 확정
int y = x + 5;   // x+5는 rvalue (임시값, 주소 없음)
                 // 표현식 평가 후 바로 사라짐, 한 번만 사용

int* ptr = &x;   // ✅ OK: lvalue의 주소를 가져올 수 있음
                 // x는 메모리에 저장되어 있음
// int* ptr2 = &(x+5);  // ❌ 에러: rvalue의 주소를 가져올 수 없음
                        // x+5는 임시 레지스터 값, 메모리 주소 없음

핵심:

  • lvalue: 이름이 있으며, 여러 번 사용 가능, 주소를 가짐
  • rvalue: 임시값, 표현식 끝나면 사라짐, 주소를 가질 수 없음

lvalue와 rvalue 예시

#include <string>
#include <iostream>

std::string getName() { 
    return "Alice"; 
}

int main() {
    int x = 10;           // x: lvalue
    int y = x + 5;        // x+5: rvalue
    int z = std::move(x); // std::move(x): rvalue
    
    std::string s1 = "hello";
    std::string s2 = s1;           // s1: lvalue
    std::string s3 = s1 + " world"; // s1 + " world": rvalue
    
    std::string name = getName();  // getName(): rvalue
    
    return 0;
}

rvalue 참조

C/C++ 예제 코드입니다.

int x = 10;

// lvalue 참조 (T&): lvalue만 바인딩 가능
int& ref1 = x;        // ✅ OK: x는 lvalue
// int& ref2 = 10;    // ❌ 에러: 10은 rvalue (임시값)
                      // lvalue 참조는 메모리 주소가 있는 값만 가능

// rvalue 참조 (T&&): rvalue만 바인딩 가능
// int&& ref3 = x;    // ❌ 에러: x는 lvalue
                      // rvalue 참조는 임시값만 가능
int&& ref4 = 10;      // ✅ OK: 10은 rvalue (임시값)
                      // rvalue 참조는 임시값의 수명을 연장

// const lvalue 참조 (const T&): 모두 가능 (특별 규칙)
const int& ref5 = x;  // ✅ OK: lvalue 바인딩
const int& ref6 = 10; // ✅ OK: rvalue 바인딩 (임시값 수명 연장)
                      // const 참조는 임시값을 받을 수 있는 유일한 lvalue 참조

2. 복사 vs 이동

복사 (비효율적)

#include <iostream>
#include <cstring>

class String {
private:
    char* data;
    size_t size;
    
public:
    String(const char* str) {
        size = strlen(str);
        data = new char[size + 1];
        strcpy(data, str);
        std::cout << "생성자" << std::endl;
    }
    
    ~String() {
        delete[] data;
        std::cout << "소멸자" << std::endl;
    }
    
    // 복사 생성자
    String(const String& other) {
        size = other.size;
        data = new char[size + 1];
        strcpy(data, other.data);  // 깊은 복사
        std::cout << "복사 생성자" << std::endl;
    }
    
    void print() const {
        std::cout << data << std::endl;
    }
};

int main() {
    String s1("Hello");
    String s2 = s1;  // 복사 발생 (메모리 할당 + 복사)
    s2.print();
    
    return 0;
}

출력:

터미널에서 다음 명령어를 실행합니다.

생성자
복사 생성자
Hello
소멸자
소멸자

이동 (효율적)

print 함수의 구현 예제입니다.

#include <iostream>
#include <cstring>

class String {
private:
    char* data;
    size_t size;
    
public:
    String(const char* str) {
        size = strlen(str);
        data = new char[size + 1];
        strcpy(data, str);
        std::cout << "생성자" << std::endl;
    }
    
    ~String() {
        delete[] data;
        std::cout << "소멸자" << std::endl;
    }
    
    // 복사 생성자
    String(const String& other) {
        size = other.size;
        data = new char[size + 1];
        strcpy(data, other.data);
        std::cout << "복사 생성자" << std::endl;
    }
    
    // 이동 생성자: rvalue 참조(String&&)를 받음
    // noexcept: 예외를 던지지 않음을 보장 (성능 최적화)
    String(String&& other) noexcept {
        // 1. 포인터만 복사 (얕은 복사)
        data = other.data;      // other의 메모리를 가져옴
        size = other.size;
        
        // 2. 원본 무효화 (중요!)
        // other의 소멸자가 호출될 때 delete하지 않도록
        other.data = nullptr;   // 원본 포인터를 nullptr로
        other.size = 0;
        std::cout << "이동 생성자" << std::endl;
        
        // 결과: 메모리 할당 없음, 포인터만 이동 (매우 빠름)
    }
    
    void print() const {
        if (data) std::cout << data << std::endl;
        else std::cout << "(empty)" << std::endl;
    }
};

int main() {
    String s1("Hello");
    String s2 = std::move(s1);  // 이동 (포인터만 복사, 빠름!)
    
    s2.print();  // Hello
    s1.print();  // (empty) - s1은 이제 사용 불가
    
    return 0;
}

출력:

터미널에서 다음 명령어를 실행합니다.

생성자
이동 생성자
Hello
(empty)
소멸자
소멸자

3. std::move

기본 사용

#include <utility>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v1 = {1, 2, 3, 4, 5};
    
    // 복사: 모든 요소를 새 메모리에 복사
    std::vector<int> v2 = v1;  // v1의 모든 요소 복사
    // v1과 v2는 독립적인 메모리 소유
    std::cout << "v1 크기: " << v1.size() << std::endl;  // 5 (유지)
    std::cout << "v2 크기: " << v2.size() << std::endl;  // 5 (새로 할당)
    
    // 이동: 내부 버퍼 포인터만 이동
    // std::move(v1): v1을 rvalue로 캐스팅
    // 이동 생성자 호출 → v1의 내부 버퍼를 v3로 이전
    std::vector<int> v3 = std::move(v1);  // v1의 내부 버퍼를 v3로 이동
    // v1은 유효하지만 비어있는 상태 (moved-from state)
    std::cout << "v1 크기: " << v1.size() << std::endl;  // 0 (비어있음)
    std::cout << "v3 크기: " << v3.size() << std::endl;  // 5 (v1의 버퍼 소유)
    
    // 주의: v1은 더 이상 사용하면 안 됨 (소멸자 호출은 안전)
    
    return 0;
}

주의: std::move는 실제로 이동하지 않으며, rvalue로 캐스팅만 합니다!

std::move 구현

// std::move의 개념적 구현
// 실제로는 단순한 캐스팅만 수행 (이동하지 않음!)
template<typename T>
typename std::remove_reference<T>::type&& move(T&& arg) noexcept {
    // std::remove_reference<T>::type: T에서 참조 제거
    // T가 int&이면 → int
    // T가 int&&이면 → int
    // 
    // static_cast<...&&>: rvalue 참조로 캐스팅
    // 이 캐스팅이 이동 생성자를 호출하게 만듦
    // 
    // 핵심: std::move는 이동하지 않으며, "이동 가능"하다고 표시만 함
    // 실제 이동은 이동 생성자/대입 연산자가 수행
    return static_cast<typename std::remove_reference<T>::type&&>(arg);
}

심화: rvalue 참조 축소(Reference collapsing)

rvalue 참조 축소는 “참조가 두 겹 쌓였을 때 최종적으로 어떤 한 겹 참조가 되는가”를 정하는 언어 규칙입니다. C++11 이후 T&&가 템플릿·별칭·일부 문맥에서 어떻게 실제 매개변수의 값 범주(lvalue / rvalue)와 결합하는지 이해하려면 이 규칙이 필수입니다.

네 가지 축소 규칙

임의의 타입 T에 대해, 다음과 같이 정리됩니다(가장 오른이 “바깥” 참조에 해당한다고 읽어도 됩니다).

결합결과
T& &T&
T& &&T&
T&& &T&
T&& &&T&&

한 줄로 기억하면 “lvalue 참조(&)가 하나라도 끼면 최종은 lvalue 참조(T&), 둘 다 rvalue 참조(&&)일 때만 T&&입니다. 이 규칙은 typedef/using 별칭, decltype에 의해 만들어진 참조 타입, 템플릿 인자 치환 등에서 동일하게 적용됩니다.

std::move는 “항상 rvalue 참조”로 보이게 만드는가

앞 절의 std::moveremove_reference참조를 한 겹 벗긴 뒤 static_cast<U&&>만 합니다. 여기서 최종 타입이 U&&가 되는 것은 “이름 있는 객체를 rvalue로 대우하라”는 표식이며, 축소 규칙과 함께 벌어지는 일입니다. 예를 들어 템플릿 매개변수가 T&로 추론된 경우, T&&가 아니라 T&로 남는 것이 축소의 직접적인 결과입니다.

템플릿에서의 T&&는 “항상 rvalue 참조”가 아니다

아래처럼 타입이 템플릿 매개변수로 주어지고, 그 타입 추론이 일어나는 T&&전달 참조(forwarding reference)라고 부릅니다(구 표기: universal reference).

template <typename T>
void foo(T&& arg);  // T가 추론되면 전달 참조 — rvalue 참조만이 아님

template <typename T>
class Bar {
    void baz(T&& arg);  // T가 고정된 클래스 매개변수면 일반 rvalue 참조
};
  • foo(x)처럼 lvalue를 넘기면 Tint&처럼 참조로 추론되고, T&&int& &&int&로 축소되어 arglvalue 참조가 됩니다.
  • foo(42)처럼 rvalue를 넘기면 Tint, T&&int&&로 남습니다.

같은 T&& 문법이라도 “추론되는 T&&”인지 “고정된 TT&&”인지에 따라 의미가 갈립니다. 이 구분 없이 move 시맨틱스를 논하면 현장에서 설명이 자주 어긋납니다.


심화: 완벽 전달(Perfect forwarding) 메커니즘

완벽 전달은 호출자가 넘긴 인자의 값 범주(lvalue / rvalue)constness를 잃지 않으며, 내부에서 다른 함수(또는 생성자)로 그대로 넘기는 기법입니다. 이동 시맨틱스와 직결되는 이유는, “임시를 살려 이동 생성자까지 보낼 것인가, 이름 있는 객체를 복사 생성자로 보낼 것인가”를 한 번의 템플릿으로 표현할 수 있기 때문입니다.

std::forward의 역할: std::move와 대비

  • std::move(T&): 무조건 rvalue로 캐스팅 → 이동 후보로 만듦(소유권 이전 의도가 분명할 때).
  • std::forward<T>(arg): 전달 참조로 받은 arg가 원래 lvalue였으면 lvalue로, rvalue였으면 rvalue로 유지합니다. 구현은 대개 “조건부 static_cast”이며, 인자를 두 번 넘기는 중간 계층에서 값 범주를 보존하는 데 사용됩니다.
#include <utility>
#include <iostream>

void sink(int&) { std::cout << "lvalue\n"; }
void sink(int&&) { std::cout << "rvalue\n"; }

template <typename T>
void wrap(T&& arg) {
    sink(std::forward<T>(arg));  // 호출자가 넘긴 범주 유지
}

int main() {
    int x = 1;
    wrap(x);   // T: int&  → forward 후 lvalue
    wrap(2);   // T: int   → forward 후 rvalue
}

팩토리에서의 전형적 패턴

template <typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

여기서 Args&&...는 각 인자에 대해 전달 참조이으며, forward해당 인자가 원래 임시였는지T의 이동/복사 생성자 선택에 반영됩니다. std::move(args)...만 쓰면 lvalue 인자까지 전부 rvalue로 바꿔버려 의도와 다른 오버로드가 선택될 수 있습니다.

컴파일러 관점에서 한 줄

전달 참조 + 참조 축소로 T가 추론되고, std::forward<T>가 그 추론 결과에 맞춰 참조 타입 한 겹을 복원해 줍니다. 완벽 전달은 “문법 설탕”이 아니라 오버로드 결정과 이동/복사 분기를 템플릿 한 줄로 합성하는 메커니즘입니다.


4. 5가지 규칙 (Rule of Five)

#include <iostream>

class Resource {
private:
    int* data;
    
public:
    // 1. 생성자
    Resource(int value) : data(new int(value)) {
        std::cout << "생성자: " << *data << std::endl;
    }
    
    // 2. 소멸자
    ~Resource() {
        std::cout << "소멸자: " << (data ? std::to_string(*data) : "null") << std::endl;
        delete data;
    }
    
    // 3. 복사 생성자
    Resource(const Resource& other) {
        data = new int(*other.data);
        std::cout << "복사 생성자: " << *data << std::endl;
    }
    
    // 4. 복사 대입 연산자
    Resource& operator=(const Resource& other) {
        if (this != &other) {
            delete data;
            data = new int(*other.data);
            std::cout << "복사 대입: " << *data << std::endl;
        }
        return *this;
    }
    
    // 5. 이동 생성자
    Resource(Resource&& other) noexcept {
        data = other.data;
        other.data = nullptr;
        std::cout << "이동 생성자" << std::endl;
    }
    
    // 6. 이동 대입 연산자
    Resource& operator=(Resource&& other) noexcept {
        if (this != &other) {
            delete data;
            data = other.data;
            other.data = nullptr;
            std::cout << "이동 대입" << std::endl;
        }
        return *this;
    }
    
    int get() const { return data ? *data : 0; }
};

int main() {
    Resource r1(10);
    Resource r2 = r1;              // 복사 생성자
    Resource r3 = std::move(r1);   // 이동 생성자
    
    Resource r4(20);
    r4 = r2;                       // 복사 대입
    
    Resource r5(30);
    r5 = std::move(r4);            // 이동 대입
    
    return 0;
}

출력:

터미널에서 다음 명령어를 실행합니다.

생성자: 10
복사 생성자: 10
이동 생성자
생성자: 20
복사 대입: 10
생성자: 30
이동 대입
소멸자: 10
소멸자: 10
소멸자: null
소멸자: 10
소멸자: null

심화: 이동 전용 타입(Move-only type) 설계

이동 전용 타입은 복사 생성자와 복사 대입 연산자를 삭제하거나 비가시적으로 막아 두며, 이동만 허용하는 타입입니다. std::unique_ptr, std::thread, std::ofstream 등이 대표적입니다. “소유권이 하나뿐인 리소스”를 표현할 때 복사 대신 이동만으로 의미를 유지하려는 설계입니다.

선언 패턴

struct Handle {
    Handle() = default;
    ~Handle() = default;

    Handle(const Handle&) = delete;
    Handle& operator=(const Handle&) = delete;

    Handle(Handle&&) noexcept = default;
    Handle& operator=(Handle&&) noexcept = default;
};
  • 복사 삭제: 동일 리소스를 둘이 나누어 갖는 모델을 컴파일 타임에 차단합니다.
  • 이동 기본: 멤버가 이동 가능하면 = default로 충분한 경우가 많습니다. 수동으로 소유 포인터를 옮길 때는 Rule of Five를 이동 쪽에만 맞춥니다.

API 설계에서의 이점

  • 함수가 소유권 이전만 표현하면 됨: void consume(std::unique_ptr<Foo> p) — 호출자는 std::move로 의도를 드러냄.
  • 컨테이너에 넣을 때 복사 비용 없이 이동으로만 처리(복사 불가 타입은 이동 또는 emplace).
  • 다만 복사가 필요한 값 의미 모델과는 맞지 않으므로, “공유”가 필요하면 shared_ptr·내부 공유 버퍼 등 별도 모델을 택해야 합니다.

이동만으로 충분한지 검토할 질문

  1. 두 인스턴스가 동시에 같은 외부 리소스를 가리키면 안 되는가? → 이동 전용 후보.
  2. 값 복제가 도메인 의미인가? → 복사 허용 필요.
  3. 예외 안전noexcept 이동 요구가 있는가? → vector 재할당 등과 연계해 검토.

심화: 이동 대입과 자기 대입(Self-assignment)

복사 대입에서는 if (this == &other) 검사가 상식이지만, 이동 대입에서도 self-move는 현실적으로 발생합니다. 예: v = std::move(v), 또는 같은 객체를 참조하는 참조가 꼬인 경우.

왜 위험한가

이동 대입을 다음처럼 쓴다고 합니다(개념 코드).

Resource& operator=(Resource&& other) noexcept {
    delete data;           // 1. 기존 리소스 해제
    data = other.data;     // 2. 상대방 포인터를 가져옴
    other.data = nullptr;  // 3. 상대를 비움
    return *this;
}

other가 곧 *this이면, 1번에서 자기 메모리를 먼저 해제해 버리고 2번에서 이미 해제된 포인터를 읽는 형태가 될 수 있어 정의되지 않은 동작입니다. 따라서 이동 대입에도 자기 대입 가드가 필요합니다.

표준 라이브러리의 메시지

std::vector 등 많은 표준 타입은 a = std::move(a) 이후 유효하지만 값이 미지정일 수 있다고 문서화합니다. 즉 “반드시 비어 있다”는 보장은 없으며, 소멸·재대입은 안전하다는 수준입니다. 사용자 정의 타입은 이보다 강한 불변식을 약속하려면, 자기 이동 대입을 명시적으로 설계해야 합니다.

권장 패턴: 동일 객체면 no-op, 또는 이동 후 std::swap

포인터만 갖는 단순 타입은 if (this != &other) 가드로 같은 객체면 대입 본문을 건너뛰기만 해도 됩니다.

copy-and-swap 스타일로 자기 대입까지 포함해 안전하게 쓰려면 다음처럼 임시로 이동한 뒤 포인터를 맞바꿀 수 있습니다(tmp가 기존 data를 소멸 시 정리).

#include <utility>

// Resource는 int* data; 를 갖는다고 가정
Resource& operator=(Resource&& other) noexcept {
    if (this == &other) {
        return *this;
    }
    Resource tmp(std::move(other));
    std::swap(data, tmp.data);
    return *this;
}

중요한 점은 “이동 대입의 상대가 항상 다른 객체는 아니다”를 전제에 넣는 것입니다.


5. 실전 예제

예제 1: 벡터 최적화

#include <vector>
#include <iostream>

class BigObject {
private:
    std::vector<int> data;
    
public:
    BigObject(int size) : data(size, 0) {
        std::cout << "생성자: " << size << "개 요소" << std::endl;
    }
    
    BigObject(const BigObject& other) : data(other.data) {
        std::cout << "복사 생성자: " << data.size() << "개 요소" << std::endl;
    }
    
    BigObject(BigObject&& other) noexcept : data(std::move(other.data)) {
        std::cout << "이동 생성자: " << data.size() << "개 요소" << std::endl;
    }
};

std::vector<BigObject> createObjects() {
    std::vector<BigObject> result;
    result.push_back(BigObject(1000));  // 이동 생성자 호출
    result.push_back(BigObject(2000));
    return result;  // RVO 또는 이동
}

int main() {
    auto objects = createObjects();
    std::cout << "완료" << std::endl;
    
    return 0;
}

예제 2: 스마트 포인터 이동

#include <memory>
#include <iostream>

void process(std::unique_ptr<int> ptr) {
    std::cout << "값: " << *ptr << std::endl;
}

int main() {
    auto ptr1 = std::make_unique<int>(42);
    
    // process(ptr1);  // 에러: unique_ptr은 복사 불가
    process(std::move(ptr1));  // OK: 이동
    
    // ptr1은 이제 nullptr
    if (!ptr1) {
        std::cout << "ptr1은 비어있음" << std::endl;
    }
    
    return 0;
}

출력:

값: 42
ptr1은 비어있음

예제 3: 완벽 전달 (Perfect Forwarding)

#include <iostream>
#include <utility>

void process(int& x) {
    std::cout << "lvalue 버전: " << x << std::endl;
}

void process(int&& x) {
    std::cout << "rvalue 버전: " << x << std::endl;
}

template<typename T>
void wrapper(T&& arg) {
    process(std::forward<T>(arg));  // 완벽 전달
}

int main() {
    int x = 10;
    wrapper(x);      // lvalue 버전 호출
    wrapper(20);     // rvalue 버전 호출
    
    return 0;
}

출력:

lvalue 버전: 10
rvalue 버전: 20

6. 자주 발생하는 문제

문제 1: move 후 사용

#include <vector>
#include <iostream>

int main() {
    // ❌ 위험한 코드
    std::vector<int> v1 = {1, 2, 3};
    std::vector<int> v2 = std::move(v1);
    std::cout << v1.size() << std::endl;  // 0 (또는 정의되지 않은 동작)
    // v1[0];  // 정의되지 않은 동작!
    
    // ✅ 올바른 코드
    std::vector<int> v3 = {4, 5, 6};
    std::vector<int> v4 = std::move(v3);
    // v3은 더 이상 사용하지 않음
    
    return 0;
}

문제 2: const 객체는 이동 불가

#include <vector>
#include <iostream>

int main() {
    // ❌ 이동 안 됨
    const std::vector<int> v1 = {1, 2, 3};
    std::vector<int> v2 = std::move(v1);  // 복사됨!
    std::cout << "v1 크기: " << v1.size() << std::endl;  // 3 (여전히 유효)
    
    // ✅ const 없이
    std::vector<int> v3 = {1, 2, 3};
    std::vector<int> v4 = std::move(v3);  // 이동됨
    std::cout << "v3 크기: " << v3.size() << std::endl;  // 0
    
    return 0;
}

문제 3: 이동 생성자에서 예외

#include <stdexcept>
#include <vector>

// ❌ 위험: noexcept 없음
class Bad {
public:
    Bad(Bad&& other) {  // noexcept 없음
        throw std::runtime_error("에러");
    }
};

// ✅ noexcept 추가
class Good {
    std::vector<int> data;
public:
    Good(Good&& other) noexcept : data(std::move(other.data)) {
        // 예외 안던짐
    }
};

int main() {
    std::vector<Good> v;
    v.reserve(10);  // noexcept 이동 생성자 사용
    
    return 0;
}

이유: std::vectorreserve() 시 noexcept 이동 생성자가 있으면 이동, 없으면 복사합니다.

문제 4: 반환 시 std::move 사용

#include <vector>

// ❌ move 사용: RVO 방해
std::vector<int> createVector1() {
    std::vector<int> v = {1, 2, 3};
    return std::move(v);  // RVO 방해
}

// ✅ 그냥 반환: RVO 적용
std::vector<int> createVector2() {
    std::vector<int> v = {1, 2, 3};
    return v;  // RVO
}

int main() {
    auto v1 = createVector1();
    auto v2 = createVector2();
    
    return 0;
}

7. 성능 비교

#include <chrono>
#include <vector>
#include <iostream>

void testCopy() {
    std::vector<int> v1(1000000, 42);
    auto start = std::chrono::high_resolution_clock::now();
    
    std::vector<int> v2 = v1;  // 복사
    
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    std::cout << "복사: " << duration.count() << "μs" << std::endl;
}

void testMove() {
    std::vector<int> v1(1000000, 42);
    auto start = std::chrono::high_resolution_clock::now();
    
    std::vector<int> v2 = std::move(v1);  // 이동
    
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    std::cout << "이동: " << duration.count() << "μs" << std::endl;
}

int main() {
    testCopy();  // ~1000μs
    testMove();  // ~1μs
    
    return 0;
}

결과:

  • 복사: ~1000μs (메모리 할당 + 복사)
  • 이동: ~1μs (포인터만 복사)

8. 실무 패턴

패턴 1: 팩토리 함수

#include <vector>
#include <string>

class Database {
    std::vector<std::string> data_;
    
public:
    Database(std::vector<std::string> data) 
        : data_(std::move(data)) {}  // 이동
    
    size_t size() const { return data_.size(); }
};

// 팩토리 함수
Database createDatabase() {
    std::vector<std::string> data;
    data.push_back("record1");
    data.push_back("record2");
    data.push_back("record3");
    
    return Database(std::move(data));  // 이동
}

int main() {
    Database db = createDatabase();  // RVO 또는 이동
    
    return 0;
}

패턴 2: 컨테이너 최적화

#include <vector>
#include <string>

int main() {
    std::vector<std::string> names;
    
    // ❌ 복사
    std::string name1 = "Alice";
    names.push_back(name1);  // 복사
    
    // ✅ 이동
    std::string name2 = "Bob";
    names.push_back(std::move(name2));  // 이동
    
    // ✅ emplace_back (더 좋음)
    names.emplace_back("Charlie");  // 직접 생성
    
    return 0;
}

패턴 3: 스왑 최적화

#include <vector>
#include <utility>

class Buffer {
    std::vector<char> data_;
    
public:
    Buffer(size_t size) : data_(size) {}
    
    // 이동 기반 스왑
    void swap(Buffer& other) noexcept {
        data_.swap(other.data_);  // O(1)
    }
    
    // 또는 std::swap 사용
    friend void swap(Buffer& a, Buffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
    }
};

int main() {
    Buffer b1(1000), b2(2000);
    swap(b1, b2);  // 빠른 이동
    
    return 0;
}

패턴 4: 프로덕션 — Sink 인자와 이동 생성자 연계

API에서 “소유권을 받는다”는 의도를 분명히 하려면 값으로 받고 멤버에 이동하는 패턴이 자주 사용됩니다. 호출자는 필요 시 std::move로 비용을 한 번만 지불하며, 구현부는 복사/이동 오버로드를 나누지 않아도 됩니다.

class Session {
    std::string label_;
public:
    explicit Session(std::string label) : label_(std::move(label)) {}
};

컨테이너에 넣을 때는 emplace로 임시 생성까지 합치면 추가 이동 한 번을 줄일 수 있습니다.

패턴 5: 프로덕션 — noexcept 이동과 vector 재할당

std::vector는 재할당 시 요소를 이동할 수 있으면 이동을 택합니다. 이때 이동 생성자/이동 대입이 noexcept가 아니면 강한 예외 안전을 위해 복사로 되돌아가는 구현이 흔합니다. 즉 프로덕션에서 이동이 “실제로 호출되지 않는” 이유가 여기에 있습니다. 이동 연산은 예외를 던지지 않는다는 계약을 가능하면 noexcept로 밝히는 것이 실무적으로 중요합니다.

패턴 6: 프로덕션 — 이동 후 상태와 std::exchange

이동 후 객체를 명시적으로 비우고 싶을 때 멤버 단위로 std::exchange를 쓰면, “옛 값 가져오기 + 상대를 안전한 값으로 설정”을 한 번에 쓸 수 있습니다.

#include <utility>

// 개념 예: 핸들 이동
struct Owned {
    int* p = nullptr;
    Owned(Owned&& o) noexcept : p(std::exchange(o.p, nullptr)) {}
};

문서화할 때는 “moved-from 객체에 어떤 연산을 허용하는가”(소멸만? 재대입 가능?)를 팀 규약으로 정하는 것이 좋습니다.

패턴 7: 프로덕션 — 예외 안전한 업데이트(copy-and-swap)

강한 예외 보장이 필요한 대입에서는 임시를 만들고 swap으로 커밋하는 방식이 여전히 유효합니다. 이동 시맨틱스 시대에는 이동 생성자로 임시를 만든 뒤 스왑하는 형태로, 대형 버퍼를 한 번의 이동으로 옮기고 기존 상태는 임시 소멸에 맡깁니다.

패턴 8: 프로덕션 — 로깅·디버그에서의 주의

릴리스 빌드가 아닐 때 이동 생성자에 로그를 넣으면 호출 빈도가 드러나 유용하지만, 이동이 예외를 던지는 것처럼 보이게 만드는 실수(숨은 할당 등)는 피해야 합니다. 또한 이동 후 객체를 읽는 어설션은 “미지정 상태” 전제를 깨지 않도록, 검사 대상을 불변식의 최소 집합으로 제한하는 편이 안전합니다.


9. 실전 예제: 리소스 관리자

#include <iostream>
#include <vector>
#include <memory>
#include <string>

class ResourceManager {
    std::vector<std::unique_ptr<std::string>> resources_;
    
public:
    // 리소스 추가
    void add(std::unique_ptr<std::string> resource) {
        resources_.push_back(std::move(resource));  // 이동
    }
    
    // 리소스 가져오기
    std::unique_ptr<std::string> take(size_t index) {
        if (index >= resources_.size()) return nullptr;
        
        auto resource = std::move(resources_[index]);  // 이동
        resources_.erase(resources_.begin() + index);
        return resource;
    }
    
    // 리소스 개수
    size_t count() const {
        return resources_.size();
    }
    
    // 리소스 출력
    void print() const {
        std::cout << "Resources (" << resources_.size() << "):" << std::endl;
        for (size_t i = 0; i < resources_.size(); ++i) {
            if (resources_[i]) {
                std::cout << "  [" << i << "]: " << *resources_[i] << std::endl;
            } else {
                std::cout << "  [" << i << "]: (moved)" << std::endl;
            }
        }
    }
};

int main() {
    ResourceManager mgr;
    
    // 리소스 추가
    mgr.add(std::make_unique<std::string>("Resource 1"));
    mgr.add(std::make_unique<std::string>("Resource 2"));
    mgr.add(std::make_unique<std::string>("Resource 3"));
    
    mgr.print();
    
    // 리소스 가져오기
    auto r = mgr.take(1);
    std::cout << "\n가져온 리소스: " << *r << std::endl;
    
    std::cout << "\n남은 리소스:" << std::endl;
    mgr.print();
    
    return 0;
}

출력:

터미널에서 다음 명령어를 실행합니다.

Resources (3):
  [0]: Resource 1
  [1]: Resource 2
  [2]: Resource 3

가져온 리소스: Resource 2

남은 리소스:
Resources (2):
  [0]: Resource 1
  [1]: Resource 3

정리

핵심 요약

  1. Move 시맨틱스: 리소스를 복사 없이 이동
  2. rvalue 참조: T&&로 임시 객체 바인딩 — 단, 템플릿 추론 시 전달 참조참조 축소T&로 남을 수 있음
  3. std::move: rvalue로 캐스팅 (실제 이동 아님)
  4. 완벽 전달: std::forward로 인자의 값 범주를 보존해 이동/복사 오버로드를 올바르게 선택
  5. 이동 전용 타입: 복사 삭제 + 이동으로 소유권 단일성을 컴파일 타임에 강제
  6. 이동 대입: 자기 이동 대입(x = std::move(x))을 대비해 가드 또는 copy-and-swap
  7. noexcept: 이동 생성자/대입에 필수(컨테이너 재할당 등)
  8. Rule of Five: 소멸자, 복사, 이동 모두 구현

복사 vs 이동

특징복사이동
비용높음 (메모리 할당 + 복사)낮음 (포인터만)
원본유지무효화
문법T a = b;T a = std::move(b);
생성자복사 생성자이동 생성자
대입복사 대입이동 대입
용도원본 유지 필요원본 불필요

실전 팁

사용 원칙:

  • 객체를 더 이상 사용하지 않을 때
  • 함수 반환 시 (RVO 안될 때)
  • 컨테이너 삽입 시
  • 소유권 이전 시 (unique_ptr)

성능:

  • 동적 메모리 객체에서 효과적
  • 기본 타입은 효과 없음
  • RVO가 move보다 빠름
  • 벤치마크로 확인

주의사항:

  • move 후 객체 사용 금지
  • const 객체는 이동 불가
  • 이동 생성자에 noexcept 필수
  • 반환 시 std::move 사용 금지

다음 단계


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Everything about C++ Move 시맨틱스 : from basic concepts to practical applications.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, move, rvalue, 이동시맨틱스, 성능 등으로 검색하시면 이 글이 도움이 됩니다.