C++ Move Semantics | std::move로 불필요한 복사 제거하기

std::move는 이름과 달리 아무것도 옮기지 않습니다. “이 객체를 rvalue로 취급해도 된다”는 캐스트일 뿐이고, 실제로 자원을 넘기는 일은 그 rvalue를 받은 이동 생성자나 이동 대입 연산자가 합니다. 이 글은 이 구분에서 출발해 lvalue와 rvalue, rvalue 참조, 이동 생성자와 이동 대입, Rule of Five를 차례로 구현하고, return std::move(x)나 noexcept 누락처럼 이동을 쓰면서 자주 하는 실수를 정리합니다. 시리즈 흐름상 13-2 std::function 다음 글입니다.

복사와 이동은 무엇이 다른가

이동 의미론(move semantics)은 C++11에서 추가되었습니다. 그 전에는 객체를 넘기는 방법이 복사뿐이었기 때문에, 곧 버려질 객체라도 내용을 통째로 복제해야 했습니다.

flowchart LR
  subgraph copy[복사]
    C1[원본] --> C2[데이터 복제]
    C2 --> C3[대상]
    C1 -.->|유지| C1
  end
  subgraph move[이동]
    M1[원본] --> M2[포인터/핸들만 이전]
    M2 --> M3[대상]
    M1 -.->|빈 상태| M1
  end

std::vector나 std::string처럼 힙 버퍼를 가리키는 객체를 복사하면 새 버퍼를 할당하고 내용을 전부 복사해야 합니다. 원본을 다시 쓰지 않을 것이라면, 새 객체가 원본의 버퍼 포인터를 가져가고 원본은 빈 상태로 두면 충분합니다. 이것이 이동입니다. 스마트 포인터와 RAII가 “누가 자원을 소유하는가”를 객체로 표현한다면, 이동은 그 소유권을 상수 시간에 넘기는 수단입니다. Rust의 소유권 이동은 기본 동작이 이동이고 비싼 복사는 clone()으로 드러나게 한다는 점에서 C++과 방향이 반대입니다.

함수가 큰 벡터를 반환하는 코드를 예로 들어 보겠습니다.

#include <vector>
std::vector<int> createLargeVector() {
    std::vector<int> vec(1000000);
    // 데이터 채우기...
    return vec;
}
int main() {
    std::vector<int> data = createLargeVector();
}

C++03에서는 컴파일러가 복사 생략(NRVO)을 해 주지 않으면 return vec;에서 복사 생성자가 호출되어 100만 개 원소가 통째로 복사되었습니다. 복사 생략은 허용된 최적화였을 뿐 보장되지 않았으므로, 큰 컨테이너는 반환하지 않고 참조 인자로 채우는 관례가 많았습니다.

C++11부터는 같은 코드가 다르게 동작합니다. 표준이 “return 문에서 이름만 적힌 지역 객체는 먼저 rvalue로 취급해 오버로드를 고른다”고 정했기 때문에, 복사 생략이 적용되지 않더라도 이동 생성자가 선택됩니다. std::move를 붙이지 않아도 같은 소스를 C++11 이상으로 컴파일하기만 하면 복사가 이동으로 바뀝니다.

이동이 이득이 되는 곳은 반환값만이 아닙니다. 수신한 패킷 버퍼를 파싱 함수로 넘기는 경우를 보겠습니다.

void processBuffer(std::vector<uint8_t> buf);  // 값으로 받아 소유

std::vector<uint8_t> buffer = receivePacket();
processBuffer(buffer);             // 패킷 크기만큼 할당 + 복사
processBuffer(std::move(buffer));  // 내부 포인터만 이전

수신 루프에서 같은 buffer 변수를 계속 재사용하는 코드라면, 이동하면 버퍼의 용량까지 함께 넘어가 buffer가 빈 벡터가 된다는 점을 기억해야 합니다. 이동으로 할당 한 번을 아낀 대신 다음 수신에서 다시 할당이 일어나므로, “버퍼 재사용”과 “소유권 이전” 중 어느 쪽이 이득인지는 호출 구조를 보고 정해야 합니다. 같은 이유로 파싱한 nlohmann::json 문서처럼 큰 트리를 여러 단계가 읽기만 한다면 이동이 아니라 const&로 넘기는 것이 맞고, 단계마다 소유권이 넘어가는 파이프라인일 때만 이동이 의미가 있습니다.


lvalue와 rvalue를 구분하는 이유

기본 개념

#include <iostream>
int main() {
    int x = 10;     // x는 lvalue (이름 있음, 주소 있음)
    int y = 20;     // y는 lvalue
    int z = x + y;  // x + y는 rvalue (임시 값)
    int* p = &x;    // OK: lvalue의 주소
    std::cout << z << " " << *p << "\n";  // 30 10
    return 0;
}

여기에 int* q = &(x + y);를 넣으면 컴파일 오류가 납니다. x + y는 rvalue라 주소를 취할 수 없기 때문입니다.

구분lvaluervalue
예시변수, *ptr, arr[i]42, x + y, 값을 반환하는 func()
주소취할 수 있음취할 수 없음
대입왼쪽에 올 수 있음(const가 아니라면)내장 타입은 왼쪽에 올 수 없음

처음 배울 때는 “lvalue는 이름이 있고 자리가 정해진 값, rvalue는 곧 사라질 임시 값” 정도로 기억하면 충분합니다. 이 구분이 이동에서 중요한 이유는, 곧 사라질 값이라면 내용을 가져가도 아무도 알아차리지 못하기 때문입니다. C++은 rvalue 참조 T&&라는 타입을 만들어 “이 인자는 가져가도 되는 값”이라는 정보를 오버로드 해석에 실었고, 이동 생성자와 이동 대입은 그 타입으로 인자를 받습니다.

함수 반환값

값을 반환하는 함수 호출은 rvalue이고, 참조를 반환하는 함수 호출은 lvalue입니다.

int getValue() {
    return 42;
}
int& getRef() {
    static int x = 10;
    return x;
}
int main() {
    int a = getValue();  // getValue()는 rvalue
    int& b = getRef();   // getRef()는 lvalue

    // getValue() = 100;  // 에러: rvalue에 대입 불가
    getRef() = 100;       // OK: lvalue
}

rvalue 참조와 오버로딩

기본 문법

일반 참조(T&)는 lvalue에만 묶을 수 있고, rvalue 참조(T&&)는 rvalue에만 묶을 수 있습니다.

int x = 10;
int& lref = x;      // lvalue 참조
// int& lref2 = 42;    // 에러: rvalue를 non-const lvalue 참조에 묶을 수 없음
int&& rref = 42;    // rvalue 참조
// int&& rref2 = x;    // 에러: lvalue를 rvalue 참조에 묶을 수 없음

const lvalue 참조

const T&는 C++11 이전부터 “임시 값도 받을 수 있는 참조”로 쓰였습니다. lvalue와 rvalue를 모두 받지만, const이기 때문에 받은 객체를 수정할 수도, 내용을 가져갈 수도 없습니다. 값을 읽기만 할 때 쓰는 용도이며, 이동이 목적이라면 T&&로 받아야 합니다.

const int& ref1 = 10;  // OK: const lvalue 참조는 rvalue도 받음
const int& ref2 = x;   // OK: lvalue도 받음
// ref1 = 20;  // 에러: 수정 불가

오버로딩

같은 이름의 함수를 T&와 T&&로 오버로드하면, 호출할 때 넘긴 식이 lvalue인지 rvalue인지에 따라 다른 함수가 선택됩니다. 이동 생성자와 복사 생성자가 공존할 수 있는 것도 이 규칙 덕분입니다.

#include <iostream>
void process(int& x) {
    std::cout << "lvalue: " << x << "\n";
}
void process(int&& x) {
    std::cout << "rvalue: " << x << "\n";
}
int main() {
    int a = 10;
    process(a);    // lvalue: 10
    process(20);   // rvalue: 20
}

std::move는 캐스트일 뿐이다

std::move라는 이름과 달리 이 함수는 아무것도 옮기지 않습니다. 인자를 rvalue 참조 타입으로 캐스팅해 반환할 뿐입니다.

std::string의 복사, 이동, const 객체 이동 시도 결과

그림: 실제 실행 결과 — 이동된 a는 빈 상태가 되지만, const 객체에 std::move를 써도 이동 생성자를 못 골라 복사로 바뀐다. std::move는 캐스트일 뿐 이동을 보장하지 않는다. (환경: Ubuntu 24.04 · g++ 13.3)

// std::move의 개념적 구현 (C++14 표기)
template <typename T>
constexpr std::remove_reference_t<T>&& move(T&& arg) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(arg);
}

매개변수 T&&는 뒤에서 설명할 전달 참조라서 lvalue와 rvalue를 모두 받습니다. lvalue x(타입 int)를 넘기면 T는 int&로 추론되는데, 이대로 T&&를 반환 타입으로 쓰면 참조 축소 때문에 int&가 되어 버립니다. 그래서 remove_reference_t로 참조를 먼저 벗겨 int를 만든 뒤 int&&로 캐스팅합니다. 결과는 항상 xvalue이고, 실제 이동은 그 결과를 받은 이동 생성자나 이동 대입 연산자가 수행합니다.

std::vector<int> v1 = {1, 2, 3, 4, 5};
std::vector<int> v3 = std::move(v1);  // 이동 생성자가 v1의 버퍼를 가져감

std::cout << v3.size() << '\n';  // 5
std::cout << v1.size() << '\n';  // 주요 구현에서는 0이지만, 표준이 보장하는 것은 "유효하지만 지정되지 않은 상태"
v1.clear();                      // 사전 조건이 없는 연산이므로 안전
v1.push_back(42);                // 이후 정상적으로 재사용 가능

std::move를 호출만 하고 결과를 이동 생성자나 이동 대입으로 받지 않으면 아무 일도 일어나지 않습니다. std::move(v1);이라는 문장 하나만 있으면 v1은 그대로입니다.

이동 생성자와 이동 대입

복사 생성자와 이동 생성자

#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>

class Buffer {
    int* data;
    std::size_t size;

public:
    explicit Buffer(std::size_t s) : data(new int[s]), size(s) {
        std::cout << "Constructor\n";
    }

    // 복사 생성자: 새 버퍼를 할당하고 내용 복사
    Buffer(const Buffer& other) : data(new int[other.size]), size(other.size) {
        std::copy(other.data, other.data + size, data);
        std::cout << "Copy constructor\n";
    }

    // 이동 생성자: 포인터만 가져오고 원본을 비움
    Buffer(Buffer&& other) noexcept
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
        std::cout << "Move constructor\n";
    }

    ~Buffer() {
        delete[] data;
    }
};

int main() {
    Buffer b1(1000);
    Buffer b2 = b1;              // Copy constructor
    Buffer b3 = std::move(b1);   // Move constructor
}

복사 생성자는 new int[other.size]로 새 메모리를 할당하고 원소를 모두 복사하므로 크기에 비례하는 비용이 듭니다. 이동 생성자는 포인터와 크기만 복사하고 원본의 포인터를 nullptr로 바꾸므로 크기와 상관없이 상수 시간입니다.

여기서 other.data = nullptr;는 빠뜨리면 안 됩니다. 이동된 뒤에도 other는 살아 있는 객체이고, 수명이 끝나면 소멸자가 호출됩니다. 원본 포인터를 그대로 두면 두 객체의 소멸자가 같은 메모리를 delete[]하는 이중 해제가 일어납니다. delete[] nullptr는 아무 일도 하지 않으므로, 비워 두기만 하면 원본의 소멸자는 안전합니다.

이동 생성자에 붙은 noexcept도 중요합니다. 이유는 아래 noexcept 절에서 설명합니다.

이 Buffer는 설명을 위해 복사 대입과 이동 대입을 생략했습니다. 이 상태에서 b2 = b1;을 쓰면 컴파일러가 만든 복사 대입이 포인터만 복사해 이중 해제가 생기므로, 실제 클래스라면 아래의 Rule of Five를 따라야 합니다.

이동 대입 연산자

Buffer& operator=(Buffer&& other) noexcept {
    if (this != &other) {
        delete[] data;          // 기존 리소스 해제

        data = other.data;      // 리소스 가져오기
        size = other.size;

        other.data = nullptr;   // 원본 비우기
        other.size = 0;
    }
    return *this;
}

이동 생성자와의 차이는 대상 객체가 이미 존재한다는 점입니다. 생성자는 새 객체를 만들므로 정리할 것이 없지만, 대입은 대상이 원래 들고 있던 버퍼를 먼저 해제해야 누수가 없습니다.

if (this != &other)는 a = std::move(a); 같은 자기 이동 대입을 막습니다. 이 검사가 없으면 자신의 버퍼를 먼저 해제한 뒤 이미 해제된 포인터를 다시 가져오고, 마지막 줄에서 그것마저 nullptr로 바꿔 데이터를 잃어버립니다. 표준 라이브러리 타입도 자기 이동 대입 후에는 “유효하지만 지정되지 않은 상태”만 보장하므로, 일부러 쓸 일은 없지만 제네릭 코드(예: std::swap(a, a))에서 간접적으로 일어날 수 있습니다.

항목이동 생성자이동 대입 연산자
호출 시점새 객체 생성 시기존 객체에 대입 시
기존 리소스없음해제 필요
예시Buffer b = std::move(a);b = std::move(a);

Rule of Five

동적 메모리, 파일 핸들, 소켓처럼 스스로 관리하는 자원이 있는 클래스는 소멸자, 복사 생성자, 복사 대입, 이동 생성자, 이동 대입 다섯 가지를 함께 정의하거나 명시적으로 삭제해야 합니다. 이를 Rule of Five라고 부릅니다.

class Resource {
public:
    ~Resource();
    Resource(const Resource& other);
    Resource& operator=(const Resource& other);
    Resource(Resource&& other) noexcept;
    Resource& operator=(Resource&& other) noexcept;
};

다섯 개를 직접 써야 하는 이유는 컴파일러가 만들어 주는 기본 복사가 멤버별 얕은 복사이기 때문입니다. int* data를 소유하는 클래스를 기본 복사 생성자로 복사하면 포인터 값만 복사되어 두 객체가 같은 버퍼를 가리키고, 둘 다 소멸될 때 이중 해제가 일어납니다. 또 소멸자나 복사 연산을 사용자가 선언하면 컴파일러는 이동 연산을 암시적으로 만들지 않으므로, 이동을 원한다면 직접 선언해야 합니다. 이동 연산이 없으면 std::move를 붙여도 조용히 복사 생성자가 선택됩니다.

반대로 자원을 std::vector, std::string, std::unique_ptr 같은 표준 타입 멤버에 맡기면 다섯 개 모두 컴파일러가 올바르게 만들어 주므로 아무것도 선언하지 않아도 됩니다(Rule of Zero). 직접 자원을 다루는 클래스는 이렇게 작은 래퍼 하나로 제한하고, 나머지 클래스는 Rule of Zero를 따르는 것이 유지보수에 유리합니다.

복사 대입은 copy-and-swap 관용구로 쓰면 자기 대입과 예외 안전을 한 번에 해결할 수 있습니다.

class Buffer {
    int* data;
    std::size_t size;
public:
    // ... 생성자, 소멸자, 복사 생성자(깊은 복사), 이동 생성자 ...
    Buffer& operator=(Buffer other) noexcept {  // 값으로 받음: lvalue면 복사, rvalue면 이동 생성
        swap(*this, other);
        return *this;                           // other가 소멸되며 기존 자원 해제
    }
    friend void swap(Buffer& a, Buffer& b) noexcept {
        using std::swap;
        swap(a.data, b.data);
        swap(a.size, b.size);
    }
};

새 자원을 매개변수 other로 먼저 만든 뒤 교체하므로, 복사 중 new가 예외를 던져도 *this는 바뀌지 않습니다. a = a도 복사본과 교환하는 것이라 별도 검사 없이 안전합니다. 이 연산자 하나가 복사 대입과 이동 대입을 모두 처리하므로 대입 연산자를 두 벌 쓸 필요도 없습니다(이 경우 operator=(Buffer&&)를 따로 선언하면 호출이 모호해지므로 함께 두지 않습니다). 대신 같은 크기의 버퍼를 재사용할 수 있는 경우에도 항상 새로 할당한다는 비용이 있어, 성능이 중요한 컨테이너 구현에서는 대입 연산자를 따로 최적화하기도 합니다.

멤버 중에 std::mutex처럼 복사도 이동도 안 되는 타입이 있으면 이동 생성자를 = default로 둘 수 없습니다. 이때는 이동 가능한 멤버만 옮기고 뮤텍스는 새로 기본 생성합니다. 뮤텍스는 “이 객체를 보호하는 잠금”이라 원본의 잠금 상태를 넘겨받을 이유가 없기 때문입니다.

class Guarded {
    std::mutex mtx;
    std::vector<int> data;
public:
    Guarded(Guarded&& other) noexcept : mtx{}, data(std::move(other.data)) {}
};

이 코드는 other를 다른 스레드가 동시에 쓰지 않는다고 가정합니다. 그럴 가능성이 있다면 other.mtx를 잠근 상태에서 data를 옮겨야 합니다.


std::move로 컨테이너, 인자, unique_ptr 넘기기

기본 사용법

std::string str1 = "Hello";
std::string str2 = std::move(str1);  // str1의 내용을 str2로 이동
std::cout << "str1: " << str1 << "\n";  // 주요 구현에서는 빈 문자열 (표준은 보장하지 않음)
std::cout << "str2: " << str2 << "\n";  // "Hello"

앞서 말했듯 std::move(str1)은 static_cast<std::string&&>(str1)과 같은 캐스트일 뿐이고, 내부 버퍼를 가져가는 일은 str2의 이동 생성자가 합니다. 이동된 str1은 값이 지정되지 않은 상태이므로, 다시 쓰려면 새 값을 대입한 뒤에 쓰는 것이 안전합니다.

컨테이너, 함수 인자, unique_ptr

std::vector<std::string> vec1 = {"a", "b", "c"};
std::vector<std::string> vec2 = std::move(vec1);
std::cout << vec1.size() << "\n";  // 0 (vector의 이동 생성자는 원본을 비운다고 보장)
std::cout << vec2.size() << "\n";  // 3

void process(std::string str);
std::string text = "Hello";
process(text);              // 복사
process(std::move(text));   // 이동, 이후 text 값은 지정되지 않음

std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// std::unique_ptr<int> ptr2 = ptr1;  // 에러: 복사 불가
std::unique_ptr<int> ptr2 = std::move(ptr1);  // 이동, ptr1은 nullptr이 됨(보장)

unique_ptr은 복사가 삭제된 이동 전용 타입이라, 소유권을 넘기려면 반드시 std::move를 써야 합니다. 이동된 unique_ptr이 nullptr이 되는 것은 표준이 보장하는 동작입니다.


push_back, swap, 반환값, 멤버 초기화에서 이동 활용

컨테이너에 추가

std::vector<std::string> names;
std::string name = "Alice";
names.push_back(name);             // 복사
names.push_back(std::move(name));  // 이동
names.emplace_back("Bob");         // 컨테이너 안에서 직접 생성

vec.push_back(Widget(1, "a"))는 임시 Widget을 만든 뒤 컨테이너 안으로 이동하고, vec.emplace_back(1, "a")는 인자만 넘겨 컨테이너 안에서 바로 생성합니다. 이동이 싸다면 차이는 이동 한 번뿐이지만, 이동 생성자가 없는 타입이라면 push_back 쪽은 복사가 됩니다. emplace가 인자를 어떻게 넘기는지는 14-2 Perfect Forwarding에서 다룹니다.

swap 구현

template <typename T>
void mySwap(T& a, T& b) {
    T temp = std::move(a);  // 이동 생성
    a = std::move(b);       // 이동 대입
    b = std::move(temp);    // 이동 대입
}

C++03의 std::swap은 T temp = a; a = b; b = temp;로 복사 세 번이었습니다. 1MB짜리 벡터 두 개를 바꾸면 임시까지 포함해 약 3MB를 복사하는 셈입니다. 이동 버전은 이동 생성 한 번과 이동 대입 두 번으로 포인터 몇 개만 바꿉니다. C++11의 std::swap 기본 구현이 바로 이 형태입니다(표준 컨테이너는 이보다 더 단순한 멤버 swap을 따로 제공합니다).

반환값: return std::move를 쓰지 않는 이유

std::vector<int> createVector() {
    std::vector<int> vec(1000);
    return vec;             // NRVO 또는 암시적 이동
}

std::vector<int> createVector2() {
    std::vector<int> vec(1000);
    return std::move(vec);  // NRVO가 막히고 이동 1회가 강제됨
}

RVO(Return Value Optimization)는 반환할 객체를 호출자가 받을 자리에 바로 만들어 복사와 이동을 모두 없애는 최적화입니다. return T(...)처럼 prvalue를 반환하는 경우는 C++17부터 생략이 보장되고, return vec;처럼 이름 있는 지역 객체를 반환하는 경우(NRVO)는 허용되지만 보장되지는 않습니다. NRVO가 가능하려면 반환식이 함수의 지역 객체 이름 그 자체이고, 그 타입이 반환 타입과 같아야 합니다.

return vec;는 컴파일러가 NRVO를 적용하면 비용이 0이고, 적용하지 못해도 암시적 이동이 일어나므로 최악이 이동 한 번입니다. return std::move(vec);는 반환식이 더 이상 변수 이름이 아니라 함수 호출식이므로 NRVO 조건을 깨고, 항상 이동 한 번이 일어납니다. 얻는 것은 없고 잃는 것만 있으므로, GCC와 Clang은 -Wpessimizing-move 경고로 이를 알려 줍니다.

몇 가지 경계 사례도 있습니다. 함수 매개변수를 반환하는 경우 NRVO는 적용되지 않지만 암시적 이동은 적용되므로 여전히 std::move가 필요 없습니다. 반면 return cond ? a : b;는 반환식이 지역 변수 이름이 아니라 조건 연산자이고, 그 결과가 lvalue이므로 NRVO도 암시적 이동도 없이 복사가 일어납니다. 이때는 if (cond) return a; return b;처럼 나눠 쓰면 각 return이 암시적 이동 대상이 됩니다. 멤버나 구조체의 일부를 반환할 때(return std::move(this->data_);, return {a, std::move(b)};)는 암시적 이동 대상이 아니므로 std::move가 필요합니다.

멤버 초기화: rvalue 참조 매개변수도 lvalue다

#include <vector>
#include <utility>
class Container {
    std::vector<int> data;
public:
    Container(std::vector<int>&& vec) : data(std::move(vec)) {}
    void setData(std::vector<int>&& vec) { data = std::move(vec); }
};

int main() {
    std::vector<int> temp = {1, 2, 3};
    Container c(std::move(temp));
}

많이 헷갈리는 부분은 “vec는 이미 rvalue 참조인데 왜 또 std::move를 쓰는가”입니다. vec의 타입은 std::vector<int>&&이지만, vec라는 식 자체는 이름이 있는 변수이므로 lvalue입니다. 그래서 : data(vec)라고 쓰면 복사 생성자가 선택되고, : data(std::move(vec))라고 써야 이동 생성자가 선택됩니다. 이름이 있으면 함수 안에서 여러 번 쓸 수 있으므로, 언어가 첫 사용에서 몰래 내용을 가져가지 않도록 lvalue로 취급하는 것입니다.

같은 이유로 템플릿 래퍼에서 “받은 인자가 lvalue였으면 lvalue로, rvalue였으면 rvalue로” 다시 넘기려면 std::forward가 필요합니다. 이 내용은 14-2 Perfect Forwarding에서 이어서 설명합니다.


Rule of Five, 빌더, 팩토리 예제

예제 1: 리소스 관리 클래스

#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>

class ManagedBuffer {
    int* data_;
    std::size_t size_;
public:
    explicit ManagedBuffer(std::size_t size) : data_(new int[size]), size_(size) {
        std::fill(data_, data_ + size_, 0);
        std::cout << "Constructor(" << size_ << ")\n";
    }
    ManagedBuffer(const ManagedBuffer& other)
        : data_(new int[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);
        std::cout << "Copy constructor\n";
    }
    ManagedBuffer(ManagedBuffer&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
        std::cout << "Move constructor\n";
    }
    ManagedBuffer& operator=(const ManagedBuffer& other) {
        if (this != &other) {
            int* fresh = new int[other.size_];  // 먼저 할당: 실패해도 *this는 그대로
            std::copy(other.data_, other.data_ + other.size_, fresh);
            delete[] data_;
            data_ = fresh;
            size_ = other.size_;
            std::cout << "Copy assignment\n";
        }
        return *this;
    }
    ManagedBuffer& operator=(ManagedBuffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
            std::cout << "Move assignment\n";
        }
        return *this;
    }
    ~ManagedBuffer() {
        delete[] data_;
        std::cout << "Destructor\n";
    }
};

int main() {
    ManagedBuffer a(100);
    ManagedBuffer b = std::move(a);   // Move constructor
    ManagedBuffer c(50);
    c = std::move(b);                 // Move assignment
}
Constructor(100)
Move constructor
Constructor(50)
Move assignment
Destructor
Destructor
Destructor

복사 대입에서 기존 버퍼를 먼저 delete[]한 뒤 새로 할당하면, new가 std::bad_alloc을 던졌을 때 data_가 이미 해제된 포인터를 가리키는 상태로 남습니다. 위 코드는 새 버퍼를 먼저 만들고 성공한 뒤에 교체해 이 문제를 피합니다.

예제 2: 빌더 패턴

#include <string>
#include <utility>
#include <vector>

class ConfigBuilder {
    std::string name_;
    std::vector<std::string> tags_;
    std::vector<int> values_;
public:
    struct Config {
        std::string name;
        std::vector<std::string> tags;
        std::vector<int> values;
    };
    ConfigBuilder& setName(std::string name) {
        name_ = std::move(name);
        return *this;
    }
    ConfigBuilder& addTag(std::string tag) {
        tags_.push_back(std::move(tag));
        return *this;
    }
    ConfigBuilder& addValue(int value) {
        values_.push_back(value);
        return *this;
    }
    Config build() {
        return Config{std::move(name_), std::move(tags_), std::move(values_)};
    }
};

int main() {
    ConfigBuilder builder;
    builder.setName("my-service")
           .addTag("production")
           .addTag("v1")
           .addValue(42)
           .addValue(100);
    auto config = builder.build();
}

setName과 addTag는 값으로 받은 뒤 멤버로 이동하므로, 호출자가 임시 문자열을 넘기면 복사 없이 저장됩니다. build()는 멤버를 이동해 Config를 만들기 때문에 호출 후 빌더는 비어 있는 상태가 되고, 같은 빌더로 build()를 두 번 부르면 두 번째 결과는 비어 있습니다. 빌더를 재사용해야 한다면 build() const&에서 복사하고 build() &&에서 이동하는 식으로 참조 한정자 오버로드를 두는 방법이 있습니다.

예제 3: 팩토리 함수

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

std::unique_ptr<std::vector<int>> createFilteredVector(
    const std::vector<int>& source, int threshold) {
    auto result = std::make_unique<std::vector<int>>();
    for (int x : source) {
        if (x > threshold) result->push_back(x);
    }
    return result;  // NRVO 또는 암시적 이동, std::move 불필요
}

std::vector<std::string> loadLines(const std::string& path) {
    std::vector<std::string> lines;
    // 파일에서 읽어 lines에 추가...
    return lines;
}

unique_ptr은 복사가 안 되는 타입이지만 return result;는 암시적 이동 덕분에 컴파일됩니다. 팩토리가 소유권을 호출자에게 넘긴다는 의도가 반환 타입에 그대로 드러나는 것이 이 패턴의 장점입니다.


참조 축소와 전달 참조

참조 축소 규칙

C++에서 참조의 참조를 직접 선언할 수는 없지만, 템플릿 인자 치환이나 using 별칭, decltype을 거치면 참조가 두 겹 겹치는 상황이 생깁니다. 이때 다음 규칙으로 한 겹이 됩니다.

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

둘 중 하나라도 lvalue 참조면 lvalue 참조, 둘 다 rvalue 참조일 때만 rvalue 참조입니다.

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

T가 그 자리에서 추론되는 T&& 형태의 함수 템플릿 매개변수는 전달 참조(forwarding reference, 예전 이름은 universal reference)라고 부르며 특별한 추론 규칙이 적용됩니다.

template <typename T>
void foo(T&& arg);   // 전달 참조: lvalue와 rvalue 모두 받음

template <typename T>
class Bar {
    void baz(T&& arg);  // T는 클래스 템플릿 인자로 이미 정해짐 → 일반 rvalue 참조
};

void qux(auto&& arg);  // C++20 축약 템플릿: 이것도 전달 참조

foo(x)처럼 int lvalue를 넘기면 T가 int&로 추론되고, T&&는 int& &&에서 int&로 축소되어 arg는 lvalue 참조가 됩니다. foo(42)처럼 rvalue를 넘기면 T는 int, arg의 타입은 int&&입니다. 같은 T&& 문법이라도 Bar<int>::baz는 int&&로 고정되어 lvalue를 받지 못합니다. const T&&도 전달 참조가 아니라 const rvalue 참조입니다.

std::forward와 완벽 전달

함수 안에서 arg는 이름이 있으므로 항상 lvalue입니다. 받은 인자를 다른 함수로 그냥 넘기면, 호출자가 임시 객체를 넘겼더라도 lvalue로 전달되어 복사가 일어납니다. 그렇다고 std::move(arg)로 넘기면 호출자가 lvalue를 넘긴 경우까지 이동해 버려서, 호출자의 변수가 예고 없이 비게 됩니다. std::forward<T>(arg)는 T에 남아 있는 추론 결과를 이용해, 원래 lvalue였으면 lvalue로, rvalue였으면 rvalue로 캐스팅합니다.

#include <iostream>
#include <utility>

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가 int&로 캐스팅 → "lvalue"
    wrap(2);  // T = int   → forward가 int&&로 캐스팅 → "rvalue"
}

std::forward<T>가 하는 일은 static_cast<T&&>(arg)입니다. T가 int&이면 참조 축소로 int&, T가 int이면 int&&가 됩니다. 그래서 std::forward에는 템플릿 인자를 반드시 명시해야 하고, 그 인자는 전달 참조로 추론된 바로 그 T여야 합니다.

팩토리 함수는 이 패턴의 전형적인 예입니다.

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

std::make_unique도 이와 같은 방식으로, 호출자가 넘긴 각 인자의 값 범주를 T의 생성자까지 그대로 전달합니다. 같은 인자를 두 번 전달하면 첫 번째 호출에서 이동된 뒤 두 번째 호출에 빈 값이 들어갈 수 있으므로, std::forward는 각 인자에 대해 한 번만 쓰는 것이 원칙입니다.

이동 후 상태, noexcept, const rvalue 참조

이동된 객체의 상태

std::string str = "Hello";
std::string str2 = std::move(str);
std::cout << str << "\n";  // UB는 아니지만 값은 지정되지 않음 (주요 구현에서는 빈 문자열)
str = "World";             // 새 값 대입은 안전
std::cout << str << "\n";  // "World"

표준 라이브러리 타입은 이동 후 원본을 “유효하지만 지정되지 않은(valid but unspecified)” 상태로 둡니다. 객체로서의 불변식은 유지되므로 전제 조건이 없는 연산(대입, clear(), size(), 소멸)은 안전하게 호출할 수 있고, front()처럼 “비어 있지 않음”을 전제하는 연산만 위험합니다. 다만 unique_ptr(이동 후 nullptr)이나 vector의 이동 생성(원본이 빔)처럼 타입별로 더 강한 보장이 있는 경우도 있습니다. 직접 만든 클래스라면 이동 후 상태를 소멸과 재대입이 안전한 상태로 만드는 것이 최소한의 책임입니다.

noexcept 중요성

std::vector는 재할당할 때 기존 원소를 새 버퍼로 옮깁니다. 이때 원소 타입의 이동 생성자가 noexcept가 아니고 복사 생성자를 쓸 수 있다면, 이동 대신 복사를 고릅니다(std::move_if_noexcept). 이동 도중 예외가 나면 일부 원소는 이미 옮겨져 원래 버퍼가 망가진 상태가 되어, push_back이 약속하는 강한 예외 보장(실패하면 아무 변화 없음)을 지킬 수 없기 때문입니다. 복사라면 실패해도 원래 버퍼가 그대로 남습니다.

class MyClass {
public:
    MyClass(MyClass&& other) noexcept { /* ... */ }
    MyClass& operator=(MyClass&& other) noexcept { /* ...; */ return *this; }
};

그래서 이동 생성자가 실제로 예외를 던지지 않는다면 noexcept를 붙여야 합니다. 붙이지 않으면 vector가 커질 때마다 모든 원소가 복사되는데, 컴파일 오류도 경고도 없어서 프로파일러로 보기 전에는 알아차리기 어렵습니다. = default로 선언한 이동 생성자는 모든 멤버의 이동이 noexcept이면 자동으로 noexcept가 됩니다. static_assert(std::is_nothrow_move_constructible_v<MyClass>);를 클래스 정의 뒤에 두면 이 조건을 컴파일 타임에 확인할 수 있습니다.

const rvalue 참조

void process(const std::string&& str);  // 받을 수는 있지만 const라 내용을 가져갈 수 없음
void process(std::string&& str);        // 이동 가능

const T&&는 rvalue만 받으면서도 수정이 금지되어 이동에 쓸 수 없으므로, 이동을 위한 매개변수로는 의미가 없습니다. 표준 라이브러리에서 const T&&는 std::ref에 임시를 넘기지 못하게 하는 = delete 오버로드처럼 특정 호출을 막는 용도로만 쓰입니다.


자주 하는 실수

이동 후 원본에 이전 값이 남아 있다고 가정

std::vector<int> vec = {1, 2, 3};
std::vector<int> other = std::move(vec);
vec.push_back(4);  // vec는 {4}. {1,2,3,4}를 기대하면 버그

이동된 뒤에도 객체는 유효하므로 push_back은 문제없이 실행됩니다. 문제는 이전 내용이 남아 있다고 가정하는 것입니다. vector의 이동 생성은 원본을 비운다고 보장하므로 여기서 vec는 {4}가 되고, 다른 타입이나 이동 대입에서는 원본 내용이 아예 지정되지 않습니다. Clang-Tidy의 bugprone-use-after-move 체크가 이런 코드를 찾아 줍니다.

rvalue 참조 매개변수에서 std::move 누락

class Wrapper {
    std::vector<int> data;
public:
    Wrapper(std::vector<int>&& vec) : data(vec) {}             // 복사
    // Wrapper(std::vector<int>&& vec) : data(std::move(vec)) {} // 이동
};

앞에서 본 것처럼 vec는 이름이 있으므로 lvalue입니다. 이동을 의도했다면 std::move로 다시 rvalue로 바꿔야 합니다. 컴파일러는 이를 오류로 보지 않으므로 코드 리뷰에서 “&& 매개변수를 이름 그대로 넘기는 곳”을 찾아보세요.

const 객체에 std::move

const std::string str = "Hello";
std::string other = std::move(str);  // 복사가 일어남

std::move는 캐스트만 하므로 const std::string에 적용하면 결과 타입이 const std::string&&입니다. 이동 생성자는 std::string&&를 받으므로 const를 받을 수 없고, 대신 const std::string&을 받는 복사 생성자가 선택됩니다. 컴파일 오류도 경고도 없이 조용히 복사가 되는 점이 함정입니다. 멤버 함수가 const일 때 그 안에서 멤버를 std::move하는 경우에도 같은 일이 일어납니다.

기본 타입에 std::move

int, double, 포인터 같은 기본 타입은 이동과 복사가 같은 동작입니다. int y = std::move(x);는 int y = x;와 같고 x도 그대로 남습니다. 해가 되지는 않지만 의미가 없고, 원본이 비워진다고 오해하게 만들 수 있습니다. 앞의 Buffer 이동 생성자에서 other.size = 0;을 직접 써야 했던 이유도 이것입니다.


복사와 이동 비용 측정

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

int main() {
    using clock = std::chrono::steady_clock;
    const std::size_t count = 10000;
    const std::size_t strSize = 1000;

    std::vector<std::string> src1(count, std::string(strSize, 'x'));
    std::vector<std::string> src2(count, std::string(strSize, 'x'));

    auto t0 = clock::now();
    std::vector<std::string> copied = src1;            // 복사
    auto t1 = clock::now();
    std::vector<std::string> moved = std::move(src2);  // 이동
    auto t2 = clock::now();

    using us = std::chrono::microseconds;
    std::cout << "copy: " << std::chrono::duration_cast<us>(t1 - t0).count() << " us\n"
              << "move: " << std::chrono::duration_cast<us>(t2 - t1).count() << " us\n";
}

측정 대상 벡터는 타이머를 시작하기 전에 만들어 두어야 생성 비용이 섞이지 않습니다. 복사는 문자열 1만 개마다 새 버퍼를 할당하고 1000바이트씩 복사하므로 원소 수와 문자열 길이에 비례해 비용이 늘어납니다. 이동은 vector 내부의 포인터 세 개(시작, 끝, 용량)만 넘겨받으므로 원소 수와 상관없는 상수 시간이고, 결과는 대개 타이머 해상도 수준으로 작게 나옵니다. 실제 수치는 하드웨어와 표준 라이브러리 구현에 따라 다르므로 직접 실행해 확인하는 것이 좋습니다.

push_back도 같은 방식으로 비교할 수 있습니다. 100바이트짜리 문자열(대부분 구현의 SSO 한도를 넘는 길이)을 반복해서 넣는다면 push_back(s)는 매번 새 버퍼를 할당해 복사하고, push_back(std::move(s))는 포인터만 넘기며, emplace_back(100, 'x')는 컨테이너 안에서 바로 생성합니다. 대략 emplace_back ≤ push_back(move) < push_back(copy) 순서가 되지만, 이동이 싼 타입에서는 앞의 두 방식의 차이가 거의 없습니다.


Pimpl, 작업 큐, API 설계

Pimpl과 이동

Pimpl(pointer to implementation)은 구현을 unique_ptr<Impl> 뒤에 숨기므로 이동이 포인터 하나를 옮기는 것으로 끝납니다. 주의할 점은 Impl이 헤더에서 불완전 타입이라는 것입니다. 소멸자나 이동 대입을 헤더에서 암시적으로 또는 = default로 정의하면, unique_ptr의 삭제자가 불완전 타입을 삭제하려다 컴파일 오류가 납니다. 그래서 헤더에서는 선언만 하고, Impl 정의가 보이는 .cpp에서 = default로 정의합니다.

// widget.hpp
#include <memory>
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
public:
    Widget();
    ~Widget();
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;
    Widget(const Widget&) = delete;
    Widget& operator=(const Widget&) = delete;
};

// widget.cpp
struct Widget::Impl { /* ... */ };
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;

이동된 Widget은 pImpl이 nullptr이므로, 멤버 함수가 pImpl을 역참조한다면 이동 후 호출에서 크래시가 납니다. 이동된 객체에 대해서는 소멸과 재대입만 허용한다고 문서화하는 것이 일반적입니다.

작업 큐에 이동 전용 작업 넣기

스레드 풀 작업 큐에서 흔히 부딪히는 문제는 std::function이 복사 가능한 호출 객체만 담을 수 있다는 점입니다. std::packaged_task는 이동 전용이고, 그것을 캡처한 람다도 이동 전용이 되므로 std::function<void()>에 넣으면 컴파일 오류가 납니다.

#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <queue>

class TaskQueue {
    std::queue<std::function<void()>> queue_;
    std::mutex mutex_;
public:
    template <typename F>
    void submit(F&& f) {
        std::lock_guard<std::mutex> lock(mutex_);
        queue_.push(std::forward<F>(f));  // F가 복사 가능해야 함
    }

    std::future<int> submit(std::packaged_task<int()> task) {
        auto fut = task.get_future();
        // 이동 전용 task를 shared_ptr로 감싸 람다를 복사 가능하게 만듦
        auto p = std::make_shared<std::packaged_task<int()>>(std::move(task));
        std::lock_guard<std::mutex> lock(mutex_);
        queue_.push([p] { (*p)(); });
        return fut;
    }
};

C++23을 쓸 수 있다면 큐의 원소 타입을 std::move_only_function<void()>로 바꾸는 것이 더 직접적인 해결책입니다. 이 경우 [t = std::move(task)]() mutable { t(); }를 그대로 넣을 수 있고 shared_ptr 할당도 필요 없습니다.

API에서 매개변수 받는 방식 고르기

// 값으로 받고 내부에서 이동: 호출자가 복사할지 이동할지 고름
void process(std::string data) {
    storage_.push_back(std::move(data));
}
// 이동 전용 타입을 값으로 받기: 소유권 이전을 강제
void takeOwnership(std::unique_ptr<Resource> ptr) {
    resource_ = std::move(ptr);
}
// 읽기만 할 때
void readOnly(const std::string& s);
상황권장예시
받은 값을 저장할 때T(값으로 받고 내부에서 move) 또는 const T& + T&& 오버로드void process(std::string data)
읽기만 할 때const T&void read(const std::string& s)
소유권 이전만 허용할 때std::unique_ptr<T>를 값으로void take(std::unique_ptr<Widget> p)

값으로 받는 방식은 호출자가 lvalue를 넘기면 복사 1회와 이동 1회, std::move로 넘기면 이동 2회, 임시 객체를 바로 넘기면 매개변수 생성이 생략되어 이동 1회로 끝납니다. 오버로드를 하나만 유지하면 된다는 장점이 있지만 이동이 한 번 더 끼므로, std::array처럼 원소를 객체 안에 직접 들고 있어 이동조차 비싼 타입이라면 const T&와 T&& 두 오버로드를 두는 편이 낫습니다.

메모리 오남용과 누수를 찾는 방법은 메모리 누수 가이드, Valgrind, 누수 탐지 실전에서 다룹니다.

참고 자료


같이 보면 좋은 글