C++ use-after-move 버그: std::move가 캐스팅일 뿐인 이유와 이동 의미론 실수 10가지

이 글의 핵심

std::move를 호출하는 순간 객체가 옮겨진다고 오해하면, 이동 후 비어 있는 객체를 다시 읽어 크래시나 잘못된 값이 생깁니다. 이동된 객체는 유효하지만 값이 정해지지 않은 상태라 다시 대입하기 전에는 쓰지 않아야 하고, const 객체는 이동 대신 조용히 복사된다는 점도 함정입니다. 이동 생성자 구현과 Rule of Five, unique_ptr 소유권 이전 사례까지 함께 봅니다.

들어가며: “std::move를 썼더니 크래시가 나요”

C++11의 이동 의미론(Move Semantics)은 불필요한 복사를 제거해 성능을 높이지만, 잘못 사용하면 크래시가 발생합니다.

// ❌ use after move
std::string str = "Hello";
std::string str2 = std::move(str);  // str의 내용을 str2로 이동

std::cout << str << '\n';  // ❌ 이동된 객체의 값에 의존 → 무엇이 출력될지 보장 없음

이 코드는 대부분의 구현에서 빈 줄을 출력하고 끝나기 때문에 버그처럼 보이지도 않습니다. 문제는 “비어 있을 것”이라는 가정이 표준의 약속이 아니라는 점입니다. std::string은 짧은 문자열을 객체 안에 직접 저장하는 SSO(Small String Optimization)를 쓰므로, 구현에 따라 이동 후에도 원본에 원래 문자가 남아 있을 수 있습니다. 이동 후의 값에 의존하는 코드는 컴파일러나 표준 라이브러리를 바꾸는 순간 다르게 동작합니다. 크래시로 이어지는 경우는 대개 unique_ptr이나 직접 만든 리소스 클래스처럼 이동 후 포인터가 nullptr이 되는 타입을 역참조할 때입니다.

이 글은 std::move가 실제로 하는 일에서 시작해, 이동 후 객체를 다시 쓰는 버그, 이동 생성자 구현, RVO와의 관계, 그리고 자주 보는 실수 10가지를 차례로 봅니다.


std::move란?

std::move는 캐스팅일 뿐

std::move는 실제로 이동하지 않습니다. 단지 lvalue를 rvalue로 캐스팅할 뿐입니다.

std::string str = "Hello";
std::string str2 = std::move(str);
//                 ^^^^^^^^^^^
//                 lvalue → rvalue 캐스팅

// 실제 이동은 이동 생성자가 수행
// std::string::string(std::string&& other)

std::move(x)는 static_cast<std::remove_reference_t<T>&&>(x)와 같습니다. 이 캐스트는 “이 객체의 자원을 가져가도 된다”는 허락을 오버로드 해석에 전달할 뿐이고, 기계어 수준에서는 아무 코드도 만들지 않습니다. 그래서 std::move(str);만 한 줄 써 두면 아무 일도 일어나지 않습니다. 실제 이동은 그 결과를 받는 쪽, 즉 T&&를 받는 이동 생성자나 이동 대입 연산자가 선택되어 실행될 때 일어납니다. 받는 쪽에 이동 생성자가 없으면 const T&를 받는 복사 생성자가 대신 선택되고, 이것이 “move를 썼는데 복사가 일어나는” 대부분의 원인입니다.

이동 vs 복사

// 복사
std::vector<int> vec1(1000000, 42);
std::vector<int> vec2 = vec1;  // 100만 개 복사 (느림)

// 이동
std::vector<int> vec3(1000000, 42);
std::vector<int> vec4 = std::move(vec3);  // 포인터만 이동 (빠름)
// vec3는 이제 빈 벡터

use after move 버그

문제 코드

// ❌ use after move
std::string str = "Hello";
std::string str2 = std::move(str);

std::cout << str << '\n';  // ❌ 이동된 객체의 값 사용 (무엇이 나올지 보장 없음)
std::cout << str.size() << '\n';  // ❌ 호출은 안전하지만 결과값은 정해져 있지 않음

이동 후 상태: 유효하지만 불특정 (valid but unspecified).

안전한 연산 (전제 조건이 없는 연산):

  • 소멸자 호출
  • 재할당 (str = "World";)
  • clear(), reset()
  • size(), empty() 조회 (호출 자체는 안전하지만 결과값은 정해져 있지 않음)

위험한 연산 (전제 조건이 있거나 특정 값을 가정하는 연산):

  • str[0], str.front(): 비어 있지 않다는 전제가 필요
  • 이동 전 값이 남아 있거나, 반대로 비어 있다고 가정하고 쓰는 모든 로직

“valid but unspecified”는 객체의 불변식은 지켜지지만 어떤 값인지는 모른다는 뜻입니다. 모르는 값을 가진 정상 객체이므로 size()를 불러 확인하는 것은 문제가 없지만, 확인 없이 str[0]에 접근하면 비어 있을 경우 정의되지 않은 동작이 됩니다. 예외적으로 std::unique_ptr, std::shared_ptr은 이동 후 nullptr이 된다고 표준이 명시하므로 그 상태에 의존해도 됩니다.

해결법

// ✅ 이동 후 재할당
std::string str = "Hello";
std::string str2 = std::move(str);

str = "World";  // 재할당 (안전)
std::cout << str << '\n';  // "World"

// ✅ 이동 후 사용 안 함
std::string str3 = "Hello";
std::string str4 = std::move(str3);
// str3 사용 안 함

실무에서 use-after-move는 대개 코드가 길어지면서 생깁니다. 함수 앞부분에서 std::move(request)로 넘긴 뒤, 나중에 누군가 함수 끝에 log(request.id)를 추가하는 식입니다. 컴파일러는 이것을 에러로 보지 않지만, clang-tidy의 bugprone-use-after-move 검사는 같은 함수 안의 이런 패턴을 잡아 줍니다. 이동한 객체를 다시 쓸 일이 있다면 이동 직후 명시적으로 clear()하거나 새 값을 대입해 상태를 확정하는 습관이 안전합니다.


이동 생성자/대입 연산자

이동 생성자 구현

class MyClass {
    int* data_;
    size_t size_;
    
public:
    // 이동 생성자
    MyClass(MyClass&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        
        other.data_ = nullptr;  // 원본을 빈 상태로
        other.size_ = 0;
    }
    
    // 이동 대입 연산자
    MyClass& operator=(MyClass&& other) noexcept {
        if (this != &other) {
            delete[] data_;  // 기존 리소스 해제
            
            data_ = other.data_;
            size_ = other.size_;
            
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }
    
    ~MyClass() {
        delete[] data_;
    }
};

주의: noexcept 키워드가 중요합니다 (STL 최적화). 아래 FAQ에서 설명하듯 std::vector가 재할당할 때 이동 생성자가 noexcept가 아니면 복사를 택합니다.

이동 생성자의 핵심은 두 번째 줄, 원본을 빈 상태로 만드는 부분입니다. 이것을 빠뜨리면 두 객체가 같은 data_를 가리키고, 둘 다 소멸할 때 같은 메모리를 두 번 delete[]해 free(): double free detected 같은 에러로 죽습니다. 이동 대입 연산자에서 기존 리소스를 먼저 해제하는 것도 필수입니다. 빠뜨리면 크래시는 없지만 대입할 때마다 메모리가 새어 나갑니다. 더 간단한 대안은 copy-and-swap 관용구나, 애초에 int* 대신 std::vector<int>·std::unique_ptr<int[]> 멤버를 써서 이동 연산을 = default로 두는 것입니다.

Rule of Five

class MyClass {
public:
    // 1. 소멸자
    ~MyClass();
    
    // 2. 복사 생성자
    MyClass(const MyClass& other);
    
    // 3. 복사 대입 연산자
    MyClass& operator=(const MyClass& other);
    
    // 4. 이동 생성자
    MyClass(MyClass&& other) noexcept;
    
    // 5. 이동 대입 연산자
    MyClass& operator=(MyClass&& other) noexcept;
};

규칙: 하나를 정의하면 다섯 개 모두 고려해야 합니다.

이 규칙이 필요한 이유는 컴파일러의 암시적 생성 규칙 때문입니다. 소멸자, 복사 생성자, 복사 대입 중 하나라도 사용자가 선언하면 컴파일러는 이동 생성자와 이동 대입 연산자를 자동으로 만들지 않습니다. 그러면 std::move로 넘겨도 복사 생성자가 선택되어 조용히 복사가 일어납니다. 로깅을 위해 소멸자 하나만 추가했는데 성능이 떨어지는 현상이 이것입니다. 가장 좋은 것은 리소스를 RAII 멤버에 맡겨 다섯 개 모두 선언하지 않는 “Rule of Zero”이고, 하나라도 직접 정의해야 한다면 나머지를 = default 또는 = delete로 명시해 의도를 드러냅니다.


반환값 최적화 (RVO)

RVO란?

RVO(Return Value Optimization)는 컴파일러가 반환값 복사를 제거하는 최적화입니다.

// 복사도 이동도 없음 (RVO)
std::vector<int> createVector() {
    std::vector<int> vec(1000000, 42);
    return vec;  // RVO: 복사/이동 없음
}

std::vector<int> result = createVector();  // 직접 생성

std::move를 쓰면 안 되는 경우

// ❌ RVO 방해
std::vector<int> createVector() {
    std::vector<int> vec(1000000, 42);
    return std::move(vec);  // ❌ RVO 방해 → 이동 발생
}

// ✅ 올바른 코드 (std::move 없이)
std::vector<int> createVector() {
    std::vector<int> vec(1000000, 42);
    return vec;  // RVO
}

규칙: 지역 변수를 반환할 때는 std::move를 쓰지 마세요.

return vec;에서 컴파일러는 먼저 NRVO(이름 있는 반환값 최적화)로 vec을 호출자의 반환 공간에 직접 만들려고 합니다. NRVO가 불가능한 경우(예: 조건에 따라 서로 다른 지역 변수를 반환)에도 C++11부터는 반환되는 지역 변수를 자동으로 rvalue로 취급해 이동합니다. 즉 std::move를 붙여서 얻는 것은 없고, std::move(vec)는 표현식이 더 이상 “지역 변수 이름”이 아니게 만들어 NRVO 대상에서 빠지게 합니다. GCC·Clang은 이 경우 -Wpessimizing-move 경고를 냅니다. 반대로 함수 매개변수나 멤버 변수를 반환할 때처럼 NRVO 대상이 아닌 경우에는 상황이 달라서, 멤버를 꺼내 반환하는 return std::move(member_);는 의미가 있습니다.


자주 나오는 에러 10가지

에러 1: use after move

// ❌ 이동 후 사용
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = std::move(ptr1);

std::cout << *ptr1 << '\n';  // ❌ nullptr 역참조 → 크래시

unique_ptr은 이동 후 nullptr이 보장되므로, 이 크래시는 매번 재현되는 편이라 오히려 찾기 쉬운 부류입니다. Linux에서는 Segmentation fault, Windows에서는 0xC0000005: Access violation reading location 0x0000000000000000처럼 주소 0 근처 접근으로 나타납니다. 크래시 주소가 0이나 아주 작은 값이면 이동된 스마트 포인터나 널 포인터 역참조를 먼저 의심하면 됩니다.

에러 2: const 객체 이동

// ❌ const는 이동 불가
const std::string str = "Hello";
std::string str2 = std::move(str);  // 이동이 아니라 복사!

// std::move는 const T&& 를 반환하지만,
// 이동 생성자는 T&& (비const)를 받으므로 복사 생성자 호출

이 실수는 컴파일 에러도 경고도 없이 넘어가서 성능 문제로만 드러납니다. 흔한 경로는 멤버 함수가 const인데 그 안에서 멤버를 std::move하는 경우, 또는 for (const auto& item : items) out.push_back(std::move(item));처럼 const 참조로 순회하면서 이동하려는 경우입니다. clang-tidy의 performance-move-const-arg가 이 패턴을 알려 줍니다.

에러 3: 반환값에 std::move

// ❌ RVO 방해
std::vector<int> foo() {
    std::vector<int> vec = {1, 2, 3};
    return std::move(vec);  // ❌ 불필요
}

// ✅ 올바른 코드
std::vector<int> foo() {
    std::vector<int> vec = {1, 2, 3};
    return vec;  // RVO
}

에러 4: 이동 생성자 없음

// ❌ 이동 생성자 없음
class MyClass {
    std::unique_ptr<int> ptr_;

public:
    // 복사 생성자만 정의 → 암시적 이동 생성자가 생성되지 않음
    MyClass(const MyClass& other) {
        // ptr_를 복사할 수 없어 아무것도 안 함 → 복사본의 ptr_는 nullptr
        // MyClass b = std::move(a); 도 이 생성자를 호출하므로 데이터가 조용히 사라짐
    }
};

// ✅ 이동 생성자 추가
class MyClass {
    std::unique_ptr<int> ptr_;
    
public:
    MyClass(MyClass&& other) noexcept = default;  // 기본 이동 생성자
};

에러 5: 이동 후 자기 대입

// ❌ 자기 대입 체크 없음
MyClass& operator=(MyClass&& other) noexcept {
    delete[] data_;
    data_ = other.data_;
    other.data_ = nullptr;
    return *this;
}

MyClass obj;
obj = std::move(obj);  // 자기 대입 → data_ 해제 후 nullptr 대입 → 데이터 소실 (size_는 그대로라 불일치)

// ✅ 자기 대입 체크
MyClass& operator=(MyClass&& other) noexcept {
    if (this != &other) {  // 자기 대입 체크
        delete[] data_;
        data_ = other.data_;
        other.data_ = nullptr;
    }
    return *this;
}

자기 이동 대입은 obj = std::move(obj)처럼 직접 쓰기보다 std::swap이나 알고리즘 내부, v[i] = std::move(v[j])에서 i == j인 경우처럼 간접적으로 일어납니다. 체크가 없는 구현은 크래시 대신 객체가 조용히 비어 버리고 size_만 남아 이후 접근에서 문제가 되므로, 원인을 찾기가 더 어렵습니다. 표준 라이브러리 타입은 자기 이동 대입 후에도 객체가 유효하다는 것만 보장하고 값은 보장하지 않으므로, 이 상황 자체를 만들지 않는 것이 가장 좋습니다.

에러 6: 함수 인자에 std::move

// ❌ 이동해 놓고 계속 사용
void process(std::string s) {  // 값 전달: 인자가 rvalue면 이동, lvalue면 복사
    // ...
}

std::string str = "Hello";
process(std::move(str));  // str의 내용을 넘김
std::cout << str;          // ❌ 이동된 str 사용

// ✅ 올바른 코드
std::string str2 = "Hello";
process(std::move(str2));  // OK: 이후 str2를 쓰지 않을 때만
// 또는
process(str2);             // 복사: str2를 계속 사용할 것이면
// 참고: process(const std::string&)처럼 const 참조를 받는 함수에는 std::move가 아무 효과 없음

에러 7: 우측값 참조를 반환

// ❌ 우측값 참조 반환
std::string&& foo() {
    std::string str = "Hello";
    return std::move(str);  // ❌ 지역 변수 참조 반환
}

// ✅ 값 반환
std::string foo() {
    std::string str = "Hello";
    return str;  // RVO
}

에러 8: 이동 불가능한 타입

// ❌ 이동 불가능
class NonMovable {
public:
    NonMovable(NonMovable&&) = delete;  // 이동 생성자 삭제
};

NonMovable obj1;
NonMovable obj2 = std::move(obj1);  // 컴파일 에러

// error: use of deleted function 'NonMovable::NonMovable(NonMovable&&)'

에러 9: 이동 후 벡터 크기 가정

// ❌ 이동 후 크기 가정
std::vector<int> vec1(1000, 42);
std::vector<int> vec2 = std::move(vec1);

// 주요 구현에서는 vec1이 비지만, 이 가정에 로직을 의존하지 말 것
for (int x : vec1) {  // ❌ 이동된 벡터의 내용에 의존
    // ...
}

// ✅ 이동 후 사용 안 함
std::vector<int> vec3(1000, 42);
std::vector<int> vec4 = std::move(vec3);
// vec3 사용 안 함

에러 10: 완벽한 전달 실수

// ❌ 우측값을 좌측값으로 전달
template <typename T>
void wrapper(T&& arg) {
    process(arg);  // ❌ arg는 좌측값 (이름이 있으므로)
}

// ✅ std::forward 사용
template <typename T>
void wrapper(T&& arg) {
    process(std::forward<T>(arg));  // 우측값은 우측값으로 전달
}

실전 사례 분석

사례 1: 벡터 이동 최적화

Before:

// ❌ 불필요한 복사
std::vector<int> createData() {
    std::vector<int> data(1000000, 42);
    return data;  // RVO (복사 없음)
}

void process() {
    std::vector<int> result = createData();  // RVO
}

After: 이미 최적화됨 (std::move 불필요). 이 사례는 “성능을 위해 반환값에 std::move를 붙여야 하지 않을까”라는 흔한 오해를 보여 주기 위한 것으로, 코드를 바꿀 필요가 없습니다. C++17부터는 return std::vector<int>(...)처럼 임시 객체를 반환하는 경우의 복사 생략이 언어 규칙으로 보장되고, 이름 있는 지역 변수의 NRVO는 여전히 컴파일러 최적화지만 주요 컴파일러가 대부분의 단순한 경우에 적용합니다.

사례 2: unique_ptr 소유권 이전

class ResourceManager {
    std::vector<std::unique_ptr<Resource>> resources_;
    
public:
    void add(std::unique_ptr<Resource> res) {
        resources_.push_back(std::move(res));  // 소유권 이전
    }
    
    std::unique_ptr<Resource> take(size_t idx) {
        auto res = std::move(resources_[idx]);  // 소유권 이전
        resources_.erase(resources_.begin() + idx);
        return res;  // RVO (std::move 불필요)
    }
};

사례 3: 이동 전용 타입

// 복사 금지, 이동만 허용
class MoveOnly {
    std::unique_ptr<int> data_;
    
public:
    MoveOnly() = default;
    
    // 복사 금지
    MoveOnly(const MoveOnly&) = delete;
    MoveOnly& operator=(const MoveOnly&) = delete;
    
    // 이동 허용
    MoveOnly(MoveOnly&&) noexcept = default;
    MoveOnly& operator=(MoveOnly&&) noexcept = default;
};

// 사용
MoveOnly obj1;
MoveOnly obj2 = std::move(obj1);  // OK
// MoveOnly obj3 = obj1;  // 컴파일 에러

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 이동 생성자에 noexcept를 빼먹으면 어떤 문제가 생기나요?

A. std::vector는 재할당할 때 강한 예외 보장을 지키기 위해 std::move_if_noexcept로 원소를 옮기므로, 이동 생성자가 noexcept가 아니고 복사가 가능한 타입이면 이동 대신 복사를 선택합니다. 그래서 이동 생성자를 정성껏 만들어도 컨테이너 성장 시에는 복사가 일어나 기대한 성능 향상이 사라집니다. 직접 구현한 이동 생성자·이동 대입 연산자에는 예외를 던지지 않는다면 noexcept를 붙이고, = default로 두면 멤버들의 noexcept 여부를 따라 자동으로 결정됩니다.