C++ 얕은 복사 vs 깊은 복사, 그리고 이동 의미론(Move Semantics) [#33-2]

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, 이동 후 원본 상태

목차

  1. 얕은 복사 vs 깊은 복사
  2. 복사 생성자와 복사 대입
  3. 이동 의미론이 왜 필요한가 (면접 대본)
  4. rvalue 참조와 std::move
  5. Perfect Forwarding (완벽한 전달)
  6. 일반적인 실수와 주의사항
  7. 성능 비교
  8. 모범 사례 (Best Practices)
  9. 프로덕션 패턴
  10. 면접 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.datab.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: othera의 복사본이므로, swapother가 기존 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))복사 대신 이동
swapstd::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::vectorpush_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초 요약

  1. 얕은 vs 깊은: 얕은 복사는 포인터만 복사해 같은 자원 공유(위험), 깊은 복사는 자원 새로 할당해 독립 복사본 생성.
  2. Rule of Three/Five: 자원 관리 클래스는 소멸자, 복사 2개, (선택) 이동 2개를 함께 설계.
  3. 이동이 필요한 이유: 불필요한 복사 비용 제거. 자원을 복제하지 않고 “넘기기”.
  4. 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. cppreferenceMove 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 해결