[모던 C++] 객체의 전달 방식 – 값, 참조, 포인터, 복사의 차이와 의미 보존
C++에서 객체를 함수로 넘길 때 사용하는 값(value), 참조(reference), 포인터(pointer)는 어떤 차이를 가질까? 복사 비용, 슬라이싱, 수명 문제, 의미 보존까지 실제 코드를 통해 자세히 설명한다. 또한 const &가 왜 특별한지, 복사 불가능한 타입과 다형성까지 어떻게 안전하게 전달할 수 있는지를 정리한다.
객체의 전달이란?
객체의 전달이라함수 인자나 반환을 통해 객체를 다른 스코프(함수/모듈)로 넘기는 모든 행위를 말한다. C++에서는 크게 네 가지 방식이 있다.
- 값(value): 객체 자체를 복사해 전달
- 참조(reference): 별칭(별명)으로 전달 — 복사 없음
- 포인터(pointer): 주소값을 수동으로 전달 — 널/소유권 관리 필요
- 이동(move): 자원(메모리/핸들)의 소유권 이전
본문에서는 다룰 내용이 특히 많은 이동(move)를 제외한 나머지에 대해서 다룬다.
값과 주소
값 (Value)
컴퓨터 과학에서 값은 데이터 그 자체를 의미한다.
int a = 10;위 코드에서 값은 10이다.
C++에서는 이 값이 어떤 객체의 저장공간(메모리 표현)에 담겨 있을 수도 있고, 식의 계산 결과로 잠깐 나타난 임시 값일 수도 있다. 이 차이가 뒤에서 나올 값 범주(lvalue/rvalue 등)와 연결된다.
주소 (Adress)
컴퓨터 과학에서의 주소는 값이 저장된 메모리의 위치이다. 즉 값이 있는 곳을 가리키는 숫자일 뿐이지 데이터의 자체는 아니다.
int a = 10;
int *p = &a; // a의 주소를 저장p에는 “a가 저장된 메모리 위치”가 들어있음.*p를 통해 간접적으로a를 읽거나 수정할 수 있음.
복사 (Copy)
컴퓨터 과학에서의 복사는 객체의 내용을 동일하게 복제해서 새로운 객체를 만드는 행위를 말한다. 즉, 같은 데이터를 가진 별도의 객체를 하나 더 만드는 것이다.
기본 타입의 복사
기본 타입(예: int, double)은 단순히 값이 그대로 복제된다.
int a = 10;
int b = a; // b에 a의 값이 복사됨
b = 20;
std::cout << a; // 10 (원본은 변하지 않음)
객체의 복사
사용자 정의 타입(클래스, 구조체)은 복사 시 복사 생성자 (Copy Constructor)나 복사 대입 연산자(Copy Assignmnet Operator)가 호출이 된다.
복사 생성자 (Copy Constructure)
복사 생성자는 새로운 객체를 생성할 때 다른 객체의 내용을 복사하며 생성할 떄 호출이 된다. 예제 코드는 다음과 같다.
#include <iostream>
#include <string>
struct Data
{
std::string text;
Data(const std::string &t) : text(t)
{
std::cout << "생성자\n";
}
Data(const Data &other) : text(other.text)
{
std::cout << "복사 생성자\n";
}
};
int main()
{
Data a("Hello");
Data b = a; // 복사 생성자 호출
}출력
생성자
복사 생성자
a를 복사하여b가 새로 만들어짐b.text는a.text와 같은 값을 가지지만 서로 다른 메모리
복사 대입 연산자 (Copy Assignment Operator)
복사 대입 연산자는 이미 존재하는 객체에 다른 객체의 내용을 덮어쓸 때 호출된다. 예제 코드는 다음과 같다.
#include <iostream>
#include <string>
struct Data
{
std::string text;
Data(const std::string &t) : text(t)
{
std::cout << "생성자\n";
}
Data(const Data &other) : text(other.text)
{
std::cout << "복사 생성자\n";
}
Data &operator=(const Data &other)
{
std::cout << "복사 대입 연산자\n";
if (this != &other) // 자기 자신 대입 방지
{
text = other.text;
}
return *this; // 자기 자신을 반환
}
};
int main()
{
Data a("Hello");
Data b("World");
b = a; // 복사 대입 연산자 호출
}
출력:
생성자
생성자
복사 대입 연산자Data a("Hello");→ 첫 번째 객체 생성 (생성자호출)Data b("World");→ 두 번째 객체 생성 (생성자호출)b = a;→ 이미 존재하는b에a의 데이터를 복사 대입
이때 호출되는 함수가 바로 복사 대입 연산자(operator=) 다.
참고로 아직 다루지는 않았지만, operator=으로 선언한것이 바로 연산자 오버로딩 (Operator overloading)이다.
복사는 객체의 데이터를 새로 복제하기 때문에 그만큼 비용(cost) 이 든다. 기본형(int, double 등)은 복사 비용이 거의 없지만, std::string이나 std::vector처럼 내부에 동적 메모리를 가진 객체는 복사 시 모든 데이터를 새로 할당하므로 성능 저하가 크다. 이런 불필요한 복사를 줄이기 위해, C++에서는 참조자(reference) 를 사용해 원본을 직접 다루는 방법을 제공한다.
참조, 참조자(Reference)
복사는 안전하지만 비용이 크다. 그래서 c++은 복사 없이 원번 객체를 직접 다루는 방법으로 참조자(Reference)를 제공한다. 참조자는 다른 변수(객체)의 별명(alias)으로, 새로운 메모리를 만들 지 않고 기존 객체를 가리킨다.
참조자 자체는 모던 C++의 기능은 아니지만, 모던 C++에서는 이를 확장한 rvalue 참조, std::move 등과 같은 기능으로 확장되었기 때문에 여기서 기본적인 개념부터 다시 살펴보면 추후 이해에 도움이 될 것이다.
기본 개념
int a = 10;
int &ref = a; // ref는 a의 별명
ref = 20;
std::cout << a; // 20 출력참조자 기본 예제
위 코드에서 ref는 변수 a의 복사로 선언한게 아니라 a를 참조하는 변수이다. 즉, a와 같은 메모리를 공유한다. 그래서 ref를 바꾸면 a의 값도 바뀌게 되는 것이다. 쉽게 말해 참조자는 "원본을 직접 다루는 포인터의 안전현 형태"라고 볼 수 있다.
참조자의 특징
| 항목 | 설명 |
|---|---|
| 선언 방식 | 타입 &이름 |
| 반드시 초기화 필요 | 선언 시 바로 어떤 객체를 참조해야 함 |
| null 불가 | 항상 실제 객체를 가리켜야 함 |
| 재바인딩 불가 | 한 번 참조한 객체는 다른 객체로 바꿀 수 없음 |
참조자를 선언할 때는 포인터와는 다르게 반드시 초기화를 해줘야 한다. 또한 한 번 참조를 하면 참조하는 대상을 바꿀 수 없다. 이 부분이 포인터와의 가장 큰 차이점이고, 참조자가 안전하다고 취급받는 큰 이유이다.
참조자 vs 포인터
| 항목 | 포인터 (T*) |
참조자 (T&) |
|---|---|---|
| 의미 | 객체의 주소를 저장하는 변수 | 객체의 또 다른 이름(alias) |
| 선언 시 초기화 | 선택적 | 반드시 초기화 필요 |
| null 가능 | ✅ 가능 (nullptr) |
❌ 불가능 |
| 재할당 | ✅ 가능 (p = &b) |
❌ 불가능 |
| 접근 문법 | (*p) 또는 p->member |
r 또는 r.member |
c++에서는 포인터보다 참조자를 선호하는 편이다.
- 안전성 - null이 불가능하고 항상 유효한 객체만을 참조하기 때문에 안정성이 뛰어나다 (물론 dangling reference와 같이 간혹가다 잘못쓰면 터지기도 한다.).
- 명확성 - 참조자를 사용한다는 것 자체가 원본 변수에 수정을 가할 수 있다는 것을 나타내기 때문에 함수 시그니쳐 만으로도 의도가 명확해 질 수 있다 (물론 읽기 전용으로 사용할수도 있는데 그 경우는 대게 const &를 사용한다.).
- 단순성 - 포인터의 값에 접근하려면 역참조 연산자 (*)를 통해 접근해야 하는데, 참조자는 변수 그대로 사용하면 된다.
Dangling Reference
참조자가 가리키는 객체가 이미 소멸했는데도 그 참조자를 계속 사용하려고 하는 상황을 말한다. 대표적인 예시는 아래와 같이 지역변수를 참조 반환하는 것이다.
int &badFunc()
{
int local = 10;
return local; // ❌ 지역 변수는 함수 종료 후 사라짐
}
int main()
{
int &ref = badFunc(); // ref는 죽은 local을 참조 중
std::cout << ref; // ⚠️ 정의되지 않은 동작(UB)
}local은 스택에 존재하는 지역 변수- 함수가 끝나면 스택 프레임이 정리되어
local은 파괴됨 ref는 여전히 그 주소를 가리키고 있음 → 댕글링 참조 발생
지역변수는 해당 함수 스코프를 벗어나면 소멸하기 때문에 이 경우는 반환된 참조자는 유효하지 않은 메모리를 참조하게 된다. 최근의 컴파일러는 대부분 컴파일은 되지만 경고를 띄워주기는 한다.
로컬 변수 참조 반환이 아닌 맴버 변수를 참조로 반환하는것은 대부분 안전한데, 그 경우 맴버변수의 수명은 객체(this)와 같기 때문이다.
struct Data
{
int value = 42;
int &getValue()
{
return value; // this가 살아있는 동안 안전
}
};
int main()
{
Data d;
int &r = d.getValue(); // 안전
r = 100;
std::cout << d.value; // 100
}
상수 lvalue 참조 - const & 사용
c++에서 참조는 보통 "원본을 바꾸기"위해 사용한다. 그러나 const & 는 조금 특별한데, 읽기 전용이라는 제약(const)를 걸면서도, 단순한 const 복사로는 대체하기 어려운 세 가지 이점을 한 번에 주게 된다.
- 복사 없이 읽기: 큰 객체를 함수 인자로 넘길 때 복사 및 이동 비용을 없앨 수 있다.
- rvalue 바인딩 + 수명 연장: 임시 객체에도 참조자를 붙일 수 있으며, 참조자가 살아있는 동안 임시 객체의 수명을 연장한다. 값 전달은 항상 한 번의 복사 /이동이 필요하지만,
const &는 그 비용을 피하게 된다. - 의미 보존(다형성, 비복사 타입) : 값으로 받으면 파생 객체가 슬라이싱 되지만,
const &는 다형성을 보존한다. 또한 복사 불가능한 타입도 읽기 전용 인자로 받을 수 있게 된다.
복사 비용 회피
값을 함수 인자로 넘기면 복사나 이동이 일어난다. 특히 std::string, std::vector같은 큰 객체의 경우 복사 비용이 비쌀 수 있다. 하지만 const &는 객체를 복사 없이 얇게 참조하므로, 비용 없이 원본을 읽을 수 있다.
#include <string>
int lenByValue(std::string s) // 항상 복사/이동 발생
{
return static_cast<int>(s.size());
}
int lenByRef(const std::string &s) // 복사 없음
{
return static_cast<int>(s.size());
}
위의 lenByValue는 std::string을 복사한 후 size를 구한다. 반면 lenByRef는 복사 없이 원본을 참조해 size를 구한다. 성능 측면에서 후자가 유리하다.
임시 객체의 바인딩 + 수명 연장
보통 참조자는 이름이 있는 객체(lvalue)만 참조할 수 있다. 하지만 const &는 임시 객체(rvalue)도 참조할 수 있으며, 이 경우 그 임시 객체의 수명이 참조의 수명까지 자동으로 연장된다.
#include <string>
const std::string &ref = std::string("hello"); // OK: 수명 연장됨언뜻 보면 std::string("hello")는 임시 객체이기 때문에 이 한 줄이 끝나면 소멸되고, 그걸 참조하는 ref는 dangling reference가 될 것처럼 보인다.
하지만 const 한정자가 붙은 lvalue 참조(const &)가 rvalue에 바인딩될 경우, C++ 표준에 따라 컴파일러는 그 임시 객체의 수명을 참조(ref)와 동일한 범위로 연장해 준다.
좀 더 정확한 용어로 표현하면:
"임시 객체(rvalue)에const lvalue reference가 바인딩되면,
그 임시 객체는 참조가 파괴되는 시점까지 수명이 연장된다.
이는 C++ 표준에 정의된 임시 객체의 수명 연장 (lifetime extension) 규칙에 따른 것이다."
즉, const &로 임시 객체를 참조하는 코드는 안전하며, 컴파일러가 자동으로 수명 연장을 보장해 주기 때문에 dangling reference가 발생하지 않는다.
lvalue와 rvalue에 대한 설명은 다른 챕터에서 다룰 예정이니 지금은 이정도만 이해해도 충분하다.
의미 보존 (semantic preservation)
c++에서 의미(semantic)은 "그 객체가 어떤 역할을 하는지, 어떤 행동을 기대할 수 있는지"에 대한 행동적 정의와 맥락을 의미한다. 즉, 같은 값을 가지더라도 의미가 다를 수 있고 전달 방식이나 처리에 따라 의미가 바뀔 수 있다.
const &를 사용하면 두 가지 측면에서 의미를 보존할 수 있다.
다형셩 보존 - 슬라이싱(Slicing)방지
클래스를 값(value)으로 받을 경우, 기본 클래스(Base)의 부분만 복사되기 때문에 파생 클래스(Derived)의 고유한 정보는 잘려나간다. 이를 "객체 슬라이싱(Object Slicing)"이라고 한다. 코드를 보면 조금 더 직관적으로 이해할 수 있다.
#include <iostream>
#include <string>
struct Animal
{
virtual std::string speak() const
{
return "Animal sound";
}
};
struct Dog : Animal
{
std::string speak() const override
{
return "Woof!";
}
};
void printByValue(Animal a) // 👈 값으로 받음 → slicing 발생
{
std::cout << a.speak() << '\n';
}
void printByRef(const Animal &a) // 👈 참조로 받음 → slicing 없음
{
std::cout << a.speak() << '\n';
}
int main()
{
Dog d{};
printByValue(d); // 출력: Animal sound
printByRef(d); // 출력: Woof!
}
slicing이 일어나는 예시
printByValue(d)에서Dog→Animal로 복사되면서,Dog의speak()오버라이딩은 잘리고(Base part만 살아남고)Animal::speak()만 남는다.- 반면
printByRef(d)는 슬라이싱 없이 Dog 전체 객체를 참조하므로Dog::speak()가 호출된다.
복사가 불가능한 타입도 참조 가능
일부 클래스는 복사 생성자나 복사 할당 연산자를 명시적으로 삭제(delete)하거나, 컴파일러에 의해 암묵적으로 삭제되는 경우가 있다. 이런 타입은 값으로 전달하거나 복사하는 모든 코드가 컴파일 에러가 나지만, const &로는 안전하게 참조할 수 있다.
예시 코드는 다음과 같다.
#include <iostream>
class NonCopyable
{
public:
NonCopyable() = default;
NonCopyable(const NonCopyable &) = delete; // 복사 금지
NonCopyable &operator=(const NonCopyable &) = delete;
void print() const
{
std::cout << "I'm non-copyable!\n";
}
};
void byValue(NonCopyable n) // ❌ 복사 시도 → 컴파일 에러
{
n.print();
}
void byRef(const NonCopyable &n) // ✅ 참조 → OK
{
n.print();
}
int main()
{
NonCopyable obj;
// byValue(obj); // ❌ 컴파일 에러
byRef(obj); // ✅ 정상 실행
}