본문으로 건너뛰기 C++ Rvalue vs Lvalue | '값 범주' 가이드

C++ Rvalue vs Lvalue | '값 범주' 가이드

C++ Rvalue vs Lvalue | '값 범주' 가이드

이 글의 핵심

C++ Rvalue vs Lvalue - "값 범주" 가이드. C++ Rvalue vs Lvalue의 Lvalue vs Rvalue, Lvalue (좌측값), Rvalue (우측값)를 실전 코드와 함께 설명합니다.

Lvalue vs Rvalue

C++의 모든 표현식은 값 범주(value category)라는 성질을 가지며, 그중 가장 기본적인 구분이 좌측값(lvalue)과 우측값(rvalue)입니다. lvalue는 이름을 가지고 있고 메모리 주소를 취할 수 있는, 즉 대입식의 왼쪽에 올 수 있는 표현식을 뜻하고, rvalue는 10과 같은 리터럴이나 함수 반환값처럼 이름이 없고 곧 사라질 임시 값을 뜻합니다. 이 구분이 중요한 이유는 참조를 바인딩할 때 규칙이 서로 다르기 때문인데, 일반 lvalue 참조(int&)는 rvalue에 바인딩할 수 없고 rvalue 참조(int&&)는 반대로 lvalue에 바인딩할 수 없습니다. 아래 예시는 이 규칙이 실제 컴파일 에러로 어떻게 나타나는지 보여주며, 이 구분을 정확히 이해하는 것이 뒤에서 다룰 이동 의미론과 완벽 전달을 이해하는 출발점이 됩니다.

int x = 10;  // x는 lvalue, 10은 rvalue

int& lref = x;       // OK: lvalue 레퍼런스
// int& lref2 = 10;  // 에러: rvalue를 lvalue 레퍼런스에

int&& rref = 10;     // OK: rvalue 레퍼런스
// int&& rref2 = x;  // 에러: lvalue를 rvalue 레퍼런스에

Lvalue (좌측값)

lvalue를 판별하는 가장 실용적인 기준은 “이 표현식의 주소를 &로 가져올 수 있는가”입니다. 변수, 배열, 참조가 가리키는 대상, 포인터를 역참조한 결과는 모두 메모리상의 특정 위치를 가리키고 있으므로 lvalue이며, 이 성질 덕분에 대입식의 좌변에 놓이거나 포인터로 주소를 저장할 수 있습니다. 아래 예시들은 겉보기에는 서로 다른 형태(변수 선언, 배열, 객체, 참조, 역참조)이지만 모두 이름이 있고 주소를 취할 수 있다는 공통점 때문에 lvalue로 분류된다는 것을 보여줍니다.

// 이름 있고 주소 있음
int x = 10;
int* ptr = &x;  // OK

// Lvalue 예시
int x;              // 변수
int arr[10];        // 배열
std::string s;      // 객체
int& ref = x;       // 레퍼런스
*ptr;               // 역참조

Rvalue (우측값)

반대로 rvalue는 이름이 없고 표현식이 평가되고 나면 곧 사라질 임시 값을 가리킵니다. 리터럴 10이나 x + y 같은 산술 표현식의 결과는 어딘가에 이름이 붙어 저장되어 있지 않으므로 주소를 취할 수 없고, 값을 참조할 필요가 있으면 즉시 사용하거나 새 변수에 담아야 합니다. 함수가 참조가 아닌 값을 반환하는 경우(func())도 호출이 끝나면 사라질 임시 객체이므로 rvalue이며, std::move(x)는 원래 lvalue였던 x를 명시적으로 rvalue처럼 취급하도록 강제로 캐스팅하는 특별한 경우입니다. 이렇게 rvalue는 “곧 사라질 값”이라는 특성 때문에, 그 값을 복사하지 않고 자원만 훔쳐와도 안전하다는 것이 이동 의미론의 핵심 아이디어입니다.

// 임시 값, 주소 없음
// int* ptr = &10;  // 에러

// Rvalue 예시
10;                 // 리터럴
x + y;              // 표현식 결과
func();             // 함수 반환값 (비레퍼런스)
std::move(x);       // 명시적 rvalue

실전 예시

값 범주 개념은 추상적으로 설명하는 것보다 실제 코드에서 오버로드 해석, 이동 생성자 호출, 함수 반환값 처리에 어떻게 관여하는지 보는 것이 훨씬 이해하기 쉽습니다. 아래 네 가지 예시는 오버로드 선택, 클래스의 이동 의미론, 컨테이너에서의 std::move 활용, 함수 반환값의 값 범주를 차례로 다룹니다.

예시 1: 레퍼런스 바인딩

같은 이름의 함수를 lvalue 참조 버전과 rvalue 참조 버전으로 각각 오버로드하면, 컴파일러는 인자의 값 범주를 보고 자동으로 알맞은 쪽을 선택합니다. 아래 예시에서 func(x)처럼 이름 있는 변수를 넘기면 lvalue 참조 오버로드가 호출되지만, func(10)처럼 리터럴을 넘기거나 func(std::move(x))처럼 명시적으로 rvalue로 캐스팅하면 rvalue 참조 오버로드가 선택됩니다. 이런 오버로드 분기는 표준 라이브러리 곳곳(예: std::vector::push_back)에서 복사와 이동을 자동으로 구분하는 데 실제로 사용되는 메커니즘입니다.

void func(int& x) {
    std::cout << "lvalue ref" << std::endl;
}

void func(int&& x) {
    std::cout << "rvalue ref" << std::endl;
}

int main() {
    int x = 10;
    func(x);           // lvalue ref
    func(10);          // rvalue ref
    func(std::move(x)); // rvalue ref
}

예시 2: 이동 의미론

값 범주와 이동 의미론이 실제로 만나는 지점이 바로 클래스의 복사 생성자와 이동 생성자입니다. 아래 Buffer 클래스는 Buffer(const Buffer&)로 lvalue를 받아 자원을 복제하는 복사 생성자와, Buffer(Buffer&&)로 rvalue를 받아 자원을 그대로 옮기는 이동 생성자를 함께 정의하고 있습니다. Buffer b2 = b1;처럼 이름 있는 변수를 대입하면 b1이 lvalue이므로 복사 생성자가 선택되어 메모리가 새로 할당되지만, Buffer b3 = std::move(b1);b1을 rvalue로 캐스팅했으므로 이동 생성자가 선택되어 포인터만 옮겨지는 것을 콘솔 출력으로 직접 확인할 수 있습니다.

class Buffer {
    int* data;
    size_t size;
    
public:
    Buffer(size_t s) : size(s) {
        data = new int[size];
    }
    
    ~Buffer() {
        delete[] data;
    }
    
    // 복사 생성자 (lvalue)
    Buffer(const Buffer& other) : size(other.size) {
        data = new int[size];
        std::copy(other.data, other.data + size, data);
        std::cout << "복사" << std::endl;
    }
    
    // 이동 생성자 (rvalue)
    Buffer(Buffer&& other) noexcept 
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
        std::cout << "이동" << std::endl;
    }
};

int main() {
    Buffer b1(100);
    Buffer b2 = b1;              // 복사
    Buffer b3 = std::move(b1);   // 이동
}

예시 3: std::move

std::vector<std::string>처럼 요소마다 힙 메모리를 갖는 컨테이너를 복사하는 것은 내부 문자열 전부를 다시 할당하고 복제해야 하므로 비용이 상당히 큽니다. 아래 예시에서 vec2 = vec1은 컨테이너 전체를 완전히 복제하지만, vec3 = std::move(vec1)vec1의 내부 버퍼를 그대로 vec3로 옮기고 vec1은 빈 상태(원소가 없는 유효한 벡터)로 남습니다. vec1을 이동시킨 뒤에는 그 값이 더 이상 필요 없다는 것을 코드로 명시적으로 드러내는 것이므로, 이동 후에는 vec1의 이전 내용에 의존하는 코드를 작성해서는 안 됩니다.

#include <vector>
#include <string>

int main() {
    std::vector<std::string> vec1;
    vec1.push_back("Hello");
    
    // 복사
    std::vector<std::string> vec2 = vec1;
    
    // 이동
    std::vector<std::string> vec3 = std::move(vec1);
    // vec1은 이제 비어있음
}

예시 4: 함수 반환

값을 반환하는 함수의 반환값은 이름이 없는 임시 객체이므로 rvalue로 취급됩니다. 아래 getName()이 반환하는 "Alice"로 만들어진 임시 std::stringstd::string name = getName();처럼 값으로 받으면 이동 또는 반환값 최적화(RVO)로 효율적으로 처리되지만, const std::string& ref = getName();처럼 const 참조로 받으면 rvalue를 lvalue 참조가 아닌 const lvalue 참조에는 바인딩할 수 있다는 특별 규칙 덕분에 임시 객체의 수명이 그 참조의 수명만큼 연장되는 흥미로운 동작이 일어납니다. 이 수명 연장 규칙은 편리하지만 임시 객체가 눈에 보이지 않게 계속 살아있다는 점을 이해하지 못하면 예상치 못한 메모리 사용으로 이어질 수 있어 주의가 필요합니다.

std::string getName() {
    return "Alice";  // rvalue
}

int main() {
    std::string name = getName();  // 이동 또는 RVO
    
    const std::string& ref = getName();  // 수명 연장
}

값 범주 (C++11)

C++11 이전에는 lvalue와 rvalue라는 두 범주만으로도 충분했지만, 이동 의미론이 도입되면서 값 범주가 다섯 가지로 더 세밀하게 나뉘게 되었습니다. lvalue는 여전히 이름 있고 주소를 취할 수 있는 값이고, prvalue(순수 rvalue)는 리터럴이나 산술 표현식처럼 전통적인 의미의 임시 값이며, xvalue(만료 예정 값)는 std::move로 캐스팅된 값처럼 곧 사라지지만 자원을 이동시킬 수 있는 특별한 rvalue입니다. 이 세 가지 기본 범주는 다시 두 축으로 묶이는데, 메모리 위치를 갖는 lvalue와 xvalue를 합쳐 glvalue(일반화된 lvalue)라 부르고, 자원을 이동할 수 있는 xvalue와 prvalue를 합쳐 우리가 흔히 말하는 rvalue라고 부릅니다. 이 다섯 가지 분류를 모두 암기할 필요는 없지만, “xvalue는 이동 가능한 rvalue”라는 개념만 이해해도 std::move가 왜 필요한지 훨씬 명확해집니다.

// lvalue: 이름 있음
int x;

// prvalue: 순수 rvalue
10;
x + y;

// xvalue: 만료 예정 lvalue
std::move(x);
static_cast<int&&>(x);

// glvalue: lvalue + xvalue
// rvalue: prvalue + xvalue

자주 발생하는 문제

값 범주와 이동 의미론을 잘못 이해하고 사용하면 눈에 띄지 않는 버그나 오히려 성능 저하로 이어질 수 있습니다. 아래 네 가지는 그중에서도 특히 자주 발생하는 실수들입니다.

문제 1: 이동 후 사용

std::move로 컨테이너나 객체를 이동시킨 뒤에도 원래 변수의 이전 내용에 의존하는 코드를 작성하는 것은 흔한 실수입니다. 아래 예시에서 vec1std::movevec2에 이동시킨 뒤 vec1.push_back(4)를 호출하는 것 자체는 표준상 정의되지 않은 동작은 아니지만(“이동된 후 유효하지만 명시되지 않은 상태”), 이전 내용을 전제로 한 로직을 실행하면 예상과 다른 결과를 얻게 됩니다. 이동된 객체를 다시 쓰고 싶다면 그 값이 비어 있거나 알 수 없는 상태라고 가정하고, 아래처럼 새 값을 명시적으로 대입해 재초기화한 뒤에 사용하는 것이 안전합니다.

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

// ❌ 이동 후 사용
vec1.push_back(4);  // 정의되지 않은 동작

// ✅ 재할당
vec1 = {5, 6, 7};  // OK

문제 2: const와 이동

const가 붙은 객체는 애초에 내부 상태를 변경할 수 없으므로, std::move로 rvalue 참조를 얻어와도 이동 생성자를 호출할 수 없습니다. 이동 생성자는 원본 객체의 멤버를 nullptr로 초기화하는 등 반드시 원본을 변경해야 하는데, const std::string처럼 상수 객체는 이런 변경이 언어 규칙상 금지되어 있기 때문에 컴파일러는 const Buffer& 오버로드, 즉 복사 생성자로 조용히 되돌아갑니다. 그 결과 std::move(s)라고 작성한 코드가 실제로는 복사를 수행하게 되어 개발자의 의도와 다르게 동작하므로, 이동을 활용하고 싶은 객체는 애초에 const로 선언하지 않는 것이 원칙입니다.

const std::string s = "Hello";
// std::string s2 = std::move(s);  // 복사됨 (이동 안 됨)

// const는 이동 불가

문제 3: 반환값 최적화

지역 변수를 반환할 때 “이동을 강제하면 더 빠르지 않을까”라는 생각으로 return std::move(s);라고 작성하는 경우가 있는데, 이는 오히려 컴파일러의 최적화를 방해하는 역효과를 냅니다. 컴파일러는 return s;처럼 지역 변수를 그대로 반환하면 반환값 최적화(RVO)를 적용해 복사도 이동도 없이 호출부에 직접 객체를 생성할 수 있지만, std::move를 붙이면 값이 아니라 rvalue 참조를 반환하는 것으로 해석되어 RVO 적용 대상에서 제외되고 대신 이동 생성자 호출로 격하됩니다. 그래서 지역 변수를 그대로 반환하는 가장 일반적인 경우에는 std::move를 붙이지 않는 것이 오히려 더 빠른 코드를 만드는 방법입니다.

std::string func() {
    std::string s = "Hello";
    return s;  // ✅ 그냥 반환
    // return std::move(s);  // ❌ RVO 방해
}

문제 4: 레퍼런스 붕괴

template<typename T> void func(T&& x)처럼 템플릿 매개변수 타입에 &&가 붙은 형태를 만능 참조(universal reference, 또는 forwarding reference)라고 부르며, 일반적인 rvalue 참조와는 다르게 동작합니다. 이 형태는 레퍼런스 붕괴(reference collapsing) 규칙에 따라 lvalue를 넘기면 Tint&로 추론되어 결과적으로 lvalue 참조가 되고, rvalue를 넘기면 T가 그냥 int로 추론되어 rvalue 참조가 됩니다. 아래 예시에서 func(y)func(10)이 겉보기에는 똑같은 함수를 호출하는 것 같지만 내부적으로 T가 서로 다르게 추론된다는 점이 이 문법의 핵심이며, 이 성질 덕분에 하나의 템플릿 함수로 lvalue와 rvalue를 모두 받아 처리할 수 있게 됩니다.

template<typename T>
void func(T&& x) {  // Universal Reference
    // x는 lvalue 또는 rvalue
}

int y = 10;
func(y);           // T = int&
func(10);          // T = int

std::forward

만능 참조로 받은 매개변수는 함수 안에서 사용될 때 그 자체로는 이름이 붙은 lvalue로 취급된다는 점이 초보자가 가장 헷갈려하는 부분입니다. 아래 wrapper 함수에서 argT&&로 선언되어 rvalue를 받았을 수도 있지만, 함수 본문 안에서 arg라는 이름으로 참조하는 순간 그 자체는 항상 lvalue이므로 process(arg)라고만 쓰면 원래 rvalue였던 값의 정보가 사라져 항상 lvalue 오버로드가 호출됩니다. std::forward<T>(arg)는 이렇게 잃어버린 원래 값 범주 정보를 템플릿 매개변수 T를 근거로 복원해, 원래 rvalue로 넘어온 인자는 다시 rvalue로, lvalue로 넘어온 인자는 lvalue로 그대로 다음 함수에 전달해 주는 역할을 합니다. 이 패턴을 완벽 전달(perfect forwarding)이라고 부르며, 래퍼 함수나 팩토리 함수를 작성할 때 원래 호출자의 의도(복사할지 이동할지)를 그대로 유지하기 위해 반드시 필요한 관용구입니다.

template<typename T>
void wrapper(T&& arg) {
    // ❌ arg는 항상 lvalue
    process(arg);
    
    // ✅ std::forward 사용
    process(std::forward<T>(arg));
}

FAQ

Q1: Lvalue vs Rvalue?

A:

  • Lvalue: 이름 있음, 주소 있음
  • Rvalue: 임시 값, 주소 없음

Q2: std::move는?

A: lvalue를 rvalue로 캐스팅.

Q3: 이동 후 상태는?

A: 유효하지만 불확실. 재할당 가능.

Q4: const와 이동?

A: const는 이동 불가. 복사됩니다.

Q5: 성능 이점?

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

Q6: Rvalue/Lvalue 학습 리소스는?

A:

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

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

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

관련 글


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

C++, rvalue, lvalue, value-category, move 등으로 검색하시면 이 글이 도움이 됩니다.