본문으로 건너뛰기 C++ Move Constructor | '이동 생성자' 가이드

C++ Move Constructor | '이동 생성자' 가이드

C++ Move Constructor | '이동 생성자' 가이드

이 글의 핵심

C++ Move Constructor: "이동 생성자" 가이드. 이동 생성자란?·복사 vs 이동.

이동 생성자란?

이동 생성자는 임시 객체(rvalue)나 std::move로 캐스팅된 객체로부터 자원의 소유권을 통째로 넘겨받는 생성자입니다. 복사 생성자처럼 힙 메모리를 새로 할당하고 내용을 하나씩 복제하는 대신, 원본이 들고 있던 포인터나 핸들을 그대로 가져오고 원본은 비어 있는 상태로 만듭니다. 이렇게 하면 임시 객체가 곧 소멸될 것이 확실한 상황에서 불필요한 deep copy가 일어나는 것을 막을 수 있어, 큰 버퍼나 컨테이너를 함수에서 반환하거나 벡터에 담을 때 성능 차이가 두드러집니다. 아래 Buffer 클래스는 원본의 data 포인터를 새 객체로 옮긴 뒤 원본을 nullptr로 되돌려, 두 객체의 소멸자가 각각 호출되더라도 같은 메모리를 이중으로 해제하는 사고가 발생하지 않도록 설계되어 있습니다.

class Buffer {
    int* data;
    size_t size;
    
public:
    // 이동 생성자
    Buffer(Buffer&& other) noexcept 
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }
};

복사 vs 이동

복사 생성자와 이동 생성자는 시그니처만 보면 비슷해 보이지만 자원을 다루는 방식이 근본적으로 다릅니다. 복사는 원본을 그대로 유지한 채 새 메모리를 할당하고 값을 하나하나 복제하므로, 작업이 끝나면 원본과 사본이 완전히 독립된 자원을 각자 소유하게 됩니다. 반면 이동은 원본이 갖고 있던 포인터를 그대로 넘겨받고 원본의 포인터만 비워두는 방식이라 추가 할당이나 데이터 복제 비용이 전혀 들지 않습니다. 아래 예시에서 복사 버전은 newstd::copy로 힙 메모리를 새로 만드는 반면, 이동 버전은 포인터 대입 한 줄로 끝나는 것을 확인할 수 있습니다. 이 비용 차이 때문에 임시 객체를 다룰 때는 컴파일러가 가능한 한 이동 생성자를 선택하도록 코드를 작성하는 것이 성능에 유리합니다.

// 복사: 자원 복제
Buffer(const Buffer& other) {
    data = new int[other.size];
    std::copy(other.data, other.data + size, data);
}

// 이동: 자원 이전
Buffer(Buffer&& other) noexcept {
    data = other.data;
    other.data = nullptr;
}

실전 예시

이론만으로는 이동 생성자가 언제 호출되고 언제 복사가 일어나는지 감을 잡기 어렵습니다. 아래 네 가지 예시는 직접 만든 클래스, 표준 컨테이너, 스마트 포인터, 함수 반환값이라는 서로 다른 상황에서 복사와 이동이 어떻게 선택되는지 직접 로그로 확인할 수 있도록 구성했습니다.

예시 1: 기본 구현

String 클래스는 복사 생성자와 이동 생성자에 각각 로그를 남겨, 어떤 대입 방식이 어떤 연산을 유발하는지 눈으로 확인할 수 있게 했습니다. s2 = s1처럼 lvalue를 그대로 대입하면 복사 생성자가 호출되어 strcpy로 문자열 전체를 다시 복사하지만, s3 = std::move(s1)처럼 std::move로 rvalue로 캐스팅해서 넘기면 이동 생성자가 선택되어 포인터만 옮겨지고 s1.datanullptr로 초기화됩니다. 이 예시에서 눈여겨봐야 할 점은 이동 이후 s1이 파괴되어도 s1.datanullptr이라 delete[]가 안전하게 아무 일도 하지 않는다는 것입니다.

class String {
    char* data;
    size_t length;
    
public:
    String(const char* str) {
        length = strlen(str);
        data = new char[length + 1];
        strcpy(data, str);
    }
    
    ~String() {
        delete[] data;
    }
    
    // 복사 생성자
    String(const String& other) : length(other.length) {
        data = new char[length + 1];
        strcpy(data, other.data);
        std::cout << "복사" << std::endl;
    }
    
    // 이동 생성자
    String(String&& other) noexcept 
        : data(other.data), length(other.length) {
        other.data = nullptr;
        other.length = 0;
        std::cout << "이동" << std::endl;
    }
};

int main() {
    String s1("Hello");
    String s2 = s1;              // 복사
    String s3 = std::move(s1);   // 이동
}

예시 2: 벡터 최적화

std::vector는 요소를 추가할 때 복사와 이동 중 어느 쪽을 쓸지 상황에 따라 자동으로 선택합니다. vec.push_back(w)처럼 이미 존재하는 lvalue w를 넘기면 컨테이너 안에 별도의 사본을 만들어야 하므로 복사 생성자가 호출되지만, vec.push_back(std::move(w))처럼 더 이상 쓰지 않을 것을 명시하면 이동 생성자가 호출되어 내부 자원만 옮겨집니다. vec.emplace_back()은 아예 벡터 내부의 메모리 위치에서 직접 객체를 생성하므로 복사도 이동도 일어나지 않고 기본 생성자만 호출됩니다. 이 세 가지 호출 방식의 차이를 이해하고 있으면, 값비싼 객체를 대량으로 컨테이너에 채워 넣을 때 불필요한 복사를 얼마든지 걷어낼 수 있습니다.

#include <vector>

class Widget {
public:
    Widget() { std::cout << "생성" << std::endl; }
    Widget(const Widget&) { std::cout << "복사" << std::endl; }
    Widget(Widget&&) noexcept { std::cout << "이동" << std::endl; }
};

int main() {
    std::vector<Widget> vec;
    vec.reserve(10);
    
    Widget w;
    vec.push_back(w);              // 복사
    vec.push_back(std::move(w));   // 이동
    vec.emplace_back();            // 생성
}

예시 3: 스마트 포인터

std::unique_ptr는 하나의 자원을 오직 하나의 소유자만 갖도록 설계된 스마트 포인터라서 복사 생성자 자체가 = delete로 막혀 있고, 이동만 허용됩니다. 그래서 ptr2 = ptr1처럼 그냥 대입하려 하면 컴파일 에러가 나고, 소유권을 넘기려면 반드시 std::move(ptr1)을 통해 명시적으로 이동시켜야 합니다. 이동이 끝나면 ptr1은 아무것도 가리키지 않는 빈 상태(nullptr)가 되고, ptr2가 유일한 소유자로서 자원의 수명을 책임지게 됩니다. 이런 이동 전용 설계 덕분에 unique_ptr는 참조 카운팅 없이도 이중 해제나 소유권 충돌을 컴파일 타임에 방지할 수 있습니다.

#include <memory>

class Resource {
public:
    Resource() { std::cout << "Resource 생성" << std::endl; }
    ~Resource() { std::cout << "Resource 소멸" << std::endl; }
};

int main() {
    auto ptr1 = std::make_unique<Resource>();
    
    // 이동만 가능 (복사 불가)
    auto ptr2 = std::move(ptr1);
    // ptr1은 nullptr
}

예시 4: 반환값 최적화

함수가 지역 객체를 값으로 반환할 때는 이동 생성자조차 호출되지 않는 경우가 많습니다. 최신 컴파일러는 반환값 최적화(RVO, Return Value Optimization)를 적용해 createBuffer 내부의 b를 호출부의 b 변수 자리에 바로 생성해 버리기 때문에, 복사도 이동도 없이 객체 하나만 만들어집니다. RVO가 적용되지 않는 예외적인 상황(예: 조건에 따라 서로 다른 지역 변수를 반환하는 경우)에서도 컴파일러는 암묵적으로 이동 생성자를 우선 고려하므로, 개발자가 직접 std::move를 붙일 필요가 없습니다. 아래 코드처럼 Buffer b(size); return b;라고만 작성해도 대부분의 경우 가장 효율적인 경로가 자동으로 선택됩니다.

Buffer createBuffer(size_t size) {
    Buffer b(size);
    return b;  // 이동 또는 RVO
}

int main() {
    Buffer b = createBuffer(100);
}

noexcept 중요성

std::vector가 용량이 부족해서 재할당해야 할 때는 기존 요소들을 새 메모리로 옮겨야 하는데, 이 과정에서 예외가 발생하면 일부 요소만 옮겨진 채로 중단될 위험이 있습니다. 이런 상황을 막기 위해 표준 라이브러리는 “강한 예외 안전성(strong exception safety)“을 지키기 위한 원칙으로, 이동 생성자가 noexcept로 선언되어 있을 때만 재할당 과정에서 이동을 사용하고, 그렇지 않으면 안전하게 복사를 사용합니다. 즉 이동 생성자를 아무리 잘 구현해도 noexcept를 빠뜨리면 컴파일러가 “혹시 예외를 던질지도 모른다”고 보수적으로 판단해 복사 경로로 되돌아가 버립니다. 그래서 자원 이동만 수행하고 예외를 던질 가능성이 없는 이동 생성자에는 반드시 noexcept를 붙여야 실제 성능 이득을 볼 수 있습니다.

// ❌ noexcept 없음
Buffer(Buffer&& other) {
    // std::vector 재할당 시 복사 사용
}

// ✅ noexcept 추가
Buffer(Buffer&& other) noexcept {
    // std::vector 재할당 시 이동 사용
}

자주 발생하는 문제

이동 생성자는 얼핏 단순해 보이지만, 실제 코드에서는 몇 가지 패턴을 놓쳐서 미묘한 버그로 이어지는 경우가 많습니다. 아래 네 가지는 리뷰 과정에서 특히 자주 눈에 띄는 실수들입니다.

문제 1: 이동 후 상태

이동 생성자를 작성할 때 가장 흔히 저지르는 실수는 원본 객체의 포인터를 옮긴 뒤 원본 쪽을 정리하지 않는 것입니다. other.data를 새 객체로 대입만 하고 other.datanullptr로 되돌리지 않으면, 두 객체가 동시에 같은 메모리를 가리키게 되어 둘 중 하나라도 소멸자가 호출되는 순간 이미 해제된 메모리를 다시 delete하려는 이중 해제 버그가 발생합니다. 이는 대부분 즉시 크래시로 드러나지 않고 힙이 손상된 채로 한참 뒤에 다른 곳에서 터지기 때문에 디버깅이 특히 까다롭습니다. 그래서 이동 생성자를 작성할 때는 항상 “자원을 넘겨받은 뒤 원본을 비운다”는 두 단계를 한 세트로 기억해야 합니다.

// ❌ 이동 후 유효하지 않은 상태
Buffer(Buffer&& other) noexcept {
    data = other.data;
    // other.data는 여전히 유효 (위험)
}

// ✅ nullptr 설정
Buffer(Buffer&& other) noexcept {
    data = other.data;
    other.data = nullptr;
}

문제 2: 자기 이동

이동 대입 연산자에서는 a = std::move(a)처럼 자기 자신을 자기 자신에게 이동시키는, 드물지만 실제로 일어날 수 있는 상황을 반드시 고려해야 합니다. 자기 이동 검사 없이 곧바로 delete[] data부터 실행해 버리면, 그 직후 other.data(즉 자기 자신의 data)를 읽으려는 순간 이미 해제된 메모리에 접근하게 되어 정의되지 않은 동작으로 이어집니다. 이 문제는 코드 리뷰에서 놓치기 쉬운데, 컨테이너 알고리즘 내부에서 동일한 원소를 자기 자신에게 대입하는 경우처럼 간접적으로 발생할 수도 있기 때문입니다. 그래서 이동 대입 연산자에는 복사 대입 연산자와 마찬가지로 this != &other 검사를 넣어 자기 이동을 조기에 걸러내는 것이 안전합니다.

// ❌ 자기 이동 미처리
Buffer& operator=(Buffer&& other) noexcept {
    delete[] data;
    data = other.data;
    other.data = nullptr;
    return *this;
}

// ✅ 자기 이동 체크
Buffer& operator=(Buffer&& other) noexcept {
    if (this != &other) {
        delete[] data;
        data = other.data;
        other.data = nullptr;
    }
    return *this;
}

문제 3: 예외 안전성

이동 생성자 안에서 메모리를 새로 할당하거나 예외를 던질 수 있는 연산을 호출하면, 앞서 설명한 noexcept의 이점을 스스로 무너뜨리게 됩니다. 이동 생성자는 원래 포인터나 핸들만 옮기는 가벼운 연산이어야 하는데, 중간에 예외가 발생할 여지가 있는 코드가 섞여 있으면 noexcept를 붙이는 것 자체가 거짓 보장이 되어 예외 발생 시 std::terminate가 호출되는 심각한 결과로 이어질 수 있습니다. 따라서 이동 생성자의 본문은 최대한 단순하게, 포인터 대입과 원본 초기화 정도로만 구성하고, 예외를 던질 수 있는 연산은 애초에 포함시키지 않는 것이 원칙입니다. 만약 이동 과정에서 불가피하게 실패할 수 있는 연산이 필요하다면, 그 부분은 이동 생성자 밖에서 미리 처리하도록 설계를 바꾸는 것이 낫습니다.

// ❌ 예외 발생 가능
Buffer(Buffer&& other) {
    // 예외 발생 시 문제
}

// ✅ noexcept
Buffer(Buffer&& other) noexcept {
    // 예외 없음 보장
}

문제 4: 반환값에 std::move

지역 변수를 반환할 때 습관적으로 return std::move(b);라고 쓰는 경우를 종종 보는데, 이는 오히려 성능을 해치는 안티 패턴입니다. std::move를 붙이면 컴파일러 입장에서는 값이 아니라 rvalue 참조를 반환하는 것으로 취급되어, 앞서 설명한 반환값 최적화(RVO)를 적용할 수 없게 되고 대신 이동 생성자 호출로 강등됩니다. RVO는 아예 복사도 이동도 없이 객체를 호출부에 직접 생성하는 반면, 이동 생성자 호출은 (아무리 가볍더라도) 여전히 함수 호출 오버헤드가 남기 때문에 RVO보다 느립니다. 그래서 지역 변수를 그대로 반환할 때는 std::move를 붙이지 말고 return b;처럼 값 자체를 그대로 반환하는 것이 컴파일러에게 최적화 기회를 최대한 열어주는 방법입니다.

// ❌ RVO 방해
Buffer func() {
    Buffer b(100);
    return std::move(b);
}

// ✅ 그냥 반환
Buffer func() {
    Buffer b(100);
    return b;
}

이동 가능 타입

모든 타입이 이동에서 이득을 보는 것은 아닙니다. std::vector, std::string, std::unique_ptr처럼 힙에 별도로 할당한 자원을 포인터로 관리하는 타입은 이동 시 포인터 몇 개만 옮기면 되므로 복사 대비 비용을 크게 줄일 수 있습니다. 반면 intdouble 같은 내장 산술 타입은 애초에 내부에 힙 자원이 없고 값 자체가 몇 바이트에 불과하기 때문에, 이동이라는 개념이 사실상 복사와 동일하게 동작합니다. 그래서 이런 작은 값 타입들을 담은 클래스에서는 이동 생성자를 따로 최적화해도 실질적인 성능 차이가 거의 없으며, 이동 최적화는 내부에 포인터나 핸들, 대형 버퍼를 소유한 타입에 집중하는 것이 효율적입니다.

// 이동 생성자 있음
std::vector<int>
std::string
std::unique_ptr<int>

// 이동 생성자 없음 (복사)
int
double

FAQ

Q1: 이동 생성자는 언제?

A: rvalue에서 객체 생성 시.

Q2: noexcept 필수?

A:

  • 필수 아님
  • 성능 최적화 (std::vector)

Q3: 이동 후 상태는?

A: 유효하지만 불확실. nullptr 설정 권장.

Q4: std::move는?

A: lvalue를 rvalue로 캐스팅.

Q5: 성능 향상?

A: 복사 대신 포인터 이동. 큰 객체에서 효과적.

Q6: 이동 생성자 학습 리소스는?

A:

  • “Effective Modern C++”
  • “C++ Move Semantics”
  • cppreference.com

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

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

관련 글


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

C++, move-constructor, move-semantics, C++11, performance 등으로 검색하시면 이 글이 도움이 됩니다.