C++ 얕은 복사 vs 깊은 복사, 그리고 이동 의미론(Move Semantics) [#33-2]
이 글의 핵심
C++ 얕은 복사 vs 깊은 복사, 그리고 이동 의미론(Move Semantics) [#33-2]에 대한 실전 가이드입니다.
들어가며: 면접관이 “이동 의미론이 왜 필요하죠?”라고 물었을 때
14번 시리즈의 매운맛 — 면접용 압축
C++ 실전 가이드 #14-1: Move Semantics와 rvalue 참조에서 이동 의미론의 개념과 사용법을 다뤘습니다. 이 글은 면접에서 자주 나오는 순서로 정리합니다. “얕은 복사와 깊은 복사의 차이” → “복사 생성자/대입 연산자” → “이동이 왜 필요한가” → “rvalue 참조와 std::move”까지, 대본(Script) 형태로 외워 두었다가 말할 수 있게 구성했습니다.
실제 겪는 문제: “복사 때문에 프로그램이 느려요”
면접에서 이동 의미론을 물을 때, 면접관은 단순히 문법만 묻지 않습니다. “왜 필요한가?”를 묻습니다. 실무에서는 이런 상황이 자주 발생합니다:
- 대용량 벡터를 함수에서 반환할 때: 예전 C++에서는 임시 객체를 복사해서 넘겨야 해서 메모리 할당·복제 비용이 컸습니다.
- 컨테이너에 큰 객체를 push_back할 때: 복사 생성자가 호출되면 내부 버퍼 전체가 새로 할당되고 내용이 복제됩니다.
- 포인터 멤버가 있는 클래스를 그냥 복사하면: 같은 메모리를 가리키게 되어 이중 해제(double free)나 dangling pointer 위험이 생깁니다.
이 글은 이런 문제들의 원인(얕은 복사 vs 깊은 복사)과 해결책(이동 의미론)을 면접 답변 형식으로 정리합니다.
구체적인 문제 시나리오
실무에서 자주 마주치는 구체적인 상황을 정리했습니다.
시나리오 1: JSON 파싱 결과 전달
대용량 JSON을 파싱한 nlohmann::json 객체를 여러 함수에 전달할 때, 복사만 사용하면 메모리 사용량이 급증합니다. 이동을 활용하면 포인터만 넘기므로 메모리 효율이 좋아집니다.
시나리오 2: 스레드 풀 작업 큐
std::function이나 std::packaged_task를 큐에 넣을 때 복사하면 내부 캡처된 객체까지 복사됩니다. std::move로 이동하면 캡처된 큰 벡터·맵을 복제하지 않고 큐로 넘길 수 있습니다.
시나리오 3: 네트워크 버퍼 전달
수신한 std::vector<uint8_t> 버퍼를 파싱 함수로 넘길 때, 복사 시 패킷 크기만큼 메모리 할당과 복사가 발생합니다. 이동으로 넘기면 O(1)에 전달할 수 있습니다.
시나리오 4: 빌더 패턴에서 객체 조립
빌더가 여러 단계에서 std::string, std::vector 등을 누적한 뒤 최종 객체를 반환할 때, 이동을 쓰지 않으면 각 단계마다 복사가 발생합니다.
시나리오 5: 로그 메시지 큐
로그 시스템에서 std::string 메시지를 큐에 넣을 때, 복사하면 로그가 많을수록 메모리와 CPU 사용량이 증가합니다. std::move로 이동하면 메시지 버퍼를 복제하지 않고 큐로 넘깁니다.
시나리오 6: 데이터베이스 결과셋 전달
쿼리 결과를 담은 std::vector<Row>를 파싱 레이어로 넘길 때, 복사 시 수천 행 × 수십 컬럼이 모두 메모리에 중복됩니다. 이동으로 넘기면 O(1)에 소유권만 이전됩니다.
시나리오 7: 직렬화/역직렬화 파이프라인
std::string JSON을 파싱해 nlohmann::json으로 변환한 뒤, 내부 std::map을 다음 처리 단계로 넘길 때, 각 단계마다 복사가 발생하면 메모리 사용량이 단계 수만큼 배가됩니다.
일상 비유로 이해하기
- 얕은 복사: 사진을 복사할 때, “원본 파일 경로”만 복사해서 두 사람이 같은 파일을 가리키게 됨. 한쪽이 파일을 삭제하면 다른 쪽은 깨진 링크를 보게 됨.
- 깊은 복사: 사진 파일 자체를 새로 복제해서 각자 독립된 파일을 가지게 됨. 한쪽이 삭제해도 다른 쪽에 영향 없음.
- 이동: “이사할 때 가구를 통째로 들고 가는 것”과 “가구를 하나씩 복제해서 새 집에 놓는 것”의 차이. 더 이상 쓰지 않는 객체는 복제할 필요 없이 소유권만 넘기면 되므로 이동이 훨씬 빠름.
이 글에서 다루는 것:
- 얕은 복사(포인터 값만 복사—원본과 같은 메모리를 가리킴) vs 깊은 복사(가리키는 자원까지 새로 복제): 포인터만 복사 vs 자원까지 새로 복제
- 복사 생성자·복사 대입: Rule of Three/Five(생성자·소멸자·복사 관련 함수를 함께 정의하는 C++ 관례)
- 이동 의미론이 필요한 이유: 불필요한 복사 제거, 자원 “넘기기”
- rvalue 참조, std::move, 이동 후 원본 상태
목차
- 얕은 복사 vs 깊은 복사
- 복사 생성자와 복사 대입
- 이동 의미론이 왜 필요한가 (면접 대본)
- rvalue 참조와 std::move
- Perfect Forwarding (완벽한 전달)
- 일반적인 실수와 주의사항
- 성능 비교
- 모범 사례 (Best Practices)
- 프로덕션 패턴
- 면접 Q&A 정리
1. 얕은 복사 vs 깊은 복사
개념 시각화
얕은 복사와 깊은 복사의 차이를 아래 다이어그램으로 이해할 수 있습니다.
flowchart TB
subgraph shallow["얕은 복사 (Shallow Copy)"]
S1[원본 객체] -->|포인터 값만 복사| S2[복사본 객체]
S1 -.->|같은 메모리 가리킴| M1["(힙 메모리)"]
S2 -.->|같은 메모리 가리킴| M1
end
subgraph deep["깊은 복사 (Deep Copy)"]
D1[원본 객체] -->|자원 새로 할당·복제| D2[복사본 객체]
D1 -.->|가리킴| M2["(힙 메모리 A)"]
D2 -.->|가리킴| M3["(힙 메모리 B)"]
end
얕은 복사 (Shallow Copy)
- 포인터(주소) 값만 그대로 복사합니다.
- 복사본과 원본이 같은 메모리를 가리키게 됩니다.
- 문제: 한쪽에서
delete하면 다른 쪽은 dangling pointer가 되고, 이중 해제(double free) 위험도 있습니다.
class Bad {
int* data;
public:
Bad(int n) : data(new int[n]) {}
~Bad() { delete[] data; }
// 복사 생성자 없음 → 컴파일러가 기본 제공: 멤버 단순 복사 = 얕은 복사
};
int main() {
Bad a(100);
Bad b = a; // b.data == a.data → 같은 포인터!
// 소멸 시 a, b 둘 다 같은 메모리 해제 시도 → 위험
return 0;
}
코드 설명:
Bad a(100);: 100개 int를 힙에 할당하고data가 가리킵니다.Bad b = a;: 컴파일러가 제공하는 기본 복사 생성자가 호출됩니다. 이때b.data = a.data처럼 포인터 값만 복사됩니다.- 결과:
a.data와b.data가 같은 주소를 가리킵니다. - 소멸 시:
a가 먼저 소멸되면delete[] data로 메모리가 해제되고,b가 소멸될 때 이미 해제된 메모리를 다시 해제하려 해서 이중 해제(double free) 버그가 발생합니다.
깊은 복사 (Deep Copy)
- 자원(힙 메모리 등) 을 새로 할당하고 내용을 복제합니다.
- 복사본은 독립된 자원을 가지므로, 한쪽을 해제해도 다른 쪽에 영향이 없습니다.
- 대신: 할당·복제 비용이 들고, 복사 생성자·복사 대입을 직접 구현해야 합니다.
#include <algorithm>
class Good {
int* data;
size_t size;
public:
Good(int n) : data(new int[n]), size(n) {}
~Good() { delete[] data; }
// 깊은 복사: 새 메모리 할당 후 내용 복제
Good(const Good& other) : data(new int[other.size]), size(other.size) {
std::copy(other.data, other.data + other.size, data);
}
Good& operator=(const Good& other) {
if (this != &other) {
delete[] data;
data = new int[other.size];
size = other.size;
std::copy(other.data, other.data + other.size, data);
}
return *this;
}
};
코드 상세 설명:
복사 생성자:
data(new int[other.size]): 원본과 같은 크기의 새 메모리를 할당합니다.std::copy(...): 원본의 모든 데이터를 새 메모리로 복제합니다.- 결과: 원본과 복사본이 각자 독립적인 메모리를 가집니다.
복사 대입 연산자:
if (this != &other): 자기 대입 (a = a) 방지. 이 검사가 없으면 자기 대입 시delete[] data로 자신의 메모리를 해제한 뒤, 이미 해제된other.data를 참조하게 됩니다.delete[] data: 기존에 가지고 있던 메모리를 먼저 해제합니다. 복사 생성자와의 차이: 생성자는 새 객체를 만드는 것이므로 기존 리소스가 없습니다.data = new int[other.size]: 새 메모리를 할당하고 내용을 복제합니다.
면접에서 “얕은 복사는 포인터만 복사해서 같은 자원을 가리키고, 깊은 복사는 자원을 새로 할당해 독립된 복사본을 만든다”라고 말할 수 있으면 됩니다.
얕은 복사 vs 깊은 복사 선택 기준
- 얕은 복사가 적합한 경우: 거의 없음. 포인터만 복사하면 소유권이 불명확해지고, 이중 해제·dangling pointer 위험이 있습니다. 공유 자원이 필요하면
std::shared_ptr등을 사용하는 것이 안전합니다. - 깊은 복사가 적합한 경우: 복사본이 원본과 독립적으로 존재해야 할 때. 예: 설정 객체를 복사해 수정해도 원본에 영향이 없어야 할 때.
- 이동이 적합한 경우: “더 이상 이 객체를 쓰지 않는다”는 것이 명확할 때. 반환값,
push_back에 넣은 뒤 버리는 객체, swap 등.
2. 복사 생성자와 복사 대입
Rule of Three / Five
flowchart LR
subgraph rule3["Rule of Three"]
R1[소멸자]
R2[복사 생성자]
R3[복사 대입]
end
subgraph rule5["Rule of Five (C++11+)"]
R1
R2
R3
R4[이동 생성자]
R5[이동 대입]
end
- Rule of Three: 소멸자, 복사 생성자, 복사 대입 연산자 중 하나라도 직접 정의하면, “자원을 직접 관리한다”는 뜻이므로 셋 다 고려해야 한다.
- Rule of Five: C++11 이후 이동을 쓰면, 이동 생성자와 이동 대입 연산자까지 포함해 다섯 개(소멸자, 복사 2개, 이동 2개)를 함께 설계하는 것이 좋다.
즉, 포인터 멤버로 힙 메모리를 관리하는 클래스는:
- 기본 복사만 쓰면 → 얕은 복사 → 위험.
- 그래서 복사 생성자·복사 대입에서 깊은 복사를 구현하고,
- 필요하면 이동 생성자·이동 대입에서 자원을 “넘기는” 식으로 구현합니다.
Rule of Five 구현 예시
#include <algorithm>
#include <utility>
class Resource {
int* data;
size_t size;
public:
Resource(size_t n) : data(new int[n]), size(n) {}
~Resource() { delete[] data; }
// 복사 생성자
Resource(const Resource& other) : data(new int[other.size]), size(other.size) {
std::copy(other.data, other.data + other.size, data);
}
// 복사 대입
Resource& operator=(const Resource& other) {
if (this != &other) {
delete[] data;
data = new int[other.size];
size = other.size;
std::copy(other.data, other.data + other.size, data);
}
return *this;
}
// 이동 생성자
Resource(Resource&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
// 이동 대입
Resource& operator=(Resource&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
};
이동 생성자·이동 대입의 핵심:
data(other.data): 원본의 포인터만 가져옵니다. 메모리 할당·복제 없음.other.data = nullptr: 원본이 소멸될 때delete[] nullptr는 안전하게 아무 일도 하지 않으므로, 이중 해제를 막습니다.noexcept:std::vector등이 재할당 시 이동을 사용할 수 있게 합니다.noexcept가 없으면 복사를 사용해 성능이 떨어질 수 있습니다.
Rule of Five 호출 시나리오
| 코드 | 호출되는 함수 |
|---|---|
T b = a; | 복사 생성자 |
T c = std::move(a); | 이동 생성자 |
b = c; | 복사 대입 |
b = std::move(c); | 이동 대입 |
T d = f(); (f가 지역 T 반환) | RVO 또는 이동 생성자 |
Traced 같은 추적 구조체를 만들어 각 생성자/대입에 std::cout을 넣으면 호출 순서를 확인할 수 있습니다.
Rule of Five 완전한 예제: Copy-and-Swap 관용구
복사 대입 연산자를 예외 안전하게 구현하는 Copy-and-Swap 관용구를 적용한 예제입니다. 복사 생성자를 활용해 중복 코드를 줄이고, 자기 대입·예외 안전성을 동시에 확보합니다.
#include <algorithm>
#include <utility>
class Buffer {
int* data;
size_t size;
public:
Buffer(size_t n) : data(new int[n]), size(n) {}
~Buffer() { delete[] data; }
// 복사 생성자
Buffer(const Buffer& other) : data(new int[other.size]), size(other.size) {
std::copy(other.data, other.data + other.size, data);
}
// Copy-and-Swap: 복사 대입 = 복사 생성 + swap
Buffer& operator=(Buffer other) { // 값으로 받음 → 복사 또는 이동
swap(*this, other);
return *this;
}
// 이동 생성자
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data, b.data);
swap(a.size, b.size);
}
};
Copy-and-Swap의 동작:
a = b(lvalue):operator=(Buffer other)에서other가 복사 생성됨 →swap(*this, other)로 내용 교환.a = std::move(b)(rvalue):other가 이동 생성됨 → 복사 없이 포인터만 넘어옴 →swap으로 교환.- 자기 대입
a = a:other가a의 복사본이므로,swap후other가 기존a의 자원을 갖고, 함수 종료 시other소멸로 해제됨. 안전함. - 예외 안전: 복사 생성에서 예외가 나면
*this는 변경되지 않음.
Rule of Five 체크리스트
자원을 직접 관리하는 클래스를 설계할 때 확인할 항목입니다.
| 항목 | 구현 여부 | 비고 |
|---|---|---|
| 소멸자 | ✓ | delete[] / delete 등 자원 해제 |
| 복사 생성자 | ✓ | 깊은 복사 (새 메모리 할당 + 복제) |
| 복사 대입 | ✓ | 자기 대입 방지, Copy-and-Swap 권장 |
| 이동 생성자 | ✓ | noexcept 지정, 원본 null 초기화 |
| 이동 대입 | ✓ | noexcept 지정, 기존 자원 해제 후 이동 |
3. 이동 의미론이 왜 필요한가 (면접 대본)
복사 vs 이동 시각화
flowchart LR
subgraph copy["복사"]
C1[원본] --> C2[데이터 복제]
C2 --> C3[대상]
C1 -.->|유지| C1
end
subgraph move["이동"]
M1[원본] --> M2[포인터/핸들만 이전]
M2 --> M3[대상]
M1 -.->|빈 상태| M1
end
면접관: “이동 의미론이 왜 필요하죠?”
답변 예시:
“불필요한 복사를 줄이기 위해서입니다. 예를 들어 큰 벡터를 함수에서 반환할 때, 예전에는 임시 객체를 복사해서 넘겨야 해서 비용이 컸습니다. 이동 의미론에서는 ‘더 이상 쓰지 않는’ 객체의 자원(메모리 블록 같은 것)을 그대로 넘깁니다. 복사처럼 새로 할당하고 내용을 복제하는 게 아니라, 포인터만 바꿔서 소유권을 넘기는 것이라 훨씬 빠릅니다. 그래서 반환값, std::vector::push_back(std::move(x)) 같은 곳에서 복사 대신 이동이 일어나도록 하고, 성능과 자원 관리 모두 잡을 수 있습니다.”
한 줄 요약
- 복사: 자원을 또 하나 만들고 내용을 복제 → 비용 큼.
- 이동: 자원을 그대로 넘기고 원본은 “비어 있는” 상태로 둠 → 비용 작음.
이동이 “필요한” 이유는 그 비용 차이를 활용하기 위해서입니다.
실전 예시 1: 벡터 반환
#include <vector>
// C++11 이전: 반환 시 복사 발생 (비용 큼)
std::vector<int> createVector() {
std::vector<int> vec(1000000);
// 데이터 채우기...
return vec; // C++11: 자동으로 이동 (또는 RVO)
}
int main() {
std::vector<int> data = createVector(); // 복사 없음, 이동만
return 0;
}
왜 C++11에서는 이동이 일어나나?
return vec;에서 vec는 곧 파괴될 지역 객체입니다. C++11 표준에서는 이런 객체를 자동으로 rvalue로 취급하므로, 반환 시 이동 생성자가 선택됩니다. RVO(Return Value Optimization)가 적용되면 복사·이동 자체가 없을 수도 있고, 적용되지 않더라도 이동만 발생합니다.
실전 예시 2: swap 구현 (이동 활용)
두 객체의 내용을 바꿀 때, 복사를 쓰면 큰 객체일수록 비용이 큽니다. 이동을 쓰면 포인터만 교환하므로 훨씬 빠릅니다.
#include <utility>
template <typename T>
void mySwap(T& a, T& b) {
T temp = std::move(a); // a를 temp로 이동
a = std::move(b); // b를 a로 이동
b = std::move(temp); // temp를 b로 이동
}
// 사용 예
#include <vector>
int main() {
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = {4, 5, 6};
mySwap(v1, v2); // v1과 v2의 내용이 바뀜 (복사 없이)
return 0;
}
전통적인 swap (복사 사용)은 T temp = a; a = b; b = temp;로 총 3번의 복사가 발생합니다. 이동을 사용하면 포인터만 3번 교환하므로, 큰 벡터나 문자열을 swap할 때 성능 차이가 큽니다.
실전 예시 3: unique_ptr 이동
std::unique_ptr은 복사가 불가능하고 이동만 가능합니다. 소유권이 하나뿐이기 때문입니다.
#include <memory>
#include <iostream>
int main() {
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// std::unique_ptr<int> ptr2 = ptr1; // ❌ 에러: 복사 불가
std::unique_ptr<int> ptr2 = std::move(ptr1); // ✅ 이동
if (!ptr1) {
std::cout << "ptr1 is null (소유권 이전됨)\n";
}
std::cout << "*ptr2 = " << *ptr2 << "\n"; // 42
return 0;
}
실행 결과: ptr1 is null (소유권 이전됨)과 *ptr2 = 42가 출력됩니다. unique_ptr은 이동 후 원본이 nullptr가 됩니다.
4. rvalue 참조와 std::move
rvalue 참조 (T&&)
- rvalue(임시 객체, 반환값,
std::move결과 등)에만 바인딩되는 참조입니다. - “이 값은 곧 사라질 거니까, 복사하지 말고 가져와도 된다”는 의도를 표현합니다.
- 이동 생성자, 이동 대입 연산자는 보통
T(T&&)와T& operator=(T&&)로 선언하고, 안에서 원본의 자원을 가져온 뒤 원본의 포인터를nullptr등으로 비워 둡니다.
std::move
- 이름만 있는 객체(lvalue) 를 “이동할 수 있는 값”으로 취급하게 만드는 캐스팅입니다.
std::move(x)는 “x의 자원을 넘겨도 된다”고 컴파일러에게 알려 주고, 그러면 이동 생성자/이동 대입이 선택됩니다.- 이동 후 원본은 “유효하지만 값이 지정되지 않은(unspecified)” 상태로 두는 것이 표준 규약입니다. 보통 더 이상 쓰지 않거나, 재할당 전까지 사용하지 않습니다.
#include <vector>
#include <iostream>
int main() {
std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a);
std::cout << "a.size()=" << a.size() << " b.size()=" << b.size() << "\n";
return 0;
}
실행 결과: a.size()=0 b.size()=3 이 한 줄 출력됩니다.
코드 설명:
std::move(a):a를 rvalue로 캐스팅합니다. 실제로 이동하는 것은 아닙니다. 캐스팅만 합니다.b의 이동 생성자가 호출되어a의 내부 버퍼를 가져갑니다.- 이동 후
a는 빈 벡터(크기 0)가 됩니다.std::vector는 이동 후 빈 상태를 보장합니다.
면접에서는 “std::move는 lvalue를 rvalue로 캐스팅해서 이동 연산을 쓰게 만든다”, “이동 후에는 원본을 안 쓰거나, 재할당 후에만 쓴다” 정도로 말할 수 있으면 됩니다.
컨테이너에 이동으로 추가하기
#include <vector>
#include <string>
int main() {
std::vector<std::string> names;
std::string name = "Alice";
// ❌ 복사: name의 내용이 벡터로 복사됨
names.push_back(name);
// ✅ 이동: name의 내부 버퍼가 벡터로 넘어감 (name은 빈 문자열)
names.push_back(std::move(name));
return 0;
}
rvalue 참조 매개변수에서 std::move가 필요한 이유
많은 사람이 헷갈려하는 부분입니다. rvalue 참조 타입(T&&)으로 받은 매개변수는, 함수 안에서는 이름이 있는 변수이므로 lvalue입니다. 따라서 실제로 이동하려면 std::move로 다시 rvalue로 캐스팅해야 합니다.
#include <vector>
class Container {
std::vector<int> data;
public:
// vec는 rvalue 참조 타입이지만, 함수 안에서 vec는 lvalue!
Container(std::vector<int>&& vec) : data(std::move(vec)) {}
// ^^^^^^^^^^^^^^^^
// std::move 없으면 복사 발생!
};
int main() {
std::vector<int> temp = {1, 2, 3};
Container c(std::move(temp)); // temp → vec로 전달, vec → data로 이동
return 0;
}
핵심 규칙: rvalue 참조 타입은 “이동 가능한 값을 받는다”는 의미이지만, 이름 있는 변수는 항상 lvalue입니다. 초기화 리스트나 대입에서 실제 이동을 일으키려면 std::move를 써야 합니다. 이 규칙은 Perfect Forwarding에서 std::forward를 쓰는 이유이기도 합니다.
lvalue vs rvalue 오버로딩
같은 함수를 T&와 T&&로 오버로딩하면, 전달된 값이 lvalue인지 rvalue인지에 따라 다른 버전이 선택됩니다. 이렇게 “lvalue면 복사, rvalue면 이동”처럼 분기할 수 있습니다.
#include <iostream>
#include <string>
void process(const std::string& s) {
std::cout << "lvalue (복사 가능): " << s << "\n";
}
void process(std::string&& s) {
std::cout << "rvalue (이동 가능): " << s << "\n";
}
int main() {
std::string a = "Hello";
process(a); // lvalue (복사 가능): Hello
process(std::move(a)); // rvalue (이동 가능): Hello
process("World"); // rvalue (이동 가능): World (리터럴은 rvalue)
return 0;
}
5. Perfect Forwarding (완벽한 전달)
왜 Perfect Forwarding이 필요한가
래퍼 함수에서 인자를 내부 함수로 넘길 때, “lvalue로 받으면 lvalue로, rvalue로 받으면 rvalue로” 그 성질을 유지해 전달해야 불필요한 복사를 막을 수 있습니다. 값으로 받으면 항상 복사가 발생하고, rvalue 참조만 쓰면 lvalue를 받을 수 없습니다. 유니버설 참조(T&&)와 std::forward를 쓰면 두 경우를 한 번에 처리합니다.
문제와 해결
값으로 받으면 arg는 함수 안에서 lvalue가 되어, rvalue를 넘겨도 process(arg)는 항상 lvalue 오버로드만 호출됩니다. T&&와 std::forward를 쓰면 원래 성질을 유지해 전달할 수 있습니다.
#include <iostream>
#include <string>
#include <utility>
void process(const std::string& s) { std::cout << "lvalue: " << s << "\n"; }
void process(std::string&& s) { std::cout << "rvalue: " << s << "\n"; }
// ✅ T&&: lvalue면 lvalue 참조, rvalue면 rvalue 참조로 추론 (유니버설 참조)
template <typename Arg>
void goodWrapper(Arg&& arg) {
process(std::forward<Arg>(arg)); // 원래 성질 유지
}
int main() {
std::string text = "Hello";
goodWrapper(text); // lvalue: Hello
goodWrapper(std::string("Hi")); // rvalue: Hi ✅
goodWrapper("World"); // rvalue: World (리터럴은 rvalue)
return 0;
}
std::forward vs std::move: std::move는 lvalue를 항상 rvalue로 캐스팅합니다. std::forward는 템플릿 래퍼에서 인자를 넘길 때 원래 lvalue/rvalue 성질을 유지합니다.
실전 예: emplace_back의 원리
std::vector::emplace_back은 생성 인자를 받아 원소를 제자리에서 생성합니다. Perfect Forwarding으로 인자를 생성자에 그대로 넘깁니다. v.emplace_back(1, "hello");처럼 호출하면 pair(1, "hello")가 복사/이동 없이 직접 생성됩니다.
참고: Perfect Forwarding의 상세한 동작(참조 축약, 유니버설 참조 조건)은 C++ 실전 가이드 #14-2: Perfect Forwarding에서 다룹니다.
6. 일반적인 실수와 주의사항
실수 1: 반환값에 std::move 붙이기 (RVO 방해)
// ❌ 나쁜 예: RVO 기회를 날림
std::vector<int> createBad() {
std::vector<int> vec(1000);
return std::move(vec); // 불필요! RVO 방해
}
// ✅ 좋은 예: 컴파일러가 RVO 또는 이동 적용
std::vector<int> createGood() {
std::vector<int> vec(1000);
return vec; // RVO 또는 이동
}
왜 return std::move(vec)가 나쁜가?
return vec만 쓰면 컴파일러가 반환값을 받을 자리에 vec를 직접 생성할 수 있습니다(RVO). return std::move(vec)를 쓰면 “이미 만든 vec을 이동해서 반환해라”라고 강제해 RVO 조건을 깨고, 오히려 이동 한 번을 강제하게 됩니다. 지역 변수를 반환할 때는 std::move를 붙이지 마세요.
실수 2: 이동 후 원본 사용
#include <string>
#include <iostream>
int main() {
std::string str = "Hello";
std::string str2 = std::move(str);
// ❌ 위험: 이동된 객체 사용 (보통 빈 문자열이지만 보장 안 됨)
std::cout << str << "\n";
// ✅ 재할당 후에는 사용 가능
str = "World";
std::cout << str << "\n"; // "World"
return 0;
}
이동 후 원본은 “유효하지만 unspecified” 상태입니다. std::string은 보통 빈 문자열이 되지만, 표준이 값을 보장하지 않으므로 이동한 뒤에는 쓰지 않는다고 생각하고 코드를 짜는 것이 안전합니다.
실수 3: 이동 생성자에 noexcept 누락
// ❌ 나쁜 예: vector 재할당 시 복사 사용 (느림)
class Slow {
std::vector<int> data;
public:
Slow(Slow&& other) { data = std::move(other.data); }
};
// ✅ 좋은 예: vector 재할당 시 이동 사용 (빠름)
class Fast {
std::vector<int> data;
public:
Fast(Fast&& other) noexcept { data = std::move(other.data); }
};
std::vector는 재할당 시 원소를 새 버퍼로 옮길 때, 이동 생성자가 noexcept이면 이동을 쓰고, 아니면 복사를 씁니다. 이동 중 예외가 나면 “일부만 옮겨진” 상태가 되어 복구하기 어렵기 때문입니다.
실수 4: const rvalue 참조
// ❌ 나쁜 예: const이므로 이동 불가
void process(const std::string&& str) {
// str을 이동할 수 없음
}
// ✅ 좋은 예
void process(std::string&& str) {
std::string local = std::move(str); // 이동 가능
}
실수 5: 복사 대입에서 자기 대입 검사 누락
#include <algorithm>
#include <cstddef>
// ❌ 나쁜 예: a = a 시 delete[] data 후 other.data 접근 → undefined behavior
class BadAssign {
int* data;
size_t size;
public:
BadAssign& operator=(const BadAssign& other) {
delete[] data; // a = a일 때 자신의 메모리 해제!
data = new int[other.size]; // other.data는 이미 해제됨 → 접근 시 UB
size = other.size;
std::copy(other.data, other.data + size, data);
return *this;
}
};
// ✅ 좋은 예: 자기 대입 검사
class GoodAssign {
int* data;
size_t size;
public:
GoodAssign& operator=(const GoodAssign& other) {
if (this != &other) {
delete[] data;
data = new int[other.size];
size = other.size;
std::copy(other.data, other.data + other.size, data);
}
return *this;
}
};
실수 6: 이동 대입에서 자기 대입 검사 누락
// ❌ 나쁜 예: a = std::move(a) 시 other.data를 nullptr로 만들면 자신의 data도 nullptr
Resource& operator=(Resource&& other) noexcept {
delete[] data; // 자신의 메모리 해제
data = other.data; // other가 자기 자신이면 data는 이미 해제된 포인터
other.data = nullptr; // 자신의 data를 nullptr로 → 메모리 누수
return *this;
}
// ✅ 좋은 예
Resource& operator=(Resource&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
실수 7: std::move를 남용 (불필요한 이동)
// ❌ 나쁜 예: name을 계속 사용해야 하는데 이동
void process(const std::string& name) {
names.push_back(std::move(name)); // ❌ const 참조는 이동 불가
}
void greet(std::string name) {
std::cout << "Hello, " << name << "\n";
log.push_back(std::move(name)); // ❌ name을 아직 출력에 썼는데 이동
}
// ✅ 좋은 예: 더 이상 쓰지 않을 때만 이동
void addName(std::string name) {
log.push_back(std::move(name)); // name은 값으로 받음, 이후 사용 안 함
}
실수 8: 기본 복사/이동에 의존
// ❌ 나쁜 예: 포인터 멤버가 있는데 복사 생성자 없음
class RawPtrHolder {
int* ptr;
public:
RawPtrHolder() : ptr(new int(42)) {}
~RawPtrHolder() { delete ptr; }
// 복사 생성자 없음 → 컴파일러 기본 제공 = 얕은 복사
};
int main() {
RawPtrHolder a;
RawPtrHolder b = a; // b.ptr == a.ptr → 이중 해제
return 0;
}
해결: Rule of Three/Five를 적용해 복사 생성자·복사 대입(및 필요 시 이동)을 구현하거나, std::unique_ptr 등 스마트 포인터로 자원을 관리합니다.
실수 9: Perfect Forwarding에서 std::forward 누락
// ❌ 나쁜 예: arg가 lvalue로만 전달됨
template <typename T>
void wrapper(T&& arg) {
process(arg); // arg는 이름이 있으므로 lvalue → 항상 복사
}
// ✅ 좋은 예
template <typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg)); // lvalue/rvalue 성질 유지
}
실수 10: 이동 불가 타입을 이동 생성자에서 복사
#include <mutex>
#include <vector>
// ❌ 나쁜 예: mutex는 이동 불가인데 복사 시도
class BadSync {
std::mutex mtx;
std::vector<int> data;
public:
BadSync(BadSync&& other) : data(std::move(other.data)) {
mtx = std::move(other.mtx); // ❌ mutex는 이동 불가
}
};
// ✅ 좋은 예: 이동 불가 멤버는 새로 생성
class GoodSync {
std::mutex mtx;
std::vector<int> data;
public:
GoodSync(GoodSync&& other) noexcept
: mtx{}, data(std::move(other.data)) {} // mtx는 기본 생성
};
7. 성능 비교
복사 vs 이동 벤치마크
#include <chrono>
#include <iostream>
#include <vector>
#include <string>
void testCopy() {
auto start = std::chrono::high_resolution_clock::now();
std::vector<std::string> vec1(10000, std::string(1000, 'x'));
std::vector<std::string> vec2 = vec1; // 복사
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Copy: " << ms << " ms\n";
}
void testMove() {
auto start = std::chrono::high_resolution_clock::now();
std::vector<std::string> vec1(10000, std::string(1000, 'x'));
std::vector<std::string> vec2 = std::move(vec1); // 이동
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Move: " << ms << " ms\n";
}
int main() {
testCopy(); // 환경에 따라 수십~수백 ms
testMove(); // 보통 1ms 미만
return 0;
}
예상 결과 (환경에 따라 다름):
- Copy: 수십~수백 ms (10,000개 문자열 × 1000자 = 약 10MB 복사)
- Move: 1ms 미만 (포인터만 교환)
이동이 복사보다 비용이 훨씬 작다는 점이 핵심입니다. 큰 컨테이너나 리소스를 넘길 때 이동을 쓰면 체감 성능이 크게 나아집니다.
요약 표
| 항목 | 복사 | 이동 |
|---|---|---|
| 메모리 할당 | 새로 할당 | 없음 |
| 데이터 복제 | 전체 복제 | 포인터만 교환 |
| 시간 복잡도 | O(n) | O(1) |
| 원본 상태 | 유지 | unspecified (보통 빈 상태) |
언제 복사, 언제 이동?
| 상황 | 권장 | 이유 |
|---|---|---|
| 함수 반환 (지역 변수) | return vec; (std::move 없음) | RVO 또는 자동 이동 |
| 컨테이너에 추가 (더 이상 안 쓸 객체) | push_back(std::move(x)) | 복사 대신 이동 |
| swap | std::swap(a, b) | 이미 이동 활용 |
| unique_ptr 전달 | std::move(ptr) | 복사 불가, 이동만 가능 |
| 임시 객체를 인자로 받을 때 | void f(T&& t) | rvalue 오버로드로 이동 |
8. 모범 사례 (Best Practices)
8.1 복사 vs 이동 선택 가이드
위 성능 비교 섹션의 “언제 복사, 언제 이동?” 표를 참고하세요. 핵심은 지역 변수 반환 시 std::move 금지, 더 이상 쓰지 않을 객체만 std::move로 이동하는 것입니다.
8.2 이동 생성자/대입 작성 시 체크리스트
- noexcept 지정:
std::vector등이 재할당 시 이동을 선택하도록 - 원본 null 초기화:
other.ptr = nullptr등으로 이중 해제 방지 - 자기 대입 검사:
if (this != &other)(이동 대입에서도 필요) - 기존 자원 해제: 이동 대입 시
delete등으로 기존 자원 먼저 해제
8.3 스마트 포인터 우선 사용
자원 관리가 복잡해지면 Rule of Five를 직접 구현하기보다 std::unique_ptr, std::shared_ptr를 사용하는 것이 안전합니다. 스마트 포인터는 복사·이동 의미론이 이미 정의되어 있습니다.
// ✅ unique_ptr: 복사 불가, 이동만 가능
class SafeHolder {
std::unique_ptr<int> ptr;
public:
SafeHolder() : ptr(std::make_unique<int>(42)) {}
// 복사 생성자·대입 = delete (기본)
// 이동 생성자·대입 = 자동 생성
};
8.4 Zero-Rule: 자원 관리 회피
가능하면 직접 자원을 관리하지 않는 설계를 추구합니다. std::vector, std::string 등 표준 라이브러리 타입을 멤버로 쓰면 복사·이동이 자동으로 올바르게 동작합니다.
// ✅ 자원 관리 없음: vector가 알아서 처리
class SimpleData {
std::vector<int> data;
public:
// 복사·이동 생성자/대입 모두 컴파일러 기본 제공
};
8.5 값으로 받고 이동하기 (Pass-by-value + move)
인자를 “값으로 받고” 내부에서 std::move로 저장하면, 호출자가 rvalue를 넘기면 이동, lvalue를 넘기면 복사가 한 번만 발생합니다. 오버로딩을 두 개 만들 필요가 없습니다.
class DataStore {
std::string name_;
public:
// ✅ 값으로 받음: lvalue → 복사 1회, rvalue → 이동
void setName(std::string name) {
name_ = std::move(name);
}
};
8.6 복사/이동 금지가 필요할 때
파일 핸들, 소켓, 뮤텍스처럼 복사가 의미 없는 자원은 복사를 delete로 명시적으로 금지합니다.
class NonCopyable {
int* resource;
public:
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
NonCopyable(NonCopyable&&) = default;
NonCopyable& operator=(NonCopyable&&) = default;
};
9. 프로덕션 패턴
9.1 팩토리 함수에서 이동 반환
// 프로덕션에서 흔한 패턴: 팩토리 함수
std::unique_ptr<Config> loadConfig(const std::string& path) {
auto config = std::make_unique<Config>();
config->parse(path);
return config; // std::move 없이 반환 (RVO/이동)
}
9.2 컨테이너에 emplace_back 활용
// push_back(std::move(x)) 대신 emplace_back으로 생성과 삽입을 한 번에
std::vector<std::pair<int, std::string>> items;
items.emplace_back(1, "hello"); // 복사/이동 없이 직접 생성
9.3 Optional 반환 패턴
#include <optional>
std::optional<std::vector<int>> fetchData() {
std::vector<int> result = /* ... */;
return result; // optional 내부로 이동
}
9.4 RAII와 이동 결합
class FileHandle {
FILE* f;
public:
FileHandle(const char* path) : f(fopen(path, "r")) {}
~FileHandle() { if (f) fclose(f); }
FileHandle(FileHandle&& other) noexcept : f(other.f) { other.f = nullptr; }
FileHandle& operator=(FileHandle&& other) noexcept {
if (this != &other) {
if (f) fclose(f);
f = other.f;
other.f = nullptr;
}
return *this;
}
// 복사 금지
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
};
9.5 Pimpl 관용구에서 이동
// Pimpl: 구현을 숨기면서 이동 지원
class Widget {
struct Impl;
std::unique_ptr<Impl> pImpl;
public:
Widget(Widget&&) = default;
Widget& operator=(Widget&&) = default;
// unique_ptr이 이동만 지원하므로 Widget도 이동만 가능
};
9.6 Perfect Forwarding 팩토리
템플릿 팩토리에서 생성자 인자를 그대로 전달할 때 std::forward를 사용합니다. make_unique<T>(args...) 내부에서 new T(std::forward<Args>(args)...)처럼 전달합니다.
9.7 벡터 재할당 시 이동 활용
std::vector는 push_back으로 용량이 부족해지면 재할당합니다. 이때 원소의 이동 생성자가 noexcept이면 이동으로 옮기고, 아니면 복사를 사용합니다. 커스텀 타입의 이동 생성자에는 반드시 noexcept를 지정하세요.
class Element {
std::vector<int> data;
public:
Element(Element&& other) noexcept // noexcept 필수
: data(std::move(other.data)) {}
};
10. 면접 Q&A 정리
Q: 얕은 복사와 깊은 복사의 차이는?
- 얕은 복사는 포인터 값만 복사해서 같은 자원을 가리키게 하고, 깊은 복사는 자원을 새로 할당해 내용을 복제해 독립된 복사본을 만든다. 자원을 직접 관리하는 클래스는 깊은 복사를 구현해야 이중 해제·dangling pointer를 막을 수 있다.
Q: 복사 생성자와 대입 연산자를 직접 구현해야 하는 경우는?
- 클래스가 힙 메모리 같은 자원을 포인터로 소유할 때. 기본 복사는 얕은 복사라 위험하므로, 복사 생성자·복사 대입에서 깊은 복사를 구현한다 (Rule of Three).
Q: 이동 의미론이 왜 필요한가?
- 불필요한 복사 비용을 줄이기 위해서. “더 이상 쓰지 않는” 객체의 자원을 복제하지 않고 넘기면 할당·복제 비용이 사라져서, 반환값·컨테이너에 넣을 때 성능이 좋아진다.
Q: std::move는 뭘 하나요?
- 인자로 받은 lvalue를 rvalue로 캐스팅해서, 그 객체를 받는 쪽에서 이동 생성자·이동 대입이 선택되게 한다. “이 객체의 자원을 넘겨도 된다”는 의도를 표현한다. 실제로 이동하는 것은 아니고, 캐스팅만 한다.
Q: 이동 후 원본은 어떻게 해야 하나요?
- 유효하지만 값이 지정되지 않은(unspecified) 상태로 둔다. 보통 더 이상 사용하지 않거나, 재할당 후에만 사용한다. 표준 라이브러리 타입(vector 등)도 그렇게 동작한다.
Q: std::forward와 std::move의 차이는?
- std::move: lvalue를 항상 rvalue로 캐스팅. “이 객체는 더 이상 안 쓴다”고 명시할 때 사용.
- std::forward: 템플릿 래퍼에서 인자를 다음 함수에 넘길 때, 원래 lvalue/rvalue 성질을 유지해 전달.
T&&와 함께 사용.
이 정도를 스크립트처럼 정리해 두면, “얕은/깊은 복사 → 복사 생성/대입 → 이동이 필요한 이유 → rvalue, std::move → Perfect Forwarding” 흐름으로 면접에서 답할 수 있습니다.
면접에서 30초 요약
- 얕은 vs 깊은: 얕은 복사는 포인터만 복사해 같은 자원 공유(위험), 깊은 복사는 자원 새로 할당해 독립 복사본 생성.
- Rule of Three/Five: 자원 관리 클래스는 소멸자, 복사 2개, (선택) 이동 2개를 함께 설계.
- 이동이 필요한 이유: 불필요한 복사 비용 제거. 자원을 복제하지 않고 “넘기기”.
- std::move: lvalue를 rvalue로 캐스팅해 이동 연산이 선택되게 함. 이동 후 원본은 unspecified.
11. C++03 vs C++11: 반환값에서의 차이
C++03에는 이동 의미론이 없었기 때문에, 함수에서 큰 객체를 반환할 때 항상 복사가 발생했습니다. 그래서 예전 코드에서는 “큰 객체를 반환하지 말고, 참조 인자로 받아서 채우라”는 관례가 많았습니다.
// C++03 스타일: 반환 대신 out 파라미터 사용
void createVector(std::vector<int>& out) {
out.resize(1000000);
// 데이터 채우기...
}
int main() {
std::vector<int> data;
createVector(data); // 복사 없이 data에 직접 채움
return 0;
}
C++11에서는 return vec;만 해도 이동이 일어나므로, 위와 같은 패턴이 필요 없어졌습니다. 코드가 더 직관적이고, RVO가 적용되면 복사·이동 자체가 없을 수도 있습니다.
스스로 확인해보기
std::vector를 반환하는 함수를 만들고,return vec;와return std::move(vec);각각으로 컴파일한 뒤, 생성자 호출 횟수를 출력해 비교해 보세요.- 포인터 멤버가 있는 간단한 클래스를 만들고, 복사 생성자 없이 복사한 뒤 실행해 보세요. (이중 해제로 프로그램이 비정상 종료될 수 있음)
push_back(name)과push_back(std::move(name))후name의 상태를 출력해 비교해 보세요.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 이동 의미론 완벽 가이드 | rvalue 참조·std::move
- C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화
- C++ Perfect Forwarding | std::forward로 “복사 없이 인자 전달”
이 글에서 다루는 키워드 (관련 검색어)
C++ 복사 이동, 얕은 복사 깊은 복사, Rule of Three, Rule of Five, rvalue 참조, std::move 등으로 검색하시면 이 글이 도움이 됩니다.
정리
- 얕은 복사: 포인터만 복사 → 같은 자원 공유 → 위험. 깊은 복사: 자원 새로 할당·복제 → 독립된 복사본.
- 자원을 직접 관리하는 클래스는 복사 생성자·복사 대입(및 필요 시 소멸자, 이동 2개)를 구현한다 (Rule of Three/Five).
- 이동 의미론: “더 이상 쓰지 않는” 객체의 자원을 복사 없이 넘겨 비용을 줄인다. rvalue 참조와 std::move로 표현한다.
- std::move: lvalue를 rvalue로 캐스팅해 이동 연산이 선택되게 한다. 이동 후 원본은 unspecified 상태로 둔다.
- 주의: 반환값에
std::move붙이지 않기(RVO 방해), 이동 후 원본 사용 금지, 이동 생성자에noexcept지정.
참고: 이 글은 면접 대본 형식으로 압축했습니다. 이동 의미론의 상세한 동작, RVO/NRVO, perfect forwarding은 C++ 실전 가이드 #14-1: Move Semantics와 #14-2: Perfect Forwarding에서 다룹니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 복사 생성자, rvalue reference, std::move를 면접 대본 형식으로 정리했습니다. 실무에서는 위 본문의 모범 사례와 프로덕션 패턴을 참고해 적용하면 됩니다. 특히 std::vector::push_back(std::move(x)), 함수 반환값, swap 구현, 팩토리 함수, emplace_back 등에서 이동을 활용하면 성능이 좋아집니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다. 이동 의미론을 더 깊이 다루는 C++ 실전 가이드 #14-1: Move Semantics도 함께 읽어보면 좋습니다.
Q. 더 깊이 공부하려면?
A. cppreference의 Move semantics, std::move 문서를 참고하세요. C++ 실전 가이드 #14-1: Move Semantics에서 RVO, perfect forwarding 등 더 깊은 내용을 다룹니다.
한 줄 요약: 얕은/깊은 복사와 이동 의미론을 구분하면 리소스 관리와 면접 답변이 명확해집니다. 다음으로 스마트 포인터·순환 참조(#33-3)를 읽어보면 좋습니다.
다음 글: [C++ 면접 #33-3] 스마트 포인터와 순환 참조(Circular Reference) 해결법
이전 글: [C++ 면접 #33-1] 가상 함수(Virtual Function)와 vtable의 동작 원리
관련 글
- C++ 가상 함수(Virtual Function)와 vtable의 동작 원리 [#33-1]
- C++ 스마트 포인터와 순환 참조(Circular Reference) 해결법 [#33-3]
- C++ shared_ptr 순환 참조 완전 정복 | 부모-자식·옵저버·그래프·캐시 패턴 [#33-4]
- C++ Data Race |
- C++ 캐시 히트(Cache Hit)를 높이는 메모리 정렬과 패딩 | False Sharing 해결