본문으로 건너뛰기 C++ Copy Elision 심화 | RVO·NRVO·필수 생략·예외 안전

C++ Copy Elision 심화 | RVO·NRVO·필수 생략·예외 안전

C++ Copy Elision 심화 | RVO·NRVO·필수 생략·예외 안전

이 글의 핵심

복사 생략은 C++17 이후 문맥에 따라 의무가 되기도 하고, NRVO처럼 컴파일러 재량으로 남기도 합니다. 필수/선택, RVO·NRVO 조건, 예외 안전, 실무 패턴을 한 번에 정리합니다.

들어가며

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부터 필수입니다.

main 함수의 구현 예제입니다.

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

이름 있는 지역 변수를 반환할 때 적용됩니다. 컴파일러 재량입니다.

#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 적용 조건:

  • 반환되는 객체가 지역 변수
  • 모든 반환 경로에서 같은 변수 반환
  • 변수 타입이 반환 타입과 동일

C/C++ 예제 코드입니다.

// ✅ NRVO 가능
Data func1() {
    Data d;
    return d;  // 항상 d 반환
}

// ❌ NRVO 불가
Data func2(bool flag) {
    Data a, b;
    return flag ? a : b;  // 다른 변수 반환
}

3. 함수 인자 전달

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

process 함수의 구현 예제입니다.

#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 호출"
}

복사 생략의 두 층: 필수(의무)와 선택(구현 재량)

표준에서 말하는 복사 생략은 한 덩어리가 아닙니다. 문법·의미론이 요구하는 생략과, 같은 관찰 가능 동작을 유지하면서 복사/이동 호출을 줄이는 최적화가 나뉩니다.

필수(의무) 복사 생략이 되는 대표 문맥 (C++17~)

C++17은 특정 초기화·return 문맥에서 prvalue결과 객체(result object) 를 직접 초기화한다고 규정합니다. 이 경우 복사/이동 생성자는 오버로드 해석 후보에 포함되지 않을 수 있으며, 복사 생성자를 = delete해도 프로그램이 성립합니다(위 NonCopyable 예제).

대표적으로 다음이 여기에 해당합니다.

  • 함수 호출의 prvalue 반환이 반환 타입으로 직접 이어지는 경우(return T();, return T{a,b};임시 객체가 곧바로 반환되는 형태).
  • 변수 선언에서 동일 타입의 prvalue직접 초기화하는 경우(T x = T(); 등, 임시→임시 연쇄가 아닌 한 번의 결과 객체 생성).

이때 생략은 “컴파일러가 친절히 해줌” 수준이 아니라, 프로그램이 의미를 갖기 위한 규칙에 가깝습니다.

선택(구현 정의·재량) 복사 생략

이름 있는 자동 저장 기간 객체를 반환할 때의 NRVO는 전형적으로 여기에 해당합니다. 표준은 구현이 동일한 관찰 가능 동작을 유지하는 한 복사를 생략할 수 있음을 허용하지만, C++17의 prvalue 규정과 같은 강한 보장을 모든 NRVO에 두지는 않습니다.

또한 서로 다른 지역 변수를 조건에 따라 반환한다거나, 반환 식이 단일 이름으로 귀결되지 않는 경우에는 NRVO 자체가 성립하기 어렵으며, 이동 또는 복사로 폴백하는 모델이 전개됩니다.

디버깅 플래그와의 관계 (-fno-elide-constructors 등)

GCC·Clang 계열의 -fno-elide-constructors복사/이동 생성자 호출을 강제해 관찰하려는 용도로 사용됩니다. 다만 C++17 이후 의무 생략 문맥에서는 “복사 생성자가 반드시 호출된다”는 식의 구 C++ 감각의 출력현재 표준 의미론이 어긋날 수 있으므로, 삭제된 복사 생성자 타입으로 실험하는 편이 판별에 안전합니다.

정리하면, 의무 생략은 “표준이 객체를 어디에 만들지”를 고정하며, 선택 생략은 “그래도 비용을 줄일 수 있으면 줄인다”에 가깝습니다.


RVO와 NRVO: 조건·실패·표준 관점 정리

RVO(임시 반환)가 단순해 보이는 이유

C++17 이후, return의 피연산자가 반환 타입과 같은 타입의 prvalue이면 그 표현식은 함수의 반환값으로 쓰일 결과 객체직접 초기화합니다. 이는 이름이 없는 반환에서 특히 깔끔합니다.

// prvalue가 반환 타입 T의 결과 객체를 직접 초기화하는 모델(C++17~)
T f() {
    return T{}; // RVO/의무 생략 문맥(구현이 아니라 규칙)
}

NRVO(이름 있는 반환)가 까다로운 이유

NRVO는 “지역 변수 x가 곧 호출자 쪽 결과 객체와 같은 저장을 쓴다”는 형태의 최적화입니다. 따라서 대략 다음이 성립하기 쉽습니다.

  • 단일 이름: 모든 return 경로가 같은 자동 변수를 반환한다(return x;만 존재).
  • 타입 일치: 지역 객체의 타입이 반환 타입과 동일(또는 구현이 허용하는 동일한 대상)하다.
  • 과도한 간섭 없음: return std::move(x);처럼 lvalue를 xvalue로 바꾸는 형태는 NRVO를 깨고 이동 쪽으로 기울기 쉽다.

반면 다음은 전형적으로 NRVO를 기대하기 어렵거나 불가에 가깝습니다.

  • return cond ? a : b;처럼 서로 다른 이름이 반환된다.
  • 반환 전에 다른 이름으로 재바인딩하거나, 서로 다른 지역 객체가 경로마다 반환된다.
  • 지역 변수와 반환 타입이 달라 변환 연산이 끼어 든다(최적화 범위가 달라짐).

실무에서는 “한 번에 하나의 result만 만들고 모든 경로에서 return result;로 모은다”는 형태가 NRVO 가능성을 높입니다.

“RVO vs NRVO” 한 줄 요약

구분대상C++17 이후의 느낌
RVOreturnprvalue특정 문맥에서 의미론이 직접 초기화를 요구
NRVO이름 있는 지역 return x;흔히 선택 최적화(컴파일러·최적화 수준·코드 형태에 의존)

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

prvalue 복사 생략

main 함수의 구현 예제입니다.

#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 복사 생략은 필수이므로, 복사 생성자가 없어도 됩니다.

main 함수의 구현 예제입니다.

#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
    // 출력: "생성자"
}

C++17 “보장”이 바꾼 초기화 모델 (prvalue와 결과 객체)

C++17 이전에는 “임시 객체를 만들고 → 복사/이동으로 옮긴다”는 이중 단계를 머릿속에 그리기 쉬웠습니다. C++17 이후, 특정 문맥의 prvalue임시 객체를 거치지 않고 결과 객체를 직접 초기화합니다. 따라서 복사 생성자가 없어도 return T();가 성립합니다.

실무에서 이 차이는 다음으로 이어집니다.

  • 팩토리 함수T를 값으로 반환할 때, 불필요한 std::move를 붙이지 않는 것이 좋습니다(아래 std::move 남용 절 참고).
  • 컨테이너·문자열처럼 이동이 저렴한 타입도, “이동조차 없이 생성자 한 번”이 더 이상 이론이 아닙니다.
  • ABI 관점에서 반환값이 레지스터/숨은 포인터로 전달되더라도, 표준 의미론은 “호출자 쪽 결과 객체에 직접 구성”을 전제로 합니다.

요약하면, C++17의 “보장”은 최적화 옵션의 이름이 아니라 값의 생성 지점을 어떻게 설명할지에 대한 규칙입니다.


복사 생략과 예외 안전성

복사 생략은 성능만 이야기하는 것이 아니라, 예외가 나올 때 어떤 객체가 어느 시점에 존재하는지와도 맞닿습니다.

생성 도중 예외: 호출자 쪽 객체는?

의무 생략 문맥에서 T의 생성자가 예외를 던지면, 호출자가 받을 결과 객체완전히 생성되지 않은 채로 제어가 빠져나갑니다. 즉 “반환은 됐는데 내용물은 반쯤” 같은 상태가 표준 모델에서 정상적으로 남지 않습니다. 예외가 스택 풀기를 수행하는 동안 부분적으로 생성된 서브객체만 존재할 수는 있으나, 그건 해당 타입의 불변식을 설계할 때 생성자·멤버 초기화 순서로 다루는 문제입니다.

NRVO가 실패할 때: 이동 폴백과 noexcept

NRVO가 적용되지 않으면 구현은 이동 생성자(또는 복사)로 반환을 완성할 수 있습니다. 이때 이동이 예외를 던지면 std::vector 재할당 같은 강한 예외 보장 시나리오에서 비용·안전성이 둘 다 나빠질 수 있습니다. 따라서 이동 생성자는 가능하면 noexcept로 두는 것이 STL 컨테이너와의 상성에 유리합니다.

std::move 반환이 위험한 또 다른 이유(예외·최적화)

return std::move(x);는 NRVO를 깨기 쉬울 뿐 아니라, 불필요한 이동을 강제해 예외 가능 경로를 넓힐 수 있습니다. 예외 안전성을 “이동은 가볍다”로만 단정하지 말으며, 실제 타입의 이동이 어떤 자원 해제·교환을 하는지 확인하는 것이 좋습니다.

정리

  • 의무 생략: 생성 예외는 호출자 쪽 완성 객체로 이어지지 않는 것이 자연스러운 모델.
  • NRVO 실패 시: 이동/복사 폴백이 예외·성능에 모두 영향.
  • 실무: 이동 생성자 noexcept·불필요한 std::move 금지서로 연결된 설계 규칙입니다.

프로덕션 복사 생략 패턴 (심화)

아래는 “작동만 한다”가 아니라 코드 리뷰에서 반복되는 실무 규칙입니다.

1) 반환값은 값으로, 단일 result 변수로 모으기

API는 출력 매개변수보다 값 반환을 우선 고려합니다. 복잡한 분기에서는 조기 반환 대신 T result;로 채운 뒤 마지막에 return result; 패턴이 NRVO에 유리한 경우가 많습니다. (단, 이는 보장이 아니라 관례적으로 유리한 형태입니다.)

2) std::optional·expected와의 조합

C++23 std::expectedstd::optional<T>를 반환하는 경우, 성공/실패 경로가 달라도 내부 T의 생성 위치를 단순하게 유지할수록 이동 횟수예외 경로가 줄어듭니다. “실패 시 빈 객체, 성공 시 채운 객체”를 한 블록에서 구성하도록 설계하는 편이 읽기 쉽습니다.

3) 거대 객체·할당: “생략”만으로 끝나지 않는 경우

복사 생략은 복사/이동 호출을 없애도, 내부 버퍼 할당 자체를 없애지는 않습니다. 대용량 std::vector를 반환할 때는 예약(reserve)·재사용 가능한 버퍼·out-parameter로 재사용 같은 알고리즘 차원의 선택이 여전히 필요합니다.

4) 컴파일·최적화 수준별 검증

릴리스 빌드와 디버그 빌드에서 인라인·NRVO 적용이 달라질 수 있습니다. 성능 병목은 어셈블리 한 번이 아니라 프로파일러로 확인하며, “이 함수는 반드시 NRVO된다”는 가정으로 미세 최적화를 하지 않는 것이 안전합니다.

5) 팀 규칙으로 남기기 좋은 한 줄

  • 지역 객체는 return x;, return std::move(x); 금지(특별한 이유 없이).
  • prvalue 반환return T{...};처럼 한 번에 구성.
  • 이동 생성자는 noexcept 우선 (STL·예외 안전 시나리오).

3. 실전 예제

예제 1: 컨테이너 반환

#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: 팩토리 함수

#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;
}

출력:

터미널에서 다음 명령어를 실행합니다.

=== createConnection ===
연결: localhost:8080

=== createConnectionByType ===
연결: example.com:443

=== 정보 출력 ===
Connection(localhost:8080)
Connection(example.com:443)

예제 3: 복잡한 객체

#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;
}

출력:

터미널에서 다음 명령어를 실행합니다.

=== loadConfig ===
Config 생성

=== 설정 출력 ===
host: localhost
port: 8080
debug: true

4. 자주 발생하는 문제

문제 1: std::move 남용

#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;
}

출력:

터미널에서 다음 명령어를 실행합니다.

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

=== good() ===
생성자

문제 2: 여러 반환 경로

#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;
}

출력:

터미널에서 다음 명령어를 실행합니다.

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

=== good(true) ===
생성자

문제 3: 최적화 레벨

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

# 복사 생략 활성화 (기본)
g++ main.cpp -o main
#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. 성능 비교

#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: 값으로 반환

#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: 빌더 패턴

#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: 팩토리 함수

#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;
}

출력:

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

7. 컴파일러 동작 확인

#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;
}

출력 (복사 생략 활성화):

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

출력 (복사 생략 비활성화):

터미널에서 다음 명령어를 실행합니다.

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

정리

핵심 요약

  1. Copy Elision: 불필요한 복사/이동 제거
  2. 필수 vs 선택: C++17은 prvalue 등 특정 문맥에서 의미론상 직접 초기화를 요구하며, NRVO 등은 구현 재량인 경우가 많음
  3. RVO: returnprvalue가 결과 객체를 직접 초기화하는 모델(특정 문맥에서 강함)
  4. NRVO: 단일 이름·타입 일치 등이 맞을 때 선택적으로 걸리기 쉬움; std::move는 오히려 방해
  5. 예외 안전: NRVO 실패 시 이동 폴백noexcept 설계가 연결됨
  6. 프로덕션: 값 반환·단일 result 모으기·불필요한 std::move 금지·프로파일로 검증
  7. std::move: 반환 지역 변수에 남발하지 말 것

복사 생략 종류

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

실전 팁

사용 원칙:

  • 값으로 반환 (복사 생략 신뢰)
  • std::move 사용 금지 (반환 시)
  • 단일 변수 반환 (NRVO)
  • 임시 객체 반환 (RVO)

성능:

  • 복사/이동 비용 완전 제거
  • 큰 객체에서 효과적
  • 컴파일 타임 최적화
  • 런타임 오버헤드 없음

주의사항:

  • 여러 반환 경로 주의
  • std::move 남용 금지
  • 복사 생성자 불필요 (C++17 prvalue)
  • NRVO는 보장 안 됨

다음 단계


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

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

관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ Copy Elision과 RVO(Return Value Optimization) 완벽 이해. 컴파일러 최적화·이동 생성자·복사 제거 규칙을 실제 어셈블리와 함께 분석하는 심화 가이드. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, copy-elision, optimization, 복사생략, C++17, RVO, NRVO 등으로 검색하시면 이 글이 도움이 됩니다.