본문으로 건너뛰기
Previous
Next
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는 보장 안됨

다음 단계


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

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

관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「[2026] C++ Copy Elision 심화 | RVO·NRVO·필수 생략·예외 안전」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「[2026] C++ Copy Elision 심화 | RVO·NRVO·필수 생략·예외 안전」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


자주 묻는 질문 (FAQ)

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

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

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

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

Q. 더 깊이 공부하려면?

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


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

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