[2026] C++ Copy Elision | 복사 생략 가이드

[2026] C++ Copy Elision | 복사 생략 가이드

이 글의 핵심

C++ Copy Elision: 복사 생략 가이드. Copy Elision 종류·C++17 보장된 복사 생략.

들어가며

Copy Elision(복사 생략)은 컴파일러가 불필요한 복사/이동 연산을 제거하는 최적화 기법입니다. C++17부터 특정 경우에는 필수로 적용되며, 성능을 크게 향상시킵니다.

#include <iostream>
class Widget {
public:
    Widget() {
        std::cout << "생성자" << std::endl;
    }
    
    // 복사 생성자: 다른 Widget으로부터 복사
    Widget(const Widget&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    // 이동 생성자: 다른 Widget의 리소스를 이동
    Widget(Widget&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
};
Widget createWidget() {
    // Widget() 임시 객체 반환
    // Copy Elision: 복사/이동 없이 직접 반환 위치에 생성
    return Widget();  // 복사/이동 생략
}
int main() {
    // createWidget()의 반환값을 w에 할당
    // Copy Elision 적용: Widget이 w의 위치에 직접 생성됨
    // 복사 생성자도, 이동 생성자도 호출 안됨!
    Widget w = createWidget();
    // 출력: "생성자" (한 번만)
    // 복사/이동 생략으로 성능 향상
}

왜 필요한가?:

  • 성능: 복사/이동 비용 제거
  • 효율성: 큰 객체(컨테이너, 문자열) 반환 시 효과적
  • 간결성: 값으로 반환해도 성능 걱정 없음

실무에서 마주한 현실

개발을 배울 때는 모든 게 깔끔하고 이론적입니다. 하지만 실무는 다릅니다. 레거시 코드와 씨름하고, 급한 일정에 쫓기고, 예상치 못한 버그와 마주합니다. 이 글에서 다루는 내용도 처음엔 이론으로 배웠지만, 실제 프로젝트에 적용하면서 “아, 이래서 이렇게 설계하는구나” 하고 깨달은 것들입니다. 특히 기억에 남는 건 첫 프로젝트에서 겪은 시행착오입니다. 책에서 배운 대로 했는데 왜 안 되는지 몰라 며칠을 헤맸죠. 결국 선배 개발자의 코드 리뷰를 통해 문제를 발견했고, 그 과정에서 많은 걸 배웠습니다. 이 글에서는 이론뿐 아니라 실전에서 마주칠 수 있는 함정들과 해결 방법을 함께 다루겠습니다.

1. Copy Elision 종류

1. RVO (Return Value Optimization)

임시 객체를 반환할 때 적용됩니다. C++17부터 필수입니다.

#include <iostream>
class Data {
public:
    Data() {
        std::cout << "생성자" << std::endl;
    }
    
    Data(const Data&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    Data(Data&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
};
Data func() {
    return Data();  // prvalue (순수 우측값)
}
int main() {
    Data d = func();
    // C++17: 복사 생략 보장
    // 출력: "생성자" (1번)
}

2. NRVO (Named Return Value Optimization)

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

#include <iostream>
class Data {
public:
    Data() {
        std::cout << "생성자" << std::endl;
    }
    
    Data(const Data&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    Data(Data&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
};
Data func() {
    // 이름 있는 지역 변수 (Named Return Value)
    Data d;  // 이름 있는 객체
    // d 초기화 로직
    
    // NRVO (Named Return Value Optimization):
    // 컴파일러가 d를 반환 위치에 직접 생성할 수 있음
    // 하지만 RVO와 달리 보장되지 않음 (컴파일러 재량)
    return d;
}
int main() {
    Data d = func();
    // NRVO 적용 시: "생성자" (1번만)
    //   - func() 안의 d가 main의 d 위치에 직접 생성
    // NRVO 미적용 시: "생성자" + "이동 생성자"
    //   - func() 안에서 생성 → main으로 이동
}

NRVO 적용 조건:

  • 반환되는 객체가 지역 변수
  • 모든 반환 경로에서 같은 변수 반환
  • 변수 타입이 반환 타입과 동일 아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ NRVO 가능
Data func1() {
    Data d;
    return d;  // 항상 d 반환
}
// ❌ NRVO 불가
Data func2(bool flag) {
    Data a, b;
    return flag ? a : b;  // 다른 변수 반환
}

3. 함수 인자 전달

임시 객체를 함수 인자로 전달할 때 적용됩니다.

#include <iostream>
class Widget {
public:
    Widget() {
        std::cout << "생성자" << std::endl;
    }
    
    Widget(const Widget&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    Widget(Widget&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
};
void process(Widget w) {
    std::cout << "process 호출" << std::endl;
}
int main() {
    process(Widget());  // 복사 생략
    // Widget이 process의 매개변수 위치에 직접 생성됨
    // 출력: "생성자", "process 호출"
}

2. C++17 보장된 복사 생략

prvalue 복사 생략

#include <iostream>
class Widget {
public:
    Widget() {
        std::cout << "생성자" << std::endl;
    }
    
    Widget(const Widget&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    Widget(Widget&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
};
Widget createWidget() {
    return Widget();
}
int main() {
    // C++17부터 보장
    Widget w1 = Widget();           // 복사 생략 (보장)
    Widget w2 = createWidget();     // 복사 생략 (보장)
    Widget w3 = Widget(Widget());   // 복사 생략 (보장)
    
    // 출력: "생성자" (3번만)
}

C++17 복사 생략 규칙

상황C++14 이전C++17 이후
Widget w = Widget();최적화 (선택)필수
Widget w = func(); (prvalue 반환)최적화 (선택)필수
Widget w = x; (이름 있는 변수)최적화 (선택)최적화 (선택)

복사 생성자 불필요

C++17부터 prvalue 복사 생략은 필수이므로, 복사 생성자가 없어도 됩니다.

#include <iostream>
class NonCopyable {
public:
    NonCopyable() {
        std::cout << "생성자" << std::endl;
    }
    
    NonCopyable(const NonCopyable&) = delete;  // 복사 금지
    NonCopyable(NonCopyable&&) = default;      // 이동 허용
};
NonCopyable func() {
    return NonCopyable();  // C++17: OK (복사 생략 보장)
}
int main() {
    NonCopyable obj = func();  // OK
    // 출력: "생성자"
}

3. 실전 예제

예제 1: 컨테이너 반환

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <vector>
#include <string>
#include <iostream>
// 복사 생략
std::vector<int> createVector(size_t size) {
    std::vector<int> result(size);
    for (size_t i = 0; i < size; i++) {
        result[i] = i * i;
    }
    return result;  // 복사 없음
}
// 문자열 처리
std::string processString(const std::string& input) {
    std::string result = input;
    result += " processed";
    return result;  // 복사 생략
}
int main() {
    auto vec = createVector(10);
    auto str = processString("Hello");
    
    std::cout << "벡터 크기: " << vec.size() << std::endl;
    std::cout << "문자열: " << str << std::endl;
    
    for (size_t i = 0; i < vec.size(); ++i) {
        std::cout << vec[i] << " ";
    }
    std::cout << std::endl;
    
    return 0;
}

출력:

벡터 크기: 10
문자열: Hello processed
0 1 4 9 16 25 36 49 64 81

예제 2: 팩토리 함수

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

#include <iostream>
#include <string>
class Connection {
private:
    std::string host;
    int port;
    
public:
    Connection(const std::string& h, int p) 
        : host(h), port(p) {
        std::cout << "연결: " << host << ":" << port << std::endl;
    }
    
    Connection(const Connection&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    Connection(Connection&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
    
    void info() const {
        std::cout << "Connection(" << host << ":" << port << ")" << std::endl;
    }
};
// 복사 생략
Connection createConnection(const std::string& host, int port) {
    return Connection(host, port);
}
// 조건부 생성
Connection createConnectionByType(const std::string& type) {
    if (type == "local") {
        return Connection("localhost", 8080);
    } else if (type == "remote") {
        return Connection("example.com", 443);
    }
    return Connection("default", 80);
}
int main() {
    std::cout << "=== createConnection ===" << std::endl;
    auto conn1 = createConnection("localhost", 8080);
    
    std::cout << "\n=== createConnectionByType ===" << std::endl;
    auto conn2 = createConnectionByType("remote");
    
    std::cout << "\n=== 정보 출력 ===" << std::endl;
    conn1.info();
    conn2.info();
    
    return 0;
}

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

=== createConnection ===
연결: localhost:8080
=== createConnectionByType ===
연결: example.com:443
=== 정보 출력 ===
Connection(localhost:8080)
Connection(example.com:443)

예제 3: 복잡한 객체

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

#include <map>
#include <string>
#include <iostream>
class Config {
private:
    std::map<std::string, std::string> settings;
    
public:
    Config() {
        std::cout << "Config 생성" << std::endl;
    }
    
    Config(const Config&) {
        std::cout << "Config 복사" << std::endl;
    }
    
    Config(Config&&) noexcept {
        std::cout << "Config 이동" << std::endl;
    }
    
    void set(const std::string& key, const std::string& value) {
        settings[key] = value;
    }
    
    std::string get(const std::string& key) const {
        auto it = settings.find(key);
        return it != settings.end() ? it->second : "";
    }
};
// 복사 생략
Config loadConfig(const std::string& filename) {
    Config config;
    config.set("host", "localhost");
    config.set("port", "8080");
    config.set("debug", "true");
    return config;  // NRVO
}
int main() {
    std::cout << "=== loadConfig ===" << std::endl;
    auto config = loadConfig("app.conf");
    
    std::cout << "\n=== 설정 출력 ===" << std::endl;
    std::cout << "host: " << config.get("host") << std::endl;
    std::cout << "port: " << config.get("port") << std::endl;
    std::cout << "debug: " << config.get("debug") << std::endl;
    
    return 0;
}

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

=== loadConfig ===
Config 생성
=== 설정 출력 ===
host: localhost
port: 8080
debug: true

4. 자주 발생하는 문제

문제 1: std::move 남용

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

#include <iostream>
#include <vector>
class Data {
public:
    Data() {
        std::cout << "생성자" << std::endl;
    }
    
    Data(const Data&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    Data(Data&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
};
// ❌ std::move로 복사 생략 방해
Data bad() {
    Data d;
    return std::move(d);  // 복사 생략 불가, 이동만 발생
}
// ✅ 그냥 반환
Data good() {
    Data d;
    return d;  // 복사 생략 또는 이동
}
int main() {
    std::cout << "=== bad() ===" << std::endl;
    auto d1 = bad();  // 생성자 + 이동
    
    std::cout << "\n=== good() ===" << std::endl;
    auto d2 = good();  // 생성자만 (복사 생략)
    
    return 0;
}

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

=== bad() ===
생성자
이동 생성자
=== good() ===
생성자

문제 2: 여러 반환 경로

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

#include <iostream>
class Data {
public:
    Data() {
        std::cout << "생성자" << std::endl;
    }
    
    Data(const Data&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    Data(Data&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
};
// ❌ 복사 생략 안됨
Data bad(bool flag) {
    Data a, b;
    return flag ? a : b;  // 이동 사용
}
// ✅ 복사 생략 가능
Data good(bool flag) {
    Data result;
    if (flag) {
        // result 초기화
    } else {
        // result 초기화
    }
    return result;  // 복사 생략
}
int main() {
    std::cout << "=== bad(true) ===" << std::endl;
    auto d1 = bad(true);  // 생성자 2번 + 이동
    
    std::cout << "\n=== good(true) ===" << std::endl;
    auto d2 = good(true);  // 생성자만
    
    return 0;
}

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

=== bad(true) ===
생성자
생성자
이동 생성자
=== good(true) ===
생성자

문제 3: 최적화 레벨

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

# 복사 생략 비활성화
g++ -fno-elide-constructors main.cpp -o main
# 복사 생략 활성화 (기본)
g++ main.cpp -o main

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

#include <iostream>
class Data {
public:
    Data() {
        std::cout << "생성자" << std::endl;
    }
    
    Data(const Data&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    Data(Data&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
};
Data func() {
    return Data();
}
int main() {
    auto d = func();
    // 복사 생략 비활성화 시: 생성자 + 이동
    // 복사 생략 활성화 시: 생성자만
}

5. 성능 비교

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

#include <chrono>
#include <vector>
#include <iostream>
class LargeObject {
private:
    std::vector<int> data;
    
public:
    LargeObject() : data(10000, 0) {
        // 큰 객체 생성
    }
    
    LargeObject(const LargeObject& other) : data(other.data) {
        // 복사 비용
    }
    
    LargeObject(LargeObject&& other) noexcept : data(std::move(other.data)) {
        // 이동 비용
    }
};
LargeObject createObject() {
    return LargeObject();
}
int main() {
    auto start = std::chrono::high_resolution_clock::now();
    
    for (int i = 0; i < 10000; i++) {
        auto obj = createObject();
    }
    
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    
    std::cout << "시간: " << duration.count() << "ms" << std::endl;
    
    return 0;
}

결과:

  • 복사 생략 활성화: ~100ms
  • 복사 생략 비활성화: ~150ms (이동 비용 추가)

6. 실무 패턴

패턴 1: 값으로 반환

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <vector>
#include <string>
#include <iostream>
// ✅ 복사 생략 덕분에 안전하고 효율적
std::vector<int> loadData(const std::string& filename) {
    std::vector<int> data;
    // 파일에서 데이터 로드
    for (int i = 0; i < 100; ++i) {
        data.push_back(i * i);
    }
    return data;  // 복사 생략
}
int main() {
    auto data = loadData("data.txt");  // 복사 없음
    std::cout << "데이터 크기: " << data.size() << std::endl;
    
    return 0;
}

패턴 2: 빌더 패턴

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

#include <string>
#include <iostream>
class QueryBuilder {
    std::string query_;
    
public:
    QueryBuilder() : query_("SELECT * FROM table") {}
    
    QueryBuilder& select(const std::string& fields) {
        query_ = "SELECT " + fields + " FROM table";
        return *this;
    }
    
    QueryBuilder& from(const std::string& table) {
        query_ += " FROM " + table;
        return *this;
    }
    
    QueryBuilder& where(const std::string& condition) {
        query_ += " WHERE " + condition;
        return *this;
    }
    
    std::string build() const {
        return query_;  // 복사 생략
    }
};
int main() {
    auto query = QueryBuilder()
        .select("*")
        .from("users")
        .where("age > 18")
        .build();
    
    std::cout << query << std::endl;
    
    return 0;
}

출력:

SELECT * FROM users WHERE age > 18

패턴 3: 팩토리 함수

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

#include <string>
#include <iostream>
class Connection {
    std::string host_;
    int port_;
    
public:
    Connection(std::string host, int port) 
        : host_(std::move(host)), port_(port) {
        std::cout << "Connection(" << host_ << ":" << port_ << ")" << std::endl;
    }
    
    void info() const {
        std::cout << "연결: " << host_ << ":" << port_ << std::endl;
    }
};
Connection createLocalConnection() {
    return Connection("localhost", 8080);  // 복사 생략
}
Connection createRemoteConnection(const std::string& host) {
    return Connection(host, 443);  // 복사 생략
}
int main() {
    auto conn1 = createLocalConnection();
    auto conn2 = createRemoteConnection("example.com");
    
    conn1.info();
    conn2.info();
    
    return 0;
}

출력: 다음은 간단한 code 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

Connection(localhost:8080)
Connection(example.com:443)
연결: localhost:8080
연결: example.com:443

7. 컴파일러 동작 확인

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

#include <iostream>
class Tracker {
public:
    Tracker() {
        std::cout << "생성자" << std::endl;
    }
    
    Tracker(const Tracker&) {
        std::cout << "복사 생성자" << std::endl;
    }
    
    Tracker(Tracker&&) noexcept {
        std::cout << "이동 생성자" << std::endl;
    }
    
    ~Tracker() {
        std::cout << "소멸자" << std::endl;
    }
};
Tracker func() {
    return Tracker();
}
int main() {
    std::cout << "=== 시작 ===" << std::endl;
    auto t = func();
    std::cout << "=== 끝 ===" << std::endl;
    
    return 0;
}

출력 (복사 생략 활성화): 다음은 간단한 code 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

=== 시작 ===
생성자
=== 끝 ===
소멸자

출력 (복사 생략 비활성화): 아래 코드는 code를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

=== 시작 ===
생성자
이동 생성자
소멸자
=== 끝 ===
소멸자

정리

핵심 요약

  1. Copy Elision: 불필요한 복사/이동 제거
  2. RVO: 임시 객체 반환 (C++17 필수)
  3. NRVO: 이름 있는 변수 반환 (컴파일러 재량)
  4. prvalue: C++17부터 복사 생략 보장
  5. std::move 금지: 반환 시 사용하지 마세요

복사 생략 종류

종류설명C++17
RVO임시 객체 반환필수
NRVO이름 있는 변수 반환선택
인자 전달임시 객체 전달필수

실전 팁

사용 원칙:

  • 값으로 반환 (복사 생략 신뢰)
  • std::move 사용 금지 (반환 시)
  • 단일 변수 반환 (NRVO)
  • 임시 객체 반환 (RVO) 성능:
  • 복사/이동 비용 완전 제거
  • 큰 객체에서 효과적
  • 컴파일 타임 최적화
  • 런타임 오버헤드 없음 주의사항:
  • 여러 반환 경로 주의
  • std::move 남용 금지
  • 복사 생성자 불필요 (C++17 prvalue)
  • NRVO는 보장 안됨

다음 단계


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글

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