2023년 1월 1일
08:00 AM
Buffering ...

최근 글 👑

C++ std::move는 무엇을 이동할까? 이동 생성자와 이동 대입 연산자 이해하기: std::move는 값을 옮기는 함수가 아니라 이동 가능 표시다

2026. 6. 29. 06:00ㆍC++/C++ 기초
반응형

한 줄 요약: `std::move`는 객체의 내용을 직접 옮기지 않고 lvalue를 rvalue처럼 취급하게 만드는 캐스팅 도구이며, 실제 자원 이전은 선택된 이동 생성자나 이동 대입 연산자가 수행합니다.

먼저 알아야 할 배경

C++에서 `std::move(name)`이라는 표현을 처음 보면 name 안의 데이터가 그 자리에서 다른 곳으로 이동한다고 생각하기 쉽습니다. 하지만 `std::move` 자체는 포인터를 바꾸거나 문자열 버퍼를 비우는 작업을 하지 않습니다.
이 함수는 오버로드 해석이 이동 생성자나 이동 대입 연산자를 선택할 수 있도록 표현식의 값 범주를 바꿔 줍니다.

이 차이를 모르면 이동 후 객체를 잘못 사용하거나, const 객체에 무의미하게 std::move를 붙이거나, 반환문에 불필요한 std::move를 넣는 실수가 생깁니다. 이동은 성능 최적화 문법이 아니라 소유권과 재사용 가능 상태를 다루는 계약에 가깝습니다.

modern C++에서는 `std::unique_ptr`, `std::vector`, `std::string` 같은 RAII 타입이 이동 의미론과 함께 동작합니다. 복사 비용이 큰 자원을 매번 새로 복제하지 않고 소유권을 이전할 수 있지만, 이동 후 원본은 유효하되 값은 일반적으로 기대하지 않는 상태로 남을 수 있다는 점을 함께 이해해야 합니다.

핵심 개념

std::move는 이동을 실행하지 않고 이동 가능한 표현식을 만든다

`std::move`는 이름과 달리 실제 이동 작업을 수행하지 않습니다. cppreference 기준으로 `std::move`는 인자를 rvalue reference로 캐스팅해 xvalue 표현식을 만듭니다. 그 결과 함수 호출이나 초기화에서 이동 생성자, 이동 대입 연산자, rvalue reference 오버로드가 선택될 수 있습니다.

실제 자원을 어떻게 옮길지는 대상 타입의 이동 연산자가 결정합니다. `std::string`은 내부 버퍼를 이전할 수도 있고, 작은 문자열 최적화 같은 구현 세부 사항 때문에 복사와 비슷하게 처리할 수도 있습니다.

표준이 보장하는 것은 이동 후 객체가 파괴나 대입 같은 기본 연산을 수행할 수 있는 유효 상태라는 점이지, 정확히 어떤 값이 남는지는 타입별 규칙을 봐야 합니다.

따라서 `std::move`는 ‘이 객체를 더 이상 기존 값으로 사용하지 않겠다’는 프로그래머의 의사 표현으로 읽는 편이 좋습니다. 컴파일러가 알아서 안전하게 비워 주는 마법이 아니라, 이후 코드가 이동 전 값을 기대하지 않도록 작성해야 하는 신호입니다.

이동 생성자는 새 객체를 만들 때 자원을 넘겨받는다

이동 생성자는 `T(T&& other)` 형태로 새 객체를 만들면서 other가 가진 자원을 넘겨받습니다. 원시 포인터로 버퍼를 소유하는 클래스라면 복사 생성자는 새 버퍼를 할당하고 원소를 복사하지만, 이동 생성자는 포인터와 크기만 가져오고 원본을 비어 있는 상태로 만들 수 있습니다.

원본을 비워 두는 과정이 중요합니다. 이동된 원본의 포인터를 nullptr로 바꾸지 않으면 원본 소멸자와 새 객체 소멸자가 같은 메모리를 해제할 수 있습니다. 이동은 복사보다 빠를 수 있지만, 소유권 이전 규칙을 정확히 구현하지 않으면 얕은 복사와 같은 double delete 문제가 다시 나타납니다.

표준 컨테이너는 원소를 재배치할 때 이동 생성자가 `noexcept`인지 확인하는 경우가 있습니다. 이동 중 예외가 날 수 있으면 강한 예외 보장을 지키기 위해 복사를 선택할 수 있습니다. 자원 소유 타입의 이동 연산을 `noexcept`로 만들 수 있다면 컨테이너와 함께 사용할 때 더 예측 가능한 동작을 얻습니다.

이동 대입은 이미 자원을 가진 객체를 덮어쓴다

이동 대입 연산자는 `operator=(T&& other)` 형태로 이미 존재하는 객체가 other의 자원을 넘겨받을 때 호출됩니다. 새 객체를 만드는 이동 생성자와 달리, 이동 대입 대상은 기존 자원을 소유하고 있을 수 있습니다. 그래서 먼저 기존 자원을 안전하게 해제하거나 임시 객체와 교환하는 방식으로 누수와 자기 대입 문제를 피해야 합니다.

이동 대입 후 other는 여전히 소멸 가능하고 다시 대입 가능한 유효 상태여야 합니다. 보통 크기를 0으로 만들고 포인터를 nullptr로 바꾸는 식으로 구현합니다. 이 상태는 ‘비어 있음’일 수는 있지만, 모든 타입에서 사용자가 기대하는 특정 값으로 정해지는 것은 아닙니다.

직접 이동 연산자를 작성해야 한다면 Rule of Five를 함께 검토해야 합니다. 소멸자, 복사 생성자, 복사 대입, 이동 생성자, 이동 대입은 모두 같은 소유 자원의 수명 정책을 설명합니다. 하나만 구현하고 나머지를 기본값으로 두면 복사와 이동 경로 사이에 모순이 생길 수 있습니다.

이동 후 객체는 유효하지만 값은 기대하지 않는다

표준 라이브러리 타입의 이동 후 객체는 보통 ‘valid but unspecified state’라고 설명됩니다. 즉 소멸하거나 새 값을 대입하거나 일부 제한된 연산을 호출할 수는 있지만, 이동 전 값이 그대로 남아 있다고 가정하면 안 됩니다. 문자열이 비어 있을 수도 있고, 구현에 따라 내용이 남아 보일 수도 있습니다.

이 규칙은 실무 코드에서 단순한 습관으로 바꿔 생각하면 편합니다. `std::move(x)`를 쓴 뒤에는 x를 로그 출력이나 조건 판단에 계속 쓰지 말고, 새 값을 대입하거나 스코프를 끝내는 흐름으로 작성합니다. 이동 후 원본을 검사해야 한다면 해당 타입 문서가 보장하는 연산인지 확인해야 합니다.

특히 const 객체에 `std::move`를 붙이는 코드는 의도와 다르게 동작하기 쉽습니다. const rvalue는 일반적인 이동 생성자가 요구하는 비 const rvalue reference에 바인딩되지 않으므로 복사가 선택될 수 있습니다. 이동하려면 객체를 수정 가능한 상태로 넘겨야 하며, 읽기 전용 입력은 복사로 처리하는 편이 명확합니다.

메모리와 객체 관점에서 이해하기

이동 전에는 원본 객체가 자원의 소유자입니다. `std::move` 표현식은 그 객체를 이동 가능한 후보로 표시하고, 이후 선택된 이동 생성자나 이동 대입 연산자가 내부 포인터, 크기, 핸들 같은 소유 상태를 새 객체로 넘깁니다.

이동 후에는 대상 객체가 자원의 새 소유자가 되고 원본 객체는 비어 있거나 최소한 소멸 가능한 상태로 남습니다. 포인터를 직접 소유하는 클래스라면 원본 포인터를 nullptr로 바꾸는 작업이 double delete를 막는 핵심입니다.

표준 RAII 타입은 이런 세부 작업을 이미 구현합니다. `std::unique_ptr`는 이동 후 원본이 nullptr이 되고, vector나 string은 구현별 최적화가 있을 수 있지만 원본을 유효 상태로 유지합니다. 직접 new/delete를 다룰수록 이 규칙을 클래스가 직접 책임져야 합니다.

코드로 확인하기

std::move 후 원본 문자열은 다시 대입해서 사용하기

먼저 `std::string`을 vector에 넣는 작은 예제로 `std::move`가 어떤 의도 표시인지 보겠습니다. 이동 후 문자열의 size를 출력할 수는 있지만, 그 값이 항상 특정 결과라고 일반화해서는 안 됩니다.

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

int main()
{
    std::string title = "C++ move semantics";
    std::vector<std::string> titles;

    titles.push_back(std::move(title));

    std::cout << "stored: " << titles.front() << '\n';
    std::cout << "moved-from size: " << title.size() << '\n';

    title = "reused after assignment";
    std::cout << title << '\n';
}

`titles.push_back(std::move(title))`에서 std::move는 title을 xvalue로 만들어 vector가 이동 오버로드를 선택할 수 있게 합니다. 이후 title은 유효하지만 이동 전 문자열을 유지한다고 기대하지 않습니다. 예제처럼 다시 값을 대입한 뒤 사용하는 흐름이 안전합니다.

이동 생성자와 이동 대입 연산자로 소유권 넘기기

원시 배열을 소유하는 클래스를 직접 작성해야 한다면 이동 연산자가 어떤 일을 해야 하는지 명확해야 합니다. 다음 예제는 C++17 기준으로 포인터와 크기를 이전하고 원본을 비우는 전형적인 형태를 보여 줍니다.

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

class Buffer
{
public:
    explicit Buffer(std::size_t size)
        : size_(size), data_(new int[size]{})
    {
    }

    ~Buffer()
    {
        delete[] data_;
    }

    Buffer(const Buffer& other)
        : size_(other.size_), data_(new int[other.size_])
    {
        std::copy(other.data_, other.data_ + size_, data_);
        std::cout << "copy\n";
    }

    Buffer& operator=(const Buffer& other)
    {
        if (this == &other)
        {
            return *this;
        }

        Buffer temp(other);
        swap(temp);
        return *this;
    }

    Buffer(Buffer&& other) noexcept
        : size_(std::exchange(other.size_, 0)),
          data_(std::exchange(other.data_, nullptr))
    {
        std::cout << "move\n";
    }

    Buffer& operator=(Buffer&& other) noexcept
    {
        if (this == &other)
        {
            return *this;
        }

        delete[] data_;
        size_ = std::exchange(other.size_, 0);
        data_ = std::exchange(other.data_, nullptr);
        std::cout << "move assign\n";
        return *this;
    }

    void swap(Buffer& other) noexcept
    {
        std::swap(size_, other.size_);
        std::swap(data_, other.data_);
    }

private:
    std::size_t size_ = 0;
    int* data_ = nullptr;
};

int main()
{
    Buffer first(1024);
    Buffer second = std::move(first);

    Buffer third(8);
    third = std::move(second);
}

이동 생성자는 `std::exchange`로 other의 크기와 포인터를 가져오면서 other를 빈 상태로 바꿉니다. 이동 대입은 기존 data_를 먼저 해제한 뒤 other의 자원을 가져옵니다. 두 이동 연산이 `noexcept`인 이유는 이 작업이 포인터 교환 중심이며, 표준 컨테이너가 원소 이동을 선택하는 데도 도움이 되기 때문입니다.

unique_ptr는 복사할 수 없고 이동으로 소유권을 넘긴다

`std::unique_ptr`는 이동 의미론을 가장 직관적으로 보여 주는 타입입니다. 복사가 막혀 있기 때문에 vector에 넣으려면 소유권을 명시적으로 이동해야 합니다.

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

struct Enemy
{
    explicit Enemy(std::string name) : name(std::move(name)) {}
    std::string name;
};

int main()
{
    std::vector<std::unique_ptr<Enemy>> enemies;

    auto boss = std::make_unique<Enemy>("Boss");
    enemies.push_back(std::move(boss));

    if (boss == nullptr)
    {
        std::cout << "ownership moved into vector\n";
    }

    std::cout << enemies.front()->name << '\n';
}

`enemies.push_back(std::move(boss))` 이후 boss는 nullptr이 됩니다. 이 동작은 unique_ptr의 명확한 보장입니다. raw pointer였다면 vector가 소유하는지 호출자가 소유하는지 모호했겠지만, unique_ptr는 소유권 이전이 타입과 코드에 모두 드러납니다.

자주 하는 실수

실수 1. std::move 후 기존 값이 남아 있다고 가정하기

이동된 객체는 유효하지만 이동 전 값을 보존한다고 보장되지 않습니다. 로그나 조건식에서 예전 값을 계속 쓰면 구현에 따라 우연히 동작하는 코드가 됩니다.

문제 코드 — 잘못된 예시:

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

void Store(std::vector<std::string>& out)
{
    std::string name = "Player";
    out.push_back(std::move(name));

    // 잘못된 예시: 이동 전 값이 그대로 남아 있다고 가정합니다.
    std::cout << "saved " << name << '\n';
}

개선 코드:

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

void Store(std::vector<std::string>& out)
{
    std::string name = "Player";
    out.push_back(std::move(name));

    name = "NextPlayer";
    std::cout << "reused " << name << '\n';
}

설명: 개선 코드처럼 이동 후에는 새 값을 대입한 뒤 사용합니다. 소멸, 대입, 일부 문서화된 연산은 가능하지만, 이동 전 내용이 필요하다면 애초에 move를 호출하지 말고 복사를 선택해야 합니다.

실수 2. const 객체에 std::move를 붙이면 항상 이동된다고 생각하기

이동 연산은 보통 원본을 수정할 수 있어야 하므로 `T&&`를 받습니다. const 참조에 std::move를 붙이면 const rvalue가 되고, 일반적인 이동 생성자 대신 복사가 선택될 수 있습니다.

문제 코드 — 잘못된 예시:

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

void AddName(std::vector<std::string>& names, const std::string& name)
{
    // 잘못된 예시: const 객체에서 std::move를 호출해도 string 이동 생성자를 선택하기 어렵습니다.
    names.push_back(std::move(name));
}

개선 코드:

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

void AddCopy(std::vector<std::string>& names, const std::string& name)
{
    names.push_back(name);
}

void AddMove(std::vector<std::string>& names, std::string name)
{
    names.push_back(std::move(name));
}

설명: 읽기 전용 입력은 `AddCopy`처럼 복사한다고 명시하는 편이 낫습니다. 값으로 받은 매개변수는 호출자가 복사 또는 이동으로 넘길 수 있고, 함수 내부에서는 그 지역 객체를 vector로 이동할 수 있습니다. 인터페이스가 소유권을 어떻게 받을지 먼저 결정해야 합니다.

실수 3. 지역 변수 반환에 습관적으로 std::move 붙이기

지역 변수를 반환할 때 C++17에서는 복사 생략이나 암시적 이동이 적용될 수 있습니다. 불필요한 std::move는 의도를 흐리고 경우에 따라 복사 생략 기회를 줄일 수 있습니다.

문제 코드 — 잘못된 예시:

#include <string>

std::string MakeTitle()
{
    std::string title = "move semantics";
    return std::move(title); // 잘못된 예시: 지역 변수 반환에는 보통 필요하지 않습니다.
}

개선 코드:

#include <string>

std::string MakeTitle()
{
    std::string title = "move semantics";
    return title;
}

설명: 단순 지역 변수 반환은 `return title;`로 두는 편이 읽기 쉽고 컴파일러 최적화에도 우호적입니다. std::move는 이름 있는 객체를 이후 쓰지 않겠다는 의도를 명시할 때 사용하고, 반환 최적화를 대신하는 주문처럼 사용하지 않습니다.

언제 어떤 방식을 선택해야 할까

소유권을 넘길 의도가 분명하고 원본을 이후 기존 값으로 사용하지 않을 때 `std::move`를 사용합니다. 단순히 성능이 좋아 보인다는 이유로 모든 변수에 붙이면 코드 의미가 흐려집니다.

복사 비용이 작거나 원본 값이 계속 필요하면 복사를 선택합니다. 이동 후 원본 상태를 추론하는 코드보다, 필요한 값을 명확히 복사해 두는 코드가 더 안전할 때가 많습니다.

자원 소유 클래스를 직접 만든다면 Rule of Five와 noexcept 이동을 검토합니다. 하지만 가능하면 `std::vector`, `std::string`, `std::unique_ptr` 같은 RAII 타입을 멤버로 두어 Rule of Zero에 가깝게 설계합니다.

정리

`std::move`는 자원을 옮기는 실행 함수가 아니라 이동 오버로드가 선택될 수 있게 표현식을 바꾸는 도구입니다. 실제 이동은 타입의 이동 생성자와 이동 대입 연산자가 수행합니다.

이동 생성자는 새 객체를 만들며 자원을 넘겨받고, 이동 대입은 기존 객체의 자원을 정리한 뒤 새 자원을 넘겨받습니다. 원본 객체는 유효하지만 이동 전 값을 기대해서는 안 됩니다.

이동 의미론을 안전하게 쓰려면 소유권을 타입으로 드러내고, 이동 후 원본 사용을 제한하며, 직접 자원 클래스를 만들 때 복사·이동·소멸 정책을 함께 설계해야 합니다.

참고 자료

반응형