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::string은 std::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로 컨테이너나 객체를 이동시킨 뒤에도 원래 변수의 이전 내용에 의존하는 코드를 작성하는 것은 흔한 실수입니다. 아래 예시에서 vec1을 std::move로 vec2에 이동시킨 뒤 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를 넘기면 T가 int&로 추론되어 결과적으로 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 함수에서 arg는 T&&로 선언되어 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++ 값 카테고리 | “lvalue/rvalue/xvalue” 완벽 정리
- C++ Move 시맨틱스 | “복사 vs 이동” 완벽 이해
- C++ 복사/이동 생성자 | “Rule of Five” 가이드
관련 글
- C++ 값 카테고리
- C++ Move 시맨틱스
- C++ 참조(Reference) 완벽 가이드 | lvalue·rvalue
- C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화
- C++ Algorithm Copy
이 글에서 다루는 키워드 (관련 검색어)
C++, rvalue, lvalue, value-category, move 등으로 검색하시면 이 글이 도움이 됩니다.