Skip to main content

**std::move는 실제로 이동하지 않는다? C++ 값 범주(Value Categories)를 쉽게 풀어보는 깊이 있는 탐구**

Published 5 min readMarkdown

SEO 키워드: std::move, 값 범주, C++ 이동语义


# 📌 서론 – “왜 std::move가 움직이지 않는 것처럼 보일까?”

C++ 프로그래머라면 한 번쯤 “std::move가 실제로 객체를 복사하거나 이동시키는 건가?” 라는 의문을 품어본 적이 있을 것이다. 특히 초보자는 std::move를 호출하면 자동으로 메모리 복사가 일어나고, 그 결과가 바로 “이동”이라고 착각하기 쉽다. 하지만 표준 라이브러리 구현부를 살펴보면 std::move는 그저 값 범주(Value Category)를 바꾸는 캐스팅 연산에 불과하다.

이 글에서는

  1. std::move가 실제로 하는 일

  2. C++의 값 범주(lvalue, xvalue, prvalue)와 그 의미

  3. 이동 생성자·이동 대입 연산자가 언제 호출되는지

를 차근차근 풀어낸다. 코드를 직접 실행해 보며 “이동이 일어나지 않았다”는 상황을 재현하고, 올바른 사용법까지 정리한다.


# 🔎 본론

# 1️⃣ std::move는 “값을 이동한다”가 아니라 “값을 우측값(rvalue)으로 변환한다”

cpp

template< class T >

constexpr typename std::remove_reference<T>::type&& move( T&& t ) noexcept {

    return static_cast<typename std::remove_reference<T>::type&&>(t);

}

위 구현을 보면 알 수 있듯이 std::move는 static_cast 를 이용해 T&& 로 변환한다.

  • 입력: T&& t – 여기서 t는 전달된 인수의 실제 형식에 따라 lvalue reference 혹은 `rvalue reference**가 될 수 있다.

  • 출력: remove_reference<T>::type&& – T에서 &를 제거하고 다시 우측값 참조(&&)를 만든다.

즉, std::move는 객체 자체를 복사하거나 메모리를 이동시키지 않는다. 단순히 “이 객체를 이제는 우측값으로 취급하라”는 신호를 컴파일러에 전달한다. 실제 이동이 일어나려면 이 우측값을 매개변수로 받는 함수(대개 이동 생성자·이동 대입 연산자)가 호출돼야 한다.

핵심 포인트

  • std::move는 컴파일 타임에만 작동한다. 런타임 동작을 바꾸지는 않는다.

  • std::move 뒤에 바로 cout << obj; 같은 코드를 써도 객체는 그대로 남아 있다.


# 2️⃣ 값 범주(Value Categories) – C++가 객체를 어떻게 구분하는가

C++에서는 표현식(expression) 이 어떤 상황(lvalue, rvalue) 에서 사용되는지에 따라 값 범주가 정해진다. 주요 3가지 범주는 다음과 같다.

| 값 범주 | 정의 | 특징 | 예시 |

|--------|------|------|------|

| lvalue | 메모리 주소를 가지고 있는 식 | 이름이 있는 변수, 참조 등 | int a; a |

| xvalue (expiring value) | 이동 가능한 우측값, 즉 “소멸될(소유권이 이전될) 가능성”이 있는 값 | std::move(a), std::forward<T>(t) 등 | std::move(str) |

| prvalue (pure rvalue) | 임시 객체, 리터럴 등 소유권이 없는 값 | 42, std::string('tmp') | int(5) |

왜 xvalue가 중요한가?

xvalue는 “이제 이 객체는 더 이상 사용되지 않을 것이니 소유권을 넘겨도 된다” 라는 의미를 내포한다. 이동 생성자·이동 대입 연산자는 xvalue 를 인자로 받을 때만 호출된다.

# 2‑1️⃣ lvalue → xvalue 로 변환하는 방법

  • std::move : 가장 일반적인 방법.

  • std::forward<T> : 템플릿 인수 T가 lvalue인지 rvalue인지 보존하면서 전달.

# 2‑2️⃣ 값 범주가 함수 오버로드에 미치는 영향

cpp

void foo(const std::string& s);   // lvalue 전용

void foo(std::string&& s);        // rvalue 전용 (xvalue 포함)

std::string s = 'hello';

foo(s);               // 첫 번째 overload 호출 (lvalue)

foo(std::move(s));    // 두 번째 overload 호출 (xvalue)

위 예시처럼 오버로드된 함수는 값 범주에 따라 다르게 동작한다. 이는 성능 최적화와 자원 관리에 핵심적인 역할을 한다.


# 3️⃣ 이동 생성자·이동 대입 연산자는 언제 호출되는가

| 상황 | 호출되는 함수 | 왜 호출되는가 |

|------|--------------|---------------|

| T t = std::move(src); | 이동 생성자 | src가 xvalue이므로 “새 객체를 만들면서 src의 자원을 훔쳐온다”. |

| t = std::move(src); | 이동 대입 연산자 | 기존 객체 t에 src의 자원을 “재사용”한다. |

| func(std::move(src)); | 이동 생성자 (매개변수가 값) 또는 이동 대입 연산자 (매개변수가 참조) | 함수 인자 전달 시 우측값으로 변환된 src가 이동 대상이 된다. |

# 3‑1️⃣ 이동이 일어나지 않는 함정

cpp

std::vector<int> v1 = {1,2,3};

std::vector<int> v2 = v1;          // 복사 생성자 호출 → 비용 비쌈

std::vector<int> v3 = std::move(v1); // 이동 생성자 호출 → 비용 저렴

하지만 다음과 같은 경우는 이동이 일어나지 않는다:

cpp

void foo(std::vector<int> v);   // 값 전달 (복사 또는 이동)

std::vector<int> a = {1,2,3};

foo(a);               // a는 lvalue → 복사 생성자 호출

foo(std::move(a));    // a는 xvalue → **이동 생성자** 호출 (가능)

만약 함수가 const std::vector<int>& 로 받는다면 std::move(a)를 넘겨도 복사가 일어나지 않는다(오히려 const lvalue가 되므로 이동 불가).

# 3‑2️⃣ 이동이 불가능한 경우

  • 삭제된(move deleted) 이동 생성자가 존재하거나

  • 복사 생성자만 정의된 클래스(예: std::unique_ptr은 복사 불가)

이때 std::move를 사용하면 컴파일 오류가 발생한다.


# 4️⃣ 실전 예제 – std::move와 값 범주를 직접 확인해보자

cpp

# include <iostream>

# include <utility>

struct Demo {

    Demo() { std::cout << 'Default ctor\n'; }

    Demo(const Demo&) { std::cout << 'Copy ctor\n'; }

    Demo(Demo&&) noexcept { std::cout << 'Move ctor\n'; }

    ~Demo() { std::cout << 'Dtor\n'; }

};

int main() {

    Demo d1;                 // Default ctor

    Demo d2 = d1;            // Copy ctor

    Demo d3 = std::move(d1); // Move ctor

}

출력


Default ctor

Copy ctor

Move ctor

Dtor

Dtor

Dtor
  • d1 → d2 : 복사

  • std::move(d1) → d3 : 이동 (실제로 이동 생성자가 호출됨)

핵심 정리

  • std::move 자체는 아무 동작도 하지 않는다.

  • 이동 생성자/대입 연산자가 존재하고, 우측값(xvalue) 로 전달될 때만 이동이 일어난다.


# 5️⃣ std::move를 올바르게 쓰는 팁

| 상황 | 올바른 사용법 |

|------|--------------|

| 임시 객체를 반환 | return std::move(local); → 불필요. 반환값 최적화(RVO) 때문에 자동 이동이 일어나므로 std::move를 제거한다. |

| 컨테이너에 원소 삽입 | vec.push_back(std::move(obj)); → 객체를 소유권을 넘겨 삽입, 이후 obj는 비어있음. |

| 템플릿 함수에서 전달 | template<class T> void wrapper(T&& arg) { func(std::forward<T>(arg)); } – std::move 대신 std::forward 사용. |

| const 객체 | std::move(constObj) → xvalue가 되지만 const 특성 때문에 이동 생성자를 호출할 수 없다. 복사만 일어남. |


# 🎯 결론 – 행동으로 옮기는 std::move 이해하기

1️⃣ std::move는 “값을 이동한다”가 아니라 “값을 우측값으로 만든다”는 사실을 기억하자.

2️⃣ 값 범주(lvalue, xvalue, prvalue)를 정확히 구분하면 언제 이동이 일어나고 언제 복사가 일어나는지 명확히 판단할 수 있다.

3️⃣ 실제 이동 생성자·이동 대입 연산자가 구현된 타입에만 std::move를 적용하고, 불필요한 std::move(예: 반환값) 는 제거해 코드를 깔끔하게 유지한다.

당신에게 권하는 실천 과제

  • 오늘 작성 중인 C++ 프로젝트에서 std::move 사용 위치를 한 번씩 점검해 보세요.

  • 이동이 가능한 클래스라면 이동 생성자/대입 연산자를 직접 구현하고, std::move와 값 범주를 테스트해 보는 연습을 해보세요.

이제 “std::move는 실제로 움직이지 않는다”는 오해를 벗고, 값 범주를 활용한 최적화를 자신 있게 적용할 수 있을 것이다.


읽어주셔서 감사합니다! 더 많은 C++ 팁과 최신 프로그래밍 트렌드를 원한다면 아래 구독 버튼을 눌러 주세요.

No comments yet. Be the first to comment.

More from this blog