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

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

이 글의 핵심

C++ 실전 가이드 #14-1: Move Semantics와 rvalue 참조에서 이동 의미론의 개념과 사용법을 다뤘습니다. 이 글은 면접에서 자주 나오는 순서로 정리합니다. 얕은 복사와 깊은 복사의 차이 → 복사… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다.

들어가며: 면접관이 “이동 의미론이 왜 필요하죠?”라고 물었을 때

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, 이동 후 원본 상태 실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

목차

  1. 얕은 복사 vs 깊은 복사
  2. 복사 생성자와 복사 대입
  3. 이동 의미론이 왜 필요한가 (면접 대본)
  4. rvalue 참조와 std::move
  5. Perfect Forwarding (완벽한 전달)
  6. 일반적인 실수와 주의사항
  7. 성능 비교
  8. 모범 사례 (Best Practices)
  9. 프로덕션 패턴
  10. 면접 Q&A 정리

1. 얕은 복사 vs 깊은 복사

개념 시각화

얕은 복사와 깊은 복사의 차이를 아래 다이어그램으로 이해할 수 있습니다. 아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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) 위험도 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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)

  • 자원(힙 메모리 등)새로 할당하고 내용을 복제합니다.
  • 복사본은 독립된 자원을 가지므로, 한쪽을 해제해도 다른 쪽에 영향이 없습니다.
  • 대신: 할당·복제 비용이 들고, 복사 생성자·복사 대입을 직접 구현해야 합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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

아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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 구현 예시

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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 관용구를 적용한 예제입니다. 복사 생성자를 활용해 중복 코드를 줄이고, 자기 대입·예외 안전성을 동시에 확보합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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 이동 시각화

아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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: 벡터 반환

아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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 구현 (이동 활용)

두 객체의 내용을 바꿀 때, 복사를 쓰면 큰 객체일수록 비용이 큽니다. 이동을 쓰면 포인터만 교환하므로 훨씬 빠릅니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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복사가 불가능하고 이동만 가능합니다. 소유권이 하나뿐이기 때문입니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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)” 상태로 두는 것이 표준 규약입니다. 보통 더 이상 쓰지 않거나, 재할당 전까지 사용하지 않습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고. 코드를 직접 실행해보면서 동작을 확인해보세요.
#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로 캐스팅해서 이동 연산을 쓰게 만든다”, “이동 후에는 원본을 안 쓰거나, 재할당 후에만 쓴다” 정도로 말할 수 있으면 됩니다.

컨테이너에 이동으로 추가하기

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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로 캐스팅해야 합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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면 이동”처럼 분기할 수 있습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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를 쓰면 원래 성질을 유지해 전달할 수 있습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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 방해)

아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 나쁜 예: 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: 이동 후 원본 사용

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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 누락

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 나쁜 예: 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 참조

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 나쁜 예: const이므로 이동 불가
void process(const std::string&& str) {
    // str을 이동할 수 없음
}
// ✅ 좋은 예
void process(std::string&& str) {
    std::string local = std::move(str);  // 이동 가능
}

실수 5: 복사 대입에서 자기 대입 검사 누락

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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: 이동 대입에서 자기 대입 검사 누락

다음은 cpp를 활용한 상세한 구현 코드입니다. 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 나쁜 예: 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를 남용 (불필요한 이동)

아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 나쁜 예: 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: 기본 복사/이동에 의존

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 나쁜 예: 포인터 멤버가 있는데 복사 생성자 없음
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 누락

아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 나쁜 예: 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: 이동 불가 타입을 이동 생성자에서 복사

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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 이동 벤치마크

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#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를 사용하는 것이 안전합니다. 스마트 포인터는 복사·이동 의미론이 이미 정의되어 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 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 등 표준 라이브러리 타입을 멤버로 쓰면 복사·이동이 자동으로 올바르게 동작합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 자원 관리 없음: vector가 알아서 처리
class SimpleData {
    std::vector<int> data;
public:
    // 복사·이동 생성자/대입 모두 컴파일러 기본 제공
};

8.5 값으로 받고 이동하기 (Pass-by-value + move)

인자를 “값으로 받고” 내부에서 std::move로 저장하면, 호출자가 rvalue를 넘기면 이동, lvalue를 넘기면 복사가 한 번만 발생합니다. 오버로딩을 두 개 만들 필요가 없습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

class DataStore {
    std::string name_;
public:
    // ✅ 값으로 받음: lvalue → 복사 1회, rvalue → 이동
    void setName(std::string name) {
        name_ = std::move(name);
    }
};

8.6 복사/이동 금지가 필요할 때

파일 핸들, 소켓, 뮤텍스처럼 복사가 의미 없는 자원은 복사를 delete로 명시적으로 금지합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

class NonCopyable {
    int* resource;
public:
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;
    NonCopyable(NonCopyable&&) = default;
    NonCopyable& operator=(NonCopyable&&) = default;
};

9. 프로덕션 패턴

9.1 팩토리 함수에서 이동 반환

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 프로덕션에서 흔한 패턴: 팩토리 함수
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 반환 패턴

아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고. 코드를 직접 실행해보면서 동작을 확인해보세요.

#include <optional>
std::optional<std::vector<int>> fetchData() {
    std::vector<int> result = /* ....*/;
    return result;  // optional 내부로 이동
}

9.4 RAII와 이동 결합

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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 관용구에서 이동

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 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를 지정하세요. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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에는 이동 의미론이 없었기 때문에, 함수에서 큰 객체를 반환할 때 항상 복사가 발생했습니다. 그래서 예전 코드에서는 “큰 객체를 반환하지 말고, 참조 인자로 받아서 채우라”는 관례가 많았습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 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++ 복사 이동, 얕은 복사 깊은 복사, 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의 동작 원리

관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3