Skip to main content

이동 의미론

Views 7약 11분생성형 AI 활용
Summary

복사는 왜 비쌀까?

C++에서 값은 기본적으로 복사될 수 있습니다. 이 말은 처음에는 아주 편하게 들립니다. int 하나를 복사하는 일은 거의 부담이 없습니다.

int a = 10;
int b = a;

a의 값 10이 b에 복사됩니다. 두 변수는 서로 독립적입니다. b를 바꾸어도 a는 변하지 않습니다. 여기까지는 쉽습니다.

문제는 값이 커질 때 시작됩니다. 다음 코드를 봅시다.

#include <string>

std::string name1 = "Alice";
std::string name2 = name1;

겉으로는 int 복사와 똑같아 보입니다. 하지만 std::string은 내부에 문자 데이터를 저장합니다. 문자열이 짧으면 구현에 따라 객체 안에 직접 저장될 수도 있지만, 길어지면 대개 힙 메모리를 사용합니다. 즉, std::string은 단순한 숫자 하나가 아니라 "문자 데이터를 담은 자원"을 관리하는 객체입니다.

name2 = name1을 하면 name2는 name1과 같은 문자열 내용을 가져야 합니다. 두 문자열이 서로 독립적이어야 하므로, 내부 문자 데이터도 새로 준비되어야 합니다. 문자열 길이가 수십 글자라면 별일이 아니지만, 수백만 글자라면 이야기가 달라집니다.

std::vector도 마찬가지입니다.

#include <vector>

std::vector<int> numbers1(1'000'000, 0);
std::vector<int> numbers2 = numbers1;

이 코드는 정수 백만 개를 가진 벡터를 복사합니다. numbers2는 numbers1과 같은 원소를 가져야 하므로, 내부 배열을 새로 만들고 백만 개의 값을 복사해야 합니다. 코드 한 줄은 짧지만 실제 작업량은 결코 짧지 않습니다.

복사는 안전합니다. 복사한 뒤 두 객체가 서로 독립적으로 살아가기 때문입니다. 하지만 큰 자원을 가진 객체를 복사하면 비용이 커집니다. 파일 내용을 담은 문자열, 이미지 픽셀 배열, 게임 캐릭터 목록, 대량의 로그 데이터, 네트워크 패킷 버퍼처럼 크기가 큰 데이터를 다룰수록 복사 비용은 프로그램의 성능을 흔듭니다.

그런데 모든 상황에서 진짜 복사가 필요할까요?

std::vector<int> makeNumbers() {
    std::vector<int> result;
    result.push_back(10);
    result.push_back(20);
    result.push_back(30);
    return result;
}

std::vector<int> numbers = makeNumbers();

makeNumbers 안에서 만든 result는 함수가 끝나면 사라질 지역 변수입니다. 호출하는 쪽에서는 그 결과만 필요합니다. 그렇다면 result의 내부 배열을 새로 복사하지 말고, 그대로 numbers에게 넘겨 주면 어떨까요?

이 발상이 바로 이동 의미론의 출발점입니다.

이동 의미론은 "어차피 곧 사라질 객체라면, 그 객체가 가진 자원을 새 객체가 넘겨받자"는 생각입니다. 복사가 내용물을 하나하나 베껴 쓰는 일이라면, 이동은 이삿짐을 통째로 새 집으로 옮기는 일에 가깝습니다.

이동 의미론

복사와 이동의 차이

복사와 이동의 차이를 그림처럼 생각해 봅시다.

복사는 원본 책을 보고 새 책을 한 권 더 인쇄하는 일입니다. 원본도 남고, 복사본도 남습니다. 대신 종이와 잉크와 시간이 듭니다.

이동은 책이 꽂혀 있던 책장을 다른 사람에게 넘기는 일입니다. 책을 새로 인쇄하지 않습니다. 대신 원래 주인은 더 이상 그 책장을 소유하지 않습니다.

C++ 객체에서도 비슷한 일이 일어납니다.

#include <iostream>
#include <string>

int main() {
    std::string a = "very long string data...";
    std::string b = a;  // 복사

    std::cout << a << 'n';
    std::cout << b << 'n';
}

b = a는 복사입니다. 이때 a와 b는 모두 같은 내용을 갖습니다. 하지만 둘은 독립적인 문자열입니다.

반면 이동은 원본의 자원을 대상 객체에게 넘기는 방식입니다.

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

int main() {
    std::string a = "very long string data...";
    std::string b = std::move(a);  // 이동 가능하다고 표시

    std::cout << b << 'n';
}

여기서 std::move(a)는 a가 가진 내부 자원을 b가 가져갈 수 있도록 허락하는 표현입니다. b는 새 메모리에 문자열을 다시 복사하지 않고, 가능하다면 a가 들고 있던 내부 버퍼를 넘겨받습니다.

그렇다면 이동된 a는 어떻게 될까요? 이 점이 중요합니다.

이동된 객체는 여전히 유효한 객체입니다. 즉, 소멸되어도 괜찮고, 새 값을 대입해도 괜찮고, 멤버 함수를 호출할 수도 있습니다. 다만 그 안에 정확히 어떤 값이 남아 있는지는 일반적으로 기대하지 않는 편이 좋습니다.

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

int main() {
    std::string source = "hello";
    std::string target = std::move(source);

    source = "new value";  // 좋다. 이동 후 새 값을 넣어 다시 사용

    std::cout << target << 'n';
    std::cout << source << 'n';
}

이동 후의 객체를 "망가진 객체"라고 생각하면 안 됩니다. 더 정확히는 "비어 있을 수도 있고, 이전 값이 아닐 수도 있지만, 다시 사용할 수 있는 유효한 객체"입니다. 이동 후 객체에 대해 가장 안전한 습관은 새 값을 대입하거나 소멸되도록 두는 것입니다.

lvalue와 rvalue: 이동 가능한 값의 성격

이동 의미론을 이해하려면 lvalue와 rvalue라는 말을 만나야 합니다. 이름은 낯설지만 감각은 어렵지 않습니다.

lvalue는 이름이 있고, 한 문장이 끝난 뒤에도 계속 남아 있는 값이라고 생각하면 됩니다.

int x = 10;

여기서 x는 lvalue입니다. 이름이 있고, 다음 줄에서도 사용할 수 있습니다.

x = 20;

반면 rvalue는 보통 임시 값입니다. 표현식이 끝나면 사라지는 값입니다.

int y = 10 + 20;

10 + 20의 결과인 30은 잠깐 생겼다가 y에 저장됩니다. 이 임시 결과는 이름이 없습니다. 이런 값을 rvalue라고 부릅니다.

객체에서도 같은 감각이 적용됩니다.

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

std::string name = makeName();

makeName()이 반환하는 값은 호출 표현식의 결과로 잠깐 나타나는 임시 객체입니다. 이런 값은 대개 더 이상 다른 곳에서 쓰이지 않으므로 이동하기 좋은 후보가 됩니다.

조금 더 비교해 봅시다.

std::string a = "hello";
std::string b = a;                 // a는 lvalue, 복사
std::string c = std::string("hi"); // 임시 객체, 이동 가능

a는 이름이 있는 객체입니다. 이 객체를 복사한다고 해서 a를 망가뜨리면 안 됩니다. 그래서 기본적으로 복사가 일어납니다.

반면 std::string("hi")는 곧 사라질 임시 객체입니다. 이 객체의 내부 자원을 새 객체가 가져가도 문제가 없습니다. 그래서 이동이 가능해집니다.

C++은 이름이 있는 객체를 함부로 이동하지 않습니다. 이것은 매우 중요한 안전장치입니다.

std::string a = "hello";
std::string b = a;  // a는 이후에도 쓸 수 있어야 하므로 복사

만약 C++이 a를 자동으로 이동해 버린다면, 다음 줄에서 a를 사용하려는 코드는 예측하기 어려워질 것입니다.

std::cout << a << 'n';

그래서 이름 있는 객체를 이동하고 싶다면 프로그래머가 명시적으로 "이제 이 객체의 자원을 가져가도 된다"고 표시해야 합니다. 그 표시가 바로 std::move입니다.

std::move는 실제로 이동하지 않는다

std::move라는 이름 때문에 많은 입문자가 오해합니다. 이름만 보면 무언가를 실제로 옮겨 주는 함수처럼 보입니다. 하지만 std::move 자체는 이동을 수행하지 않습니다.

std::move는 객체를 "이동해도 되는 값처럼 취급해 달라"고 표시하는 캐스팅입니다.

#include <string>
#include <utility>

std::string a = "hello";
std::string b = std::move(a);

이 코드에서 실제 이동 작업을 하는 것은 std::move가 아니라 std::string의 이동 생성자입니다. std::move(a)는 a를 rvalue처럼 보이게 만들고, 그 결과 std::string의 이동 생성자가 호출될 수 있습니다.

비유하자면 std::move는 이삿짐센터가 아닙니다. "이 물건은 가져가도 됩니다"라고 붙여 두는 허가 스티커에 가깝습니다. 실제로 물건을 가져가는 일은 이동 생성자나 이동 대입 연산자가 합니다.

이 차이는 매우 중요합니다. 어떤 타입이 이동 생성자를 제공하지 않으면 std::move를 써도 이동 대신 복사가 일어날 수 있습니다.

const std::string a = "hello";
std::string b = std::move(a);  // 대개 이동이 아니라 복사

왜 그럴까요? a가 const이기 때문입니다. 이동은 원본 객체의 내부 상태를 바꿔야 할 수 있습니다. 예를 들어 내부 포인터를 대상 객체에게 넘긴 뒤 원본 포인터를 nullptr로 바꾸는 식입니다. 그런데 const 객체는 수정할 수 없습니다. 그래서 const 객체에 std::move를 붙여도 진짜 이동이 되기 어렵습니다.

따라서 std::move를 사용할 때는 이렇게 생각해야 합니다.

"나는 이 객체의 현재 값을 더 이상 필요로 하지 않는다. 누군가 이 객체의 자원을 가져가도 괜찮다."

이 문장을 스스로에게 말할 수 없다면 std::move를 붙이지 않는 편이 좋습니다.

이동 생성자

이제 직접 이동 생성자를 작성해 봅시다. 실제 코드에서는 std::string, std::vector, std::unique_ptr 같은 표준 라이브러리 타입이 이미 이동을 잘 지원합니다. 하지만 원리를 이해하려면 작은 클래스를 만들어 보는 것이 좋습니다.

다음은 정수 배열을 직접 관리하는 아주 단순한 클래스입니다.

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

class IntBuffer {
private:
    std::size_t size_ = 0;
    int* data_ = nullptr;

public:
    explicit IntBuffer(std::size_t size)
        : size_(size), data_(new int[size]) {
        std::cout << "생성n";
    }

    ~IntBuffer() {
        delete[] data_;
        std::cout << "소멸n";
    }

    std::size_t size() const {
        return size_;
    }
};

이 클래스는 생성자에서 new[]로 배열을 만들고, 소멸자에서 delete[]로 배열을 해제합니다. RAII의 기본 구조입니다.

하지만 이 상태에서 복사가 일어나면 문제가 생깁니다.

IntBuffer a(100);
IntBuffer b = a;  // 위험한 기본 복사

컴파일러가 만들어 주는 기본 복사 생성자는 size_와 data_ 값을 그대로 복사합니다. 그러면 a.data_와 b.data_가 같은 배열을 가리키게 됩니다. 두 객체가 소멸될 때 같은 메모리를 두 번 delete[]하려고 하므로 프로그램이 망가질 수 있습니다.

그래서 자원을 직접 소유하는 클래스는 복사를 직접 정의하거나 금지해야 합니다. 먼저 복사를 금지해 봅시다.

class IntBuffer {
private:
    std::size_t size_ = 0;
    int* data_ = nullptr;

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

    ~IntBuffer() {
        delete[] data_;
    }

    IntBuffer(const IntBuffer&) = delete;
    IntBuffer& operator=(const IntBuffer&) = delete;
};

이제 IntBuffer b = a; 같은 복사는 컴파일되지 않습니다. 안전해졌지만 한 가지 아쉬움이 남습니다. 함수에서 만들어 반환하거나, 컨테이너 안에서 옮겨 다니는 일도 어려워질 수 있습니다.

이때 필요한 것이 이동 생성자입니다.

#include <cstddef>
#include <iostream>

class IntBuffer {
private:
    std::size_t size_ = 0;
    int* data_ = nullptr;

public:
    explicit IntBuffer(std::size_t size)
        : size_(size), data_(new int[size]) {
        std::cout << "생성n";
    }

    ~IntBuffer() {
        delete[] data_;
        std::cout << "소멸n";
    }

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

    IntBuffer(IntBuffer&& other) noexcept
        : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;
        std::cout << "이동 생성n";
    }

    std::size_t size() const {
        return size_;
    }
};

이동 생성자의 매개변수는 IntBuffer&&입니다. 여기서 &&는 rvalue 참조를 뜻합니다. "곧 사라질 수 있는 IntBuffer를 받아서 그 자원을 가져오겠다"는 뜻입니다.

핵심은 이 부분입니다.

IntBuffer(IntBuffer&& other) noexcept
    : size_(other.size_), data_(other.data_) {
    other.size_ = 0;
    other.data_ = nullptr;
}

새 객체는 other의 size_와 data_를 그대로 가져옵니다. 배열을 새로 만들지 않습니다. 원소를 하나하나 복사하지도 않습니다. 내부 포인터만 넘겨받습니다.

그다음 원본 객체인 other를 비워 둡니다. 특히 other.data_ = nullptr가 중요합니다. 그래야 나중에 other의 소멸자가 호출되어도 이미 넘겨준 배열을 다시 삭제하지 않습니다. delete[] nullptr;은 안전하게 아무 일도 하지 않습니다.

이것이 이동의 핵심입니다. 자원을 복제하지 않고 소유권만 넘겨받습니다.

이동 대입 연산자

이동 생성자는 새 객체를 만들 때 사용됩니다.

IntBuffer a(100);
IntBuffer b = std::move(a);  // 이동 생성

이미 존재하는 객체에 다른 객체의 자원을 넘겨받을 때는 이동 대입 연산자가 필요합니다.

IntBuffer a(100);
IntBuffer b(200);

b = std::move(a);  // 이동 대입

b는 이미 200개짜리 배열을 가지고 있습니다. 여기에 a의 배열을 넘겨받으려면 먼저 b가 기존에 가지고 있던 배열을 정리해야 합니다. 그렇지 않으면 b가 원래 들고 있던 메모리가 새 주인을 잃고 누수됩니다.

이동 대입 연산자는 보통 다음 흐름을 따릅니다.

IntBuffer& operator=(IntBuffer&& other) noexcept {
    if (this != &other) {
        delete[] data_;

        size_ = other.size_;
        data_ = other.data_;

        other.size_ = 0;
        other.data_ = nullptr;
    }

    return *this;
}

전체 클래스에 넣으면 다음과 같습니다.

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

class IntBuffer {
private:
    std::size_t size_ = 0;
    int* data_ = nullptr;

public:
    explicit IntBuffer(std::size_t size)
        : size_(size), data_(new int[size]) {
        std::cout << "생성n";
    }

    ~IntBuffer() {
        delete[] data_;
        std::cout << "소멸n";
    }

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

    IntBuffer(IntBuffer&& other) noexcept
        : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;
        std::cout << "이동 생성n";
    }

    IntBuffer& operator=(IntBuffer&& other) noexcept {
        std::cout << "이동 대입n";

        if (this != &other) {
            delete[] data_;

            size_ = other.size_;
            data_ = other.data_;

            other.size_ = 0;
            other.data_ = nullptr;
        }

        return *this;
    }

    std::size_t size() const {
        return size_;
    }
};

int main() {
    IntBuffer a(100);
    IntBuffer b(200);

    b = std::move(a);

    std::cout << b.size() << 'n';
}

여기서 if (this != &other)는 자기 자신에게 이동 대입하는 이상한 상황을 막기 위한 방어 코드입니다.

buffer = std::move(buffer);

일반적으로 이런 코드는 쓰지 않지만, 클래스가 견고하려면 스스로를 망가뜨리지 않도록 대비하는 편이 좋습니다.

이동 생성자와 이동 대입 연산자에는 noexcept를 붙이는 경우가 많습니다. noexcept는 "이 함수는 예외를 던지지 않는다"는 약속입니다. 특히 std::vector 같은 컨테이너는 원소를 재배치할 때 이동 연산이 예외를 던지지 않는다고 판단되면 더 적극적으로 이동을 사용할 수 있습니다. 따라서 직접 자원 소유 클래스를 만들고 이동 연산이 정말 예외를 던지지 않는다면 noexcept를 붙이는 습관이 좋습니다.

Rule of Five와 Rule of Zero

자원을 직접 관리하는 클래스는 특별 멤버 함수들을 신경 써야 합니다. 앞에서 본 IntBuffer처럼 new[]와 delete[]를 직접 다루면 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자, 소멸자의 관계를 고려해야 합니다.

이 다섯 가지를 함께 생각해야 한다는 규칙을 흔히 Rule of Five라고 부릅니다.

class ResourceOwner {
public:
    ~ResourceOwner();                              // 소멸자
    ResourceOwner(const ResourceOwner&);           // 복사 생성자
    ResourceOwner& operator=(const ResourceOwner&);// 복사 대입
    ResourceOwner(ResourceOwner&&) noexcept;       // 이동 생성자
    ResourceOwner& operator=(ResourceOwner&&) noexcept; // 이동 대입
};

하지만 현대 C++에서 더 권장되는 방향은 Rule of Zero입니다. 직접 new와 delete를 관리하는 클래스를 줄이고, 이미 자원 관리를 잘하는 타입을 멤버로 사용하는 방식입니다.

예를 들어 직접 포인터를 들고 있는 대신 std::vector<int>를 쓰면 어떨까요?

#include <vector>

class IntBuffer {
private:
    std::vector<int> data_;

public:
    explicit IntBuffer(std::size_t size)
        : data_(size) {}

    std::size_t size() const {
        return data_.size();
    }
};

이 클래스에는 소멸자도, 복사 생성자도, 이동 생성자도 직접 작성하지 않았습니다. 그래도 잘 동작합니다. std::vector가 내부 메모리를 알아서 관리하고, 복사와 이동도 올바르게 처리하기 때문입니다.

직접 자원을 다루는 코드는 필요한 곳에만 두고, 대부분의 클래스는 표준 라이브러리 타입을 멤버로 삼아 자연스럽게 복사와 이동을 얻는 편이 좋습니다. 이것이 현대 C++다운 설계입니다.

스마트 포인터 장에서 배운 std::unique_ptr도 같은 흐름입니다.

#include <memory>

std::unique_ptr<int> p1 = std::make_unique<int>(10);
std::unique_ptr<int> p2 = std::move(p1);

std::unique_ptr은 복사할 수 없습니다. 하나의 자원에는 하나의 소유자만 있어야 하기 때문입니다. 대신 이동은 가능합니다. p1이 소유하던 정수를 p2가 넘겨받고, p1은 비어 있는 상태가 됩니다.

이동 의미론은 스마트 포인터의 안전한 소유권 이전을 가능하게 해 주는 언어적 기반입니다.

vector 재할당과 이동

이동 의미론이 실제로 빛나는 대표적인 장소가 std::vector입니다.

vector는 내부적으로 연속된 배열을 사용합니다. 원소를 계속 추가하다가 기존 공간이 부족해지면 더 큰 공간을 새로 확보하고, 기존 원소들을 새 공간으로 옮겨야 합니다.

#include <vector>
#include <string>

int main() {
    std::vector<std::string> names;

    names.push_back("Alice");
    names.push_back("Bob");
    names.push_back("Charlie");
}

공간이 부족해져 재할당이 일어나면 기존 문자열 원소들을 새 메모리 영역으로 옮겨야 합니다. 이동 의미론이 없다면 각 문자열의 내용을 새로 복사해야 합니다. 문자열이 길다면 비용이 커집니다.

하지만 이동이 가능하면 vector는 원소들이 가진 내부 버퍼를 새 위치로 넘겨받을 수 있습니다. 문자열 내용을 모두 다시 복사하는 대신 내부 자원의 소유권을 옮기는 식으로 비용을 줄일 수 있습니다.

직접 만든 타입에서도 같은 일이 일어납니다.

#include <iostream>
#include <vector>

class Item {
public:
    Item() {
        std::cout << "생성n";
    }

    Item(const Item&) {
        std::cout << "복사n";
    }

    Item(Item&&) noexcept {
        std::cout << "이동n";
    }
};

int main() {
    std::vector<Item> items;
    items.reserve(2);

    items.push_back(Item{});
    items.push_back(Item{});
    items.push_back(Item{});
}

reserve(2)로 처음에는 두 개를 담을 공간만 준비했습니다. 세 번째 원소를 추가하면 더 큰 공간이 필요해집니다. 이때 기존 원소들을 새 공간으로 옮기는 과정에서 이동 생성자가 호출될 수 있습니다.

이 예제에서 Item(Item&&) noexcept의 noexcept를 빼면 구현이나 상황에 따라 vector가 이동 대신 복사를 선택할 수 있습니다. 이동 중 예외가 발생하면 컨테이너의 상태를 안전하게 유지하기 어려울 수 있기 때문입니다. 그래서 표준 컨테이너에 담길 타입의 이동 연산은 가능하면 예외를 던지지 않게 만들고 noexcept를 붙이는 것이 좋습니다.

push_back과 함께 자주 쓰는 함수로 emplace_back도 있습니다.

#include <string>
#include <vector>

int main() {
    std::vector<std::string> names;

    names.push_back(std::string("Alice"));
    names.emplace_back("Bob");
}

push_back은 이미 만들어진 객체를 벡터에 넣습니다. 이 과정에서 복사나 이동이 일어날 수 있습니다. emplace_back은 벡터 내부에서 객체를 직접 생성합니다. 그래서 불필요한 임시 객체를 줄일 수 있습니다.

다만 emplace_back이 언제나 push_back보다 무조건 좋은 것은 아닙니다. 간단한 타입에서는 차이가 거의 없고, 코드의 의도가 push_back이 더 명확할 때도 있습니다. 중요한 것은 원리를 아는 것입니다. 큰 객체를 컨테이너에 넣고, 컨테이너가 재할당될 때 이동 의미론은 불필요한 복사 비용을 줄여 줍니다.

함수 반환과 이동

입문자가 이동 의미론을 배우면 가장 먼저 이런 생각을 합니다.

"함수에서 큰 객체를 반환하면 복사가 일어나니까 위험하지 않을까?"

현대 C++에서는 너무 걱정하지 않아도 됩니다.

#include <vector>

std::vector<int> makeNumbers() {
    std::vector<int> result;
    result.push_back(1);
    result.push_back(2);
    result.push_back(3);
    return result;
}

int main() {
    std::vector<int> numbers = makeNumbers();
}

이 코드는 C++다운 좋은 코드입니다. std::vector<int>를 값으로 반환하고 있지만, 대개 불필요한 복사는 일어나지 않습니다.

컴파일러는 반환값 최적화, 즉 RVO 또는 NRVO라고 부르는 최적화를 적용할 수 있습니다. 쉽게 말해 함수 안의 result를 만든 뒤 다시 호출자 쪽으로 복사하는 것이 아니라, 처음부터 호출자 쪽 결과 객체 자리에 직접 만들어 버리는 방식입니다.

또한 최적화가 적용되지 않는 상황에서도 이동 생성자를 사용할 수 있다면 복사 대신 이동이 일어날 수 있습니다.

여기서 중요한 습관이 하나 있습니다. 지역 변수를 반환할 때 습관적으로 std::move를 붙이지 마세요.

std::vector<int> makeNumbers() {
    std::vector<int> result;
    result.push_back(1);
    result.push_back(2);
    result.push_back(3);

    return std::move(result);  // 권장하지 않음
}

이 코드는 그럴듯해 보입니다. "이동하면 빠르니까 std::move를 붙이자"라고 생각하기 쉽습니다. 하지만 오히려 컴파일러의 반환값 최적화를 방해할 수 있습니다.

대부분의 경우에는 이렇게 쓰는 것이 좋습니다.

std::vector<int> makeNumbers() {
    std::vector<int> result;
    result.push_back(1);
    result.push_back(2);
    result.push_back(3);

    return result;  // 좋다
}

C++에서는 값으로 반환하는 코드가 생각보다 효율적입니다. 이것은 현대 C++의 중요한 감각입니다. 큰 객체를 무조건 포인터로 반환하거나, 출력 매개변수로 억지로 넘기지 않아도 됩니다.

void makeNumbers(std::vector<int>& out) {
    out.clear();
    out.push_back(1);
    out.push_back(2);
    out.push_back(3);
}

이런 방식이 필요한 경우도 있지만, 단순히 "복사가 무서워서"라면 값 반환을 먼저 고려해도 됩니다. 값으로 주고받되, 컴파일러 최적화와 이동 의미론을 믿는 것이 현대 C++ 스타일에 가깝습니다.

std::move를 써야 하는 순간

그렇다면 std::move는 언제 써야 할까요? 가장 분명한 경우는 이름 있는 객체의 자원을 다른 곳으로 넘기고, 그 객체의 기존 값이 더 이상 필요 없을 때입니다.

#include <memory>
#include <vector>

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

    auto p = std::make_unique<int>(42);
    numbers.push_back(std::move(p));
}

p는 이름이 있으므로 lvalue입니다. std::unique_ptr은 복사할 수 없기 때문에 그냥 push_back(p)를 하면 컴파일되지 않습니다.

numbers.push_back(p);  // 오류: unique_ptr은 복사 불가

이때 std::move(p)를 사용하면 "이 포인터의 소유권을 벡터에게 넘겨도 된다"고 표시합니다. 이후 p는 비어 있는 상태가 됩니다.

if (!p) {
    // p는 더 이상 객체를 소유하지 않는다
}

또 다른 예로, 클래스의 생성자에서 외부에서 받은 큰 값을 멤버로 저장할 때 std::move를 사용할 수 있습니다.

#include <string>
#include <utility>

class User {
private:
    std::string name_;

public:
    explicit User(std::string name)
        : name_(std::move(name)) {}

    const std::string& name() const {
        return name_;
    }
};

이 생성자는 매개변수를 값으로 받습니다. 호출자가 lvalue를 넘기면 한 번 복사되어 name이 만들어지고, 그 name을 멤버 name_으로 이동합니다. 호출자가 임시 문자열을 넘기면 이동 중심으로 처리될 수 있습니다.

std::string nickname = "Alice";
User u1(nickname);        // nickname은 유지됨
User u2(std::string("Bob"));
User u3("Charlie");

이 패턴은 간결하고 실용적입니다. 특히 멤버로 저장할 값이 std::string, std::vector처럼 이동 가능한 타입일 때 자주 사용합니다.

다만 모든 곳에 std::move를 붙이면 좋은 것은 아닙니다.

std::string name = "Alice";
printName(std::move(name));
std::cout << name << 'n';  // name의 이전 값을 기대하면 안 됨

std::move를 붙인 뒤에는 그 객체의 기존 값을 더 이상 믿지 않는다는 마음가짐이 필요합니다. 이동은 성능 기법이기도 하지만, 동시에 소유권과 생명주기를 표현하는 도구입니다.

이동만 가능한 타입 설계하기

스마트 포인터를 떠올려 봅시다. std::unique_ptr은 복사할 수 없고 이동만 가능합니다. 이런 타입은 생각보다 많습니다. 파일 핸들, 네트워크 소켓, 스레드, 뮤텍스 락처럼 "하나의 자원에 하나의 주인만 있어야 하는" 객체는 복사를 막고 이동만 허용하는 편이 안전합니다.

간단한 파일 핸들 클래스를 상상해 봅시다.

#include <cstdio>
#include <utility>

class FileHandle {
private:
    std::FILE* file_ = nullptr;

public:
    explicit FileHandle(const char* path)
        : file_(std::fopen(path, "r")) {}

    ~FileHandle() {
        if (file_) {
            std::fclose(file_);
        }
    }

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

    FileHandle(FileHandle&& other) noexcept
        : file_(other.file_) {
        other.file_ = nullptr;
    }

    FileHandle& operator=(FileHandle&& other) noexcept {
        if (this != &other) {
            if (file_) {
                std::fclose(file_);
            }

            file_ = other.file_;
            other.file_ = nullptr;
        }

        return *this;
    }

    bool isOpen() const {
        return file_ != nullptr;
    }
};

이 클래스는 파일을 복사하지 않습니다. 파일 핸들을 두 객체가 동시에 닫으려고 하면 위험하기 때문입니다. 대신 이동은 허용합니다. 파일을 관리하는 책임을 한 객체에서 다른 객체로 넘길 수 있습니다.

FileHandle f1("data.txt");
FileHandle f2 = std::move(f1);

이후 파일을 닫을 책임은 f2에게 있습니다. f1은 더 이상 파일을 소유하지 않습니다. 이것이 이동 의미론이 안전성과 연결되는 지점입니다.

단순히 빠르게 만들기 위해 이동을 쓰는 것이 아닙니다. "누가 이 자원을 책임지는가?"를 코드로 분명하게 표현하기 위해 이동을 씁니다.

lvalue 참조, rvalue 참조, const 참조

함수 매개변수에서 이동 의미론을 만나면 처음에는 헷갈릴 수 있습니다.

void f(const std::string& s);
void g(std::string& s);
void h(std::string&& s);

세 함수는 각각 다른 뜻을 가집니다.

const std::string&는 읽기 전용으로 받겠다는 뜻입니다. lvalue도 받을 수 있고 임시 객체도 받을 수 있습니다. 복사가 없고, 함수 안에서 수정하지 않습니다.

void print(const std::string& s) {
    std::cout << s << 'n';
}

단순히 읽기만 한다면 대개 const&가 좋습니다.

std::string&는 수정 가능한 lvalue 참조입니다. 이름 있는 객체를 받아 함수 안에서 바꾸겠다는 뜻입니다.

void addSuffix(std::string& s) {
    s += "_suffix";
}

std::string&&는 rvalue 참조입니다. 임시 객체나 std::move된 객체를 받을 수 있습니다. 함수가 그 객체의 자원을 가져갈 수 있다는 뜻을 표현합니다.

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

class NameList {
private:
    std::vector<std::string> names_;

public:
    void add(std::string name) {
        names_.push_back(std::move(name));
    }
};

이 예제에서는 아예 값을 받아서 멤버 컨테이너로 이동했습니다. 입문 단계에서는 T&&를 직접 많이 설계하기보다, 값으로 받은 뒤 멤버에 std::move하는 패턴을 먼저 익히는 것이 좋습니다.

물론 뒤에서 템플릿과 완벽 전달을 배우면 T&&가 또 다른 의미로 확장됩니다. 지금은 "구체적인 타입의 &&는 이동 가능한 값을 받는 참조" 정도로 이해하면 충분합니다.

이동 의미론이 만드는 코드 스타일

이동 의미론이 도입되면서 C++ 코드는 더 자연스럽게 값을 주고받을 수 있게 되었습니다. 예전에는 큰 객체를 다룰 때 복사를 피하려고 포인터를 많이 사용했습니다.

void loadData(std::vector<int>* out);

또는 호출자가 미리 객체를 만들고 함수가 그 안을 채우는 방식을 자주 사용했습니다.

void loadData(std::vector<int>& out);

이런 방식이 나쁜 것은 아닙니다. 하지만 함수의 의미가 흐려질 수 있습니다. 함수가 데이터를 "만들어 반환"하는 것인지, 기존 데이터를 "수정"하는 것인지 한눈에 들어오지 않을 때가 있습니다.

현대 C++에서는 다음 코드가 충분히 좋은 선택입니다.

std::vector<int> loadData() {
    std::vector<int> data;

    // 파일이나 네트워크에서 데이터를 읽었다고 가정
    data.push_back(10);
    data.push_back(20);
    data.push_back(30);

    return data;
}

값으로 반환하면 함수의 의도가 분명합니다. loadData는 데이터를 만들어 돌려줍니다. 호출자는 그 값을 받습니다.

std::vector<int> data = loadData();

이 코드가 가능한 이유는 컴파일러 최적화와 이동 의미론이 뒤에서 비용을 줄여 주기 때문입니다. 그래서 이동 의미론은 단순한 성능 기술을 넘어 API 설계 방식까지 바꿉니다.

객체를 값처럼 다루면서도, 내부에서는 불필요한 복사를 줄인다. 이것이 현대 C++의 큰 장점 중 하나입니다.

이동을 사용할 때의 주의점

이동 의미론은 강력하지만, 아무 데나 붙이는 마법 가루는 아닙니다. 특히 std::move를 남용하면 오히려 코드가 위험해집니다.

std::string name = "Alice";
std::string copied = std::move(name);

std::cout << name << 'n';  // 이전 값 Alice를 기대하지 말 것

이동 후 객체는 유효하지만, 그 값에 기대면 안 됩니다. 이 원칙만 지켜도 많은 실수를 피할 수 있습니다.

또 하나의 실수는 const 객체에 std::move를 붙이는 것입니다.

const std::string name = "Alice";
std::string other = std::move(name);  // 이동처럼 보이지만 복사될 가능성이 큼

const 객체는 내부 상태를 바꿀 수 없으므로 이동의 장점이 사라집니다. std::move가 있다고 해서 반드시 이동이 일어나는 것이 아니라는 사실을 다시 떠올려야 합니다.

지역 변수를 반환할 때 std::move를 붙이는 습관도 조심해야 합니다.

std::string makeName() {
    std::string name = "Alice";
    return std::move(name);  // 대개 불필요하거나 방해가 됨
}

이 경우에는 단순히 return name;이라고 쓰는 편이 좋습니다.

마지막으로, 이동된 객체를 다시 사용할 때는 새 값을 넣은 뒤 사용하는 습관이 안전합니다.

std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a);

a = {4, 5, 6};  // 새 값을 대입한 뒤 다시 사용

이동 의미론을 잘 쓰는 코드는 빠르기만 한 코드가 아닙니다. 객체의 소유권이 어디에 있는지 분명하고, 이동 후 상태를 함부로 가정하지 않는 코드입니다.

작은 실험: 복사와 이동 출력해 보기

직접 눈으로 확인해 보면 감각이 더 빨리 잡힙니다. 다음 코드를 실행해 봅시다.

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

class Trace {
public:
    Trace() {
        std::cout << "기본 생성n";
    }

    Trace(const Trace&) {
        std::cout << "복사 생성n";
    }

    Trace(Trace&&) noexcept {
        std::cout << "이동 생성n";
    }

    Trace& operator=(const Trace&) {
        std::cout << "복사 대입n";
        return *this;
    }

    Trace& operator=(Trace&&) noexcept {
        std::cout << "이동 대입n";
        return *this;
    }
};

Trace makeTrace() {
    Trace t;
    return t;
}

int main() {
    std::cout << "--- 복사 ---n";
    Trace a;
    Trace b = a;

    std::cout << "--- 이동 ---n";
    Trace c = std::move(a);

    std::cout << "--- 반환 ---n";
    Trace d = makeTrace();

    std::cout << "--- vector ---n";
    std::vector<Trace> v;
    v.reserve(1);
    v.push_back(Trace{});
    v.push_back(Trace{});
}

실행 결과는 컴파일러와 최적화 옵션에 따라 조금 달라질 수 있습니다. 특히 함수 반환 부분은 반환값 최적화 때문에 이동 생성 출력이 보이지 않을 수도 있습니다. 이것은 이상한 일이 아니라 좋은 일입니다. 컴파일러가 아예 복사나 이동 자체를 없애 버렸다는 뜻이기 때문입니다.

이런 실험을 해 보면 C++에서 "코드에 보이는 것"과 "실제로 일어나는 일" 사이에 컴파일러 최적화가 끼어 있다는 사실도 함께 느낄 수 있습니다. 현대 C++을 잘 쓰려면 컴파일러가 잘 최적화할 수 있는 단순하고 명확한 코드를 작성하는 것이 중요합니다.

성능과 안전성을 함께 얻는 법

이동 의미론은 C++을 한 단계 현대적으로 만든 핵심 기능입니다. 큰 객체를 값처럼 다루면서도 불필요한 복사를 줄일 수 있고, 복사할 수 없는 자원의 소유권도 안전하게 넘길 수 있습니다.

이 장에서 기억해야 할 핵심은 명확합니다.

복사는 원본과 사본을 모두 독립적으로 유지합니다. 안전하지만 큰 자원을 가진 객체에서는 비용이 클 수 있습니다. 이동은 곧 사라지거나 더 이상 필요 없는 객체의 자원을 새 객체가 넘겨받습니다. 비용을 줄일 수 있지만, 이동된 객체의 이전 값에 기대면 안 됩니다.

std::move는 실제 이동을 수행하는 함수가 아니라 이동 가능하다고 표시하는 캐스팅입니다. 실제 이동은 이동 생성자와 이동 대입 연산자가 담당합니다. 그래서 std::move를 붙이기 전에는 항상 "이 객체의 현재 값을 포기해도 되는가?"를 생각해야 합니다.

std::vector, std::string, std::unique_ptr 같은 표준 라이브러리 타입은 이미 이동 의미론을 적극적으로 활용합니다. 덕분에 우리는 값을 반환하고, 컨테이너에 객체를 넣고, 소유권을 넘기는 코드를 훨씬 자연스럽게 작성할 수 있습니다.

현대 C++의 좋은 코드는 무조건 포인터를 피하지도 않고, 무조건 복사를 두려워하지도 않습니다. 값으로 표현할 수 있는 것은 값으로 표현하고, 소유권이 필요한 곳에서는 이동으로 넘기며, 자원 관리는 RAII와 스마트 포인터에 맡깁니다.

이제 우리는 객체가 만들어지고, 복사되고, 이동되고, 소멸되는 흐름을 훨씬 더 정확히 볼 수 있게 되었습니다. 다음 단계에서는 같은 코드를 여러 타입에 대해 재사용하는 방법, 즉 템플릿과 제네릭 프로그래밍의 세계로 들어갑니다. C++의 또 다른 강력한 얼굴이 기다리고 있습니다.

책 목록

처음 만나는 C++: 객체지향에서 현대 C++까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기