C++ 완벽 전달 | "Perfect Forwarding" 가이드

C++ 완벽 전달 | "Perfect Forwarding" 가이드

이 글의 핵심

완벽 전달(Perfect Forwarding)은 템플릿에서 인자의 lvalue·rvalue 성질을 유지해 다른 함수로 넘기는 기법입니다. 이 글에서는 유니버설 참조, std::forward, 팩토리·래퍼 활용과 흔한 실수를 예제로 설명합니다.

들어가며

완벽 전달 (Perfect Forwarding)은 템플릿 함수에서 인자를 다른 함수로 “있는 그대로” 전달하는 기법입니다. lvalue는 lvalue로, rvalue는 rvalue로 전달하여 불필요한 복사를 방지합니다.

왜 필요한가?:

  • 성능: 불필요한 복사 제거
  • 타입 보존: lvalue/rvalue 특성 유지
  • 일반성: 모든 타입에 대해 동작
  • 효율성: 팩토리 함수, 래퍼 함수에서 필수

1. 문제 상황

이름이 있는 변수는 lvalue

#include <iostream>

// 오버로드된 process 함수
void process(int& x) {
    std::cout << "lvalue 버전: " << x << std::endl;
}

void process(int&& x) {
    std::cout << "rvalue 버전: " << x << std::endl;
}

// ❌ 문제: 항상 lvalue 버전 호출
// T&&: 유니버설 참조 (lvalue와 rvalue 모두 받음)
template<typename T>
void wrapper(T&& arg) {
    // 문제: arg는 이름이 있는 변수
    // 이름이 있는 변수는 항상 lvalue로 취급됨!
    // wrapper(20)으로 rvalue를 전달해도
    // process(arg)는 lvalue 버전 호출
    process(arg);  // arg는 이름이 있으므로 lvalue!
}

int main() {
    int x = 10;
    wrapper(x);   // lvalue 전달 → lvalue 버전 (올바름)
    wrapper(20);  // rvalue 전달 → lvalue 버전 (잘못됨!)
                  // 20은 rvalue인데 lvalue로 처리됨
    
    return 0;
}

출력:

lvalue 버전: 10
lvalue 버전: 20

문제: wrapper(20)은 rvalue를 전달했지만, process(arg)는 lvalue로 처리됩니다.


2. 완벽 전달 (std::forward)

#include <iostream>
#include <utility>

void process(int& x) {
    std::cout << "lvalue 버전: " << x << std::endl;
}

void process(int&& x) {
    std::cout << "rvalue 버전: " << x << std::endl;
}

// ✅ 해결: std::forward 사용
template<typename T>
void wrapper(T&& arg) {
    // std::forward<T>(arg): 원래 타입 특성 유지
    // - arg가 lvalue로 전달되었으면 lvalue로 전달
    // - arg가 rvalue로 전달되었으면 rvalue로 전달
    // 
    // 동작 원리:
    // wrapper(x)   → T=int&  → forward는 lvalue 반환
    // wrapper(20)  → T=int   → forward는 rvalue 반환
    process(std::forward<T>(arg));  // 원래 타입 유지
}

int main() {
    int x = 10;
    wrapper(x);   // lvalue 전달 → lvalue 버전 (올바름)
    wrapper(20);  // rvalue 전달 → rvalue 버전 (올바름!)
    
    return 0;
}

출력:

lvalue 버전: 10
rvalue 버전: 20

3. 유니버설 참조 (Universal Reference)

T&& vs Widget&&

#include <iostream>

class Widget {
public:
    Widget() { std::cout << "Widget 생성" << std::endl; }
    Widget(const Widget&) { std::cout << "Widget 복사" << std::endl; }
    Widget(Widget&&) { std::cout << "Widget 이동" << std::endl; }
};

// 유니버설 참조 (T&&)
template<typename T>
void func1(T&& arg) {
    std::cout << "유니버설 참조" << std::endl;
}

// 일반 rvalue 참조 (Widget&&)
void func2(Widget&& arg) {
    std::cout << "rvalue 참조" << std::endl;
}

int main() {
    Widget w;
    
    func1(w);         // OK: lvalue
    func1(Widget());  // OK: rvalue
    
    // func2(w);      // 에러: lvalue
    func2(Widget());  // OK: rvalue
    
    return 0;
}

출력:

Widget 생성
유니버설 참조
Widget 생성
Widget 이동
유니버설 참조
Widget 생성
Widget 이동
rvalue 참조

4. 참조 축약 (Reference Collapsing)

축약 규칙

// 참조 축약 규칙 (Reference Collapsing):
// 두 개의 참조가 결합될 때 하나로 축약됨
// 
// T&  &  → T&   (lvalue ref + lvalue ref = lvalue ref)
// T&  && → T&   (lvalue ref + rvalue ref = lvalue ref)
// T&& &  → T&   (rvalue ref + lvalue ref = lvalue ref)
// T&& && → T&&  (rvalue ref + rvalue ref = rvalue ref)
// 
// 핵심: lvalue 참조가 하나라도 있으면 lvalue 참조

template<typename T>
void func(T&& arg) {
    // T가 int&면:  int& && → int& (축약)
    // T가 int면:   int&& (그대로)
}

int main() {
    int x = 10;
    // lvalue 전달: T는 int&로 추론
    func(x);   // T = int&,  arg = int& && → int& (축약)
    
    // rvalue 전달: T는 int로 추론
    func(10);  // T = int,   arg = int&& (그대로)
    
    return 0;
}

핵심: lvalue 참조가 하나라도 있으면 lvalue 참조

타입 추론 과정

#include <iostream>
#include <type_traits>

template<typename T>
void test(T&& arg) {
    std::cout << "T is lvalue ref: " 
              << std::is_lvalue_reference_v<T> << std::endl;
    std::cout << "T is rvalue ref: " 
              << std::is_rvalue_reference_v<T> << std::endl;
    std::cout << "arg is lvalue ref: " 
              << std::is_lvalue_reference_v<decltype(arg)> << std::endl;
    std::cout << "arg is rvalue ref: " 
              << std::is_rvalue_reference_v<decltype(arg)> << std::endl;
    std::cout << std::endl;
}

int main() {
    int x = 10;
    
    std::cout << "test(x):" << std::endl;
    test(x);   // T = int&,  arg = int& && → int&
    
    std::cout << "test(10):" << std::endl;
    test(10);  // T = int,   arg = int&&
    
    const int y = 20;
    std::cout << "test(y):" << std::endl;
    test(y);   // T = const int&,  arg = const int&
    
    std::cout << "test(std::move(y)):" << std::endl;
    test(std::move(y));  // T = const int,  arg = const int&&
    
    return 0;
}

5. 실전 예제

예제 1: make_unique 구현

#include <memory>
#include <iostream>
#include <string>

// my_make_unique 구현: std::make_unique의 간단한 버전
// T: 생성할 객체 타입
// Args&&...: 가변 개수의 유니버설 참조 (완벽 전달)
template<typename T, typename... Args>
std::unique_ptr<T> my_make_unique(Args&&... args) {
    // std::forward<Args>(args)...: 각 인자를 원래 타입 특성 유지하며 전달
    // lvalue는 lvalue로, rvalue는 rvalue로 전달
    // new T(...): 전달된 인자로 T 객체 생성
    // unique_ptr로 감싸서 자동 메모리 관리
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

class Widget {
public:
    Widget(int x, std::string s) : x_(x), s_(s) {
        std::cout << "Widget(" << x_ << ", \"" << s_ << "\")" << std::endl;
    }
    
private:
    int x_;
    std::string s_;
};

int main() {
    std::string str = "test";
    // lvalue 전달: str은 복사됨
    auto w1 = my_make_unique<Widget>(10, str);       // lvalue
    
    // rvalue 전달: "hello"는 이동됨 (임시 문자열)
    auto w2 = my_make_unique<Widget>(20, "hello");   // rvalue
    
    // 완벽 전달 덕분에 불필요한 복사 없이 효율적으로 전달
    
    return 0;
}

출력:

Widget(10, "test")
Widget(20, "hello")

예제 2: 팩토리 함수

#include <memory>
#include <iostream>
#include <string>

template<typename T, typename... Args>
std::shared_ptr<T> createObject(Args&&... args) {
    std::cout << "객체 생성 중..." << std::endl;
    return std::make_shared<T>(std::forward<Args>(args)...);
}

class Person {
public:
    Person(std::string n, int a) : name(n), age(a) {
        std::cout << name << ", " << age << "살" << std::endl;
    }
    
private:
    std::string name;
    int age;
};

int main() {
    std::string name = "Alice";
    auto p1 = createObject<Person>(name, 25);       // lvalue
    auto p2 = createObject<Person>("Bob", 30);      // rvalue
    auto p3 = createObject<Person>(std::string("Charlie"), 35);  // rvalue
    
    return 0;
}

출력:

객체 생성 중...
Alice, 25살
객체 생성 중...
Bob, 30살
객체 생성 중...
Charlie, 35살

예제 3: 이벤트 시스템

#include <functional>
#include <vector>
#include <iostream>
#include <string>

class EventSystem {
private:
    std::vector<std::function<void()>> handlers;
    
public:
    template<typename Func, typename... Args>
    void registerHandler(Func&& func, Args&&... args) {
        handlers.push_back([
            f = std::forward<Func>(func),
            ... capturedArgs = std::forward<Args>(args)
        ]() mutable {
            f(capturedArgs...);
        });
    }
    
    void trigger() {
        for (auto& handler : handlers) {
            handler();
        }
    }
};

void onEvent(int id, std::string message) {
    std::cout << "이벤트 " << id << ": " << message << std::endl;
}

int main() {
    EventSystem events;
    
    std::string msg = "Hello";
    events.registerHandler(onEvent, 1, msg);       // lvalue
    events.registerHandler(onEvent, 2, "World");   // rvalue
    events.registerHandler(onEvent, 3, std::string("Test"));  // rvalue
    
    std::cout << "이벤트 트리거:" << std::endl;
    events.trigger();
    
    return 0;
}

출력:

이벤트 트리거:
이벤트 1: Hello
이벤트 2: World
이벤트 3: Test

6. 자주 발생하는 문제

문제 1: forward 없이 전달

#include <iostream>
#include <string>

void process(std::string& s) {
    std::cout << "lvalue: " << s << std::endl;
}

void process(std::string&& s) {
    std::cout << "rvalue: " << s << std::endl;
}

// ❌ move 특성 손실
template<typename T>
void badWrapper(T&& arg) {
    process(arg);  // 항상 lvalue로 전달
}

// ✅ forward 사용
template<typename T>
void goodWrapper(T&& arg) {
    process(std::forward<T>(arg));  // 원래 타입 유지
}

int main() {
    std::string s = "test";
    
    std::cout << "badWrapper:" << std::endl;
    badWrapper(s);            // lvalue
    badWrapper("hello");      // lvalue (잘못됨!)
    
    std::cout << "\ngoodWrapper:" << std::endl;
    goodWrapper(s);           // lvalue
    goodWrapper("world");     // rvalue (올바름!)
    
    return 0;
}

출력:

badWrapper:
lvalue: test
lvalue: hello

goodWrapper:
lvalue: test
rvalue: world

문제 2: 여러 번 forward

#include <iostream>
#include <string>

void process1(std::string s) {
    std::cout << "process1: " << s << std::endl;
}

void process2(std::string s) {
    std::cout << "process2: " << s << std::endl;
}

// ❌ 위험: 여러 번 forward
template<typename T>
void bad(T&& arg) {
    process1(std::forward<T>(arg));  // 이동 가능
    process2(std::forward<T>(arg));  // arg가 이미 이동됨!
}

// ✅ 한 번만 forward
template<typename T>
void good(T&& arg) {
    process1(arg);  // lvalue로 전달 (복사)
    process2(std::forward<T>(arg));  // 마지막에만 forward (이동)
}

int main() {
    std::string s = "test";
    
    std::cout << "good:" << std::endl;
    good(std::move(s));
    
    return 0;
}

출력:

good:
process1: test
process2: test

문제 3: auto&& vs T&&

#include <iostream>
#include <string>

class Widget {
public:
    Widget() { std::cout << "Widget()" << std::endl; }
    Widget(const Widget&) { std::cout << "Widget 복사" << std::endl; }
    Widget(Widget&&) { std::cout << "Widget 이동" << std::endl; }
};

Widget getValue() {
    return Widget();
}

// auto&&: 항상 유니버설 참조
void testAuto() {
    auto&& x = getValue();  // rvalue 바인딩
    std::cout << "auto&& OK" << std::endl;
}

// T&&: 템플릿에서만 유니버설 참조
template<typename T>
void testTemplate(T&& arg) {
    std::cout << "T&& OK" << std::endl;
}

// Widget&&: rvalue 참조 (유니버설 아님)
void testWidget(Widget&& arg) {
    std::cout << "Widget&& OK" << std::endl;
}

int main() {
    Widget w;
    
    testAuto();
    
    testTemplate(w);         // OK: lvalue
    testTemplate(Widget());  // OK: rvalue
    
    // testWidget(w);        // 에러: lvalue
    testWidget(Widget());    // OK: rvalue
    
    return 0;
}

7. 실무 패턴

패턴 1: 스레드 래퍼

#include <thread>
#include <utility>
#include <iostream>
#include <string>

template<typename Func, typename... Args>
std::thread createThread(Func&& func, Args&&... args) {
    return std::thread(
        std::forward<Func>(func),
        std::forward<Args>(args)...
    );
}

void worker(int id, std::string name) {
    std::cout << "Worker " << id << ": " << name << std::endl;
}

int main() {
    std::string name = "Alice";
    auto t1 = createThread(worker, 1, name);       // lvalue
    auto t2 = createThread(worker, 2, "Bob");      // rvalue
    auto t3 = createThread(worker, 3, std::string("Charlie"));  // rvalue
    
    t1.join();
    t2.join();
    t3.join();
    
    return 0;
}

패턴 2: emplace 구현

#include <iostream>
#include <vector>
#include <string>

template<typename T>
class MyVector {
private:
    std::vector<T> data_;
    
public:
    template<typename... Args>
    void emplace_back(Args&&... args) {
        data_.emplace_back(std::forward<Args>(args)...);
        std::cout << "emplace_back 호출" << std::endl;
    }
    
    void print() const {
        for (const auto& item : data_) {
            std::cout << "  " << item << std::endl;
        }
    }
};

int main() {
    MyVector<std::string> vec;
    
    std::string s = "Hello";
    vec.emplace_back(s);              // lvalue (복사)
    vec.emplace_back("World");        // rvalue (이동)
    vec.emplace_back(std::string("Test"));  // rvalue (이동)
    
    std::cout << "벡터 내용:" << std::endl;
    vec.print();
    
    return 0;
}

출력:

emplace_back 호출
emplace_back 호출
emplace_back 호출
벡터 내용:
  Hello
  World
  Test

패턴 3: 로깅 시스템

#include <sstream>
#include <iostream>
#include <string>

class Logger {
public:
    template<typename... Args>
    void log(Args&&... args) {
        std::ostringstream oss;
        (oss << ... << std::forward<Args>(args));
        std::cout << "[LOG] " << oss.str() << std::endl;
    }
    
    template<typename... Args>
    void error(Args&&... args) {
        std::ostringstream oss;
        (oss << ... << std::forward<Args>(args));
        std::cerr << "[ERROR] " << oss.str() << std::endl;
    }
};

int main() {
    Logger logger;
    
    std::string user = "Alice";
    logger.log("User ", user, " logged in");  // lvalue
    logger.log("Error code: ", 404);          // rvalue
    logger.error("Failed to connect to ", "database");
    
    return 0;
}

출력:

[LOG] User Alice logged in
[LOG] Error code: 404
[ERROR] Failed to connect to database

8. 실전 예제: 체이닝 빌더

#include <iostream>
#include <string>
#include <utility>

class QueryBuilder {
    std::string query_;
    
public:
    QueryBuilder() : query_("SELECT * FROM table") {}
    
    template<typename T>
    QueryBuilder&& where(T&& condition) && {
        query_ += " WHERE " + std::forward<T>(condition);
        return std::move(*this);
    }
    
    template<typename T>
    QueryBuilder&& orderBy(T&& field) && {
        query_ += " ORDER BY " + std::forward<T>(field);
        return std::move(*this);
    }
    
    template<typename T>
    QueryBuilder&& limit(T&& count) && {
        query_ += " LIMIT " + std::to_string(std::forward<T>(count));
        return std::move(*this);
    }
    
    std::string build() && {
        return std::move(query_);
    }
};

int main() {
    std::string condition = "age > 18";
    std::string field = "name";
    
    auto query1 = QueryBuilder()
        .where(condition)      // lvalue
        .orderBy(field)        // lvalue
        .limit(10)             // rvalue
        .build();
    
    std::cout << query1 << std::endl;
    
    auto query2 = QueryBuilder()
        .where("status = 'active'")  // rvalue
        .orderBy("created_at")       // rvalue
        .build();
    
    std::cout << query2 << std::endl;
    
    return 0;
}

출력:

SELECT * FROM table WHERE age > 18 ORDER BY name LIMIT 10
SELECT * FROM table WHERE status = 'active' ORDER BY created_at

정리

핵심 요약

  1. 완벽 전달: lvalue/rvalue 특성 유지
  2. std::forward: 조건부 이동
  3. 유니버설 참조: T&& (템플릿)
  4. 참조 축약: lvalue 참조 우선
  5. 실무: 팩토리, 래퍼, emplace

forward vs move

특징std::forwardstd::move
용도조건부 이동무조건 이동
타입원래 유지rvalue로 캐스트
사용처템플릿일반 코드
lvaluelvalue 유지rvalue로 변환
rvaluervalue 유지rvalue 유지

실전 팁

사용 원칙:

  • 팩토리 함수: make_unique, make_shared
  • 래퍼 함수: 인자 전달
  • emplace 계열: emplace_back
  • 이벤트 시스템: 콜백 등록

성능:

  • 불필요한 복사 제거
  • 큰 객체일수록 효과 큼
  • 컴파일 타임 최적화
  • 런타임 오버헤드 없음

주의사항:

  • 한 번만 forward
  • 유니버설 참조 이해
  • 참조 축약 규칙
  • 완벽 전달 실패 케이스 (비트 필드, 오버로드, 중괄호 초기화)

다음 단계

  • C++ Universal Reference
  • C++ Move Semantics
  • C++ Rvalue Reference

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

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

  • C++ Universal Reference | “유니버설 레퍼런스” 가이드
  • C++ Move 시맨틱스 | “복사 vs 이동” 완벽 이해
  • C++ Perfect Forwarding | std::forward로 “복사 없이 인자 전달”

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


관련 글

  • C++ Move 시맨틱스 |
  • C++ Rvalue vs Lvalue |
  • C++ 참조(Reference) 완벽 가이드 | lvalue·rvalue
  • C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화
  • C++ Perfect Forwarding | std::forward로