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
정리
핵심 요약
- 완벽 전달: lvalue/rvalue 특성 유지
- std::forward: 조건부 이동
- 유니버설 참조:
T&&(템플릿) - 참조 축약: lvalue 참조 우선
- 실무: 팩토리, 래퍼, emplace
forward vs move
| 특징 | std::forward | std::move |
|---|---|---|
| 용도 | 조건부 이동 | 무조건 이동 |
| 타입 | 원래 유지 | rvalue로 캐스트 |
| 사용처 | 템플릿 | 일반 코드 |
| lvalue | lvalue 유지 | rvalue로 변환 |
| rvalue | rvalue 유지 | 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로