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

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

이 글의 핵심

C++ Move 시맨틱스에 대한 실전 가이드입니다.

들어가며

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

왜 필요한가?:

  • 성능: 복사 대신 이동으로 성능 향상
  • 소유권 이전: 리소스 소유권을 명시적으로 이전
  • 복사 불가 타입: unique_ptr 같은 복사 불가 타입 전달
  • 임시 객체 최적화: 임시 객체의 리소스 재사용
// ❌ 복사: 느림 (깊은 복사)
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

기본 개념

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 참조

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
소멸자
소멸자

이동 (효율적)

#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);
}

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

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;
}

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&&로 임시 객체 바인딩
  3. std::move: rvalue로 캐스팅 (실제 이동 아님)
  4. noexcept: 이동 생성자/대입에 필수
  5. Rule of Five: 소멸자, 복사, 이동 모두 구현

복사 vs 이동

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

실전 팁

사용 원칙:

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

성능:

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

주의사항:

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

다음 단계

  • C++ Rvalue vs Lvalue
  • C++ Perfect Forwarding
  • C++ Copy Elision

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

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

  • C++ Rvalue vs Lvalue | “값 범주” 가이드
  • C++ 완벽 전달 | “Perfect Forwarding” 가이드
  • C++ Init Capture | “초기화 캡처” 가이드

관련 글

  • C++ Rvalue vs Lvalue |
  • C++ Algorithm Copy |
  • C++ std::function vs 함수 포인터 |
  • C++ move 에러 |
  • C++ RVO·NRVO |