C++ 시그니처: 성능뿐만 아니라 소유권도 중요합니다

발행: (2026년 2월 1일 오전 10:24 GMT+9)
14 min read
원문: Dev.to

Source: Dev.to

Passing by Value vs. Passing by Reference

Java와 Python에서는 모든 객체가 포인터로 추적되지만, 언어가 그 포인터를 숨겨 개발자들의 편의를 도모합니다.
값에 의한 전달을 원한다면, 직접 깊은 복사를 만들거나 내부에서 깊은 복사를 수행하는 메서드를 호출해야 합니다.

이러한 언어들은 보통 사용성원시 성능보다 더 중시하기 때문에, 참조에 의한 전달이 일반적인 좋은 방법이 됩니다.

아마도 이렇게 생각할 수도 있습니다: “하지만 C++에서도 참조에 의한 전달이 항상 더 좋잖아요! 새 객체를 만들 필요가 없으니까요.”
그렇다면 왜 다른 접근 방식이 필요할까요? 핵심은 세부 사항에 있습니다.

관리형 언어와 비관리형 언어에서의 소유권

  • 관리형 언어 (Java, Python, …) 은 변수의 소유권을 숨깁니다. 소유권을 추적하기 어려워도 누가 신경 쓰겠습니까? 결국 가비지 컬렉터가 우리 대신 정리를 해 주니까요!
  • 비관리형 언어 (C++, Rust, …) 은 소유권 관리를 개발자에게 맡깁니다. 참조에 의한 전달은 악몽이 될 수 있습니다. 그래서 스마트 포인터RAII가 등장했는데, 이는 메모리를 할당한 사람이 메모리를 해제하지 않거나, 더 나아가 두 번 해제하는 일을 방지하기 위함입니다.

이 짧은 글에서는 API 시그니처를 작성할 때 소유권을 파악하는 데 도움이 되는 경험 법칙 몇 가지를 다룹니다.

C++ Primer: rvalues vs. lvalues

CategoryDescriptionExample
rvalue지속적인 메모리 주소를 갖지 않는 임시 객체(예: 리터럴, 임시 반환값).std::println("{}", 1); // prints an rvalue
lvalue메모리 상에 식별 가능한 위치(주소)와 이름을 가진 객체(예: 명명된 변수).int x = 2; std::println("{}", x); // prints an lvalue

함수를 작성할 때 인수가 rvalue인지 lvalue인지 보통 알 필요도 없고, 신경 쓸 필요도 없습니다. 다음과 같은 경험 법칙을 따르면 서명을 선택할 때 과도하게 고민하지 않으면서도 소유권 문제를 피할 수 있습니다.

Source:

API‑Signature 카테고리

핵심 키워드는 소유권(OWNERSHIP) 입니다. 메서드는 세 가지 카테고리로 나눌 수 있습니다:

  1. 소유 메서드 – 함수가 인자를 소유합니다.
  2. 비소유 메서드 – 함수가 인자를 단순히 관찰만 합니다.
  3. 제네릭 메서드 – 함수가 소유 메서드와 비소유 메서드 호출자를 모두 지원합니다.

아래에서는 각 카테고리를 세부 유형으로 나누지만, 기본 원칙은 변함없이 누가 무엇을 소유하는가 입니다.

1. 소유 메서드 (값 전달)

인자를 값으로 받아 소유합니다(보통 이동(move)으로).

Person temp_person;

void set_name(std::string str) {
    temp_person.name = std::move(str);   // 소유권 획득
}

호출 위치

set_name(std::string("Jane Doe"));   // 1 – rvalue
std::string name = "John Doe";
set_name(name);                      // 2 – lvalue (복사 + 이동)
set_name(std::move(name));           // 3 – lvalue를 rvalue로 캐스팅 (이동만)

세 경우에 대한 설명

경우일어나는 일연산
1 – rvaluestr이 임시 객체로부터 이동 생성되고, 이어서 temp_person.name에 이동됩니다.이동 2회
2 – lvaluestrname으로부터 복사 생성된 뒤, 이동됩니다.복사 1회 + 이동 1회
3 – lvalue 캐스팅std::move(name)이 rvalue를 반환하므로 str이 이동 생성됩니다.이동 1회

이 패턴이 성능에 민감한 경로에 있다면, 불필요한 복사를 없애기 위해 오버로드를 사용할 수 있습니다:

// lvalue 오버로드
void set_name(std::string& str) {
    temp_person.name = std::move(str);
}

// rvalue 오버로드
void set_name(std::string&& str) {
    temp_person.name = std::move(str);
}

이제 인자의 값 카테고리와 관계없이 한 번의 이동만 수행됩니다.

함정 예시

Person temp_person;

void set_name(std::string& str) { temp_person.name = std::move(str); }
void set_name(std::string&& str){ temp_person.name = std::move(str); }

std::string my_precious_string = "password123";
set_name(my_precious_string);          // 소유권이 이전됨!
std::println("My password: {}", my_precious_string); // 정의되지 않음 / 빈 문자열

핵심: 값을 복사해서 전달하는 방식은 소유권 의미가 명확해 보통 안전한 설계 선택입니다.

2. 제네릭(파이프) 메서드 – 비소유

파이프소유하지 않는 객체로, 일시적으로 객체에 접근합니다. 소유권을 전혀 취하지 않고 참조만 받습니다.

void add_exclamation(std::string& str) {
    // 여기서 소유권을 취하면 안 됩니다 – 안티패턴입니다.
    str.push_back('!');
}

함수가 인자를 소유하지 않기 때문에, 수정 내용은 호출자에게 바로 반영됩니다.

3. 값 반환 (소유권 이전)

값으로 전달받아 수정한 뒤 반환하는 방식은 소유권을 호출자에게 되돌려 주는 깔끔한 방법입니다.

std::string add_exclamation(std::string str) {
    // 여기서 소유권을 취하는 것은 괜찮습니다.
    str.push_back('!');
    return str;          // 이동 반환(NRVO 또는 move)
}

std::string phrase = "Lorem Ipsum";
phrase = add_exclamation(phrase);   // 소유권이 `phrase`에 남음

4. 이동 전용 타입

값 전달은 std::unique_ptr이나 std::thread와 같은 이동 전용 객체와 자연스럽게 어울립니다.

std::unique_ptr foo(std::unique_ptr ptr) {
    // `ptr`은 함수 내부에서 소유됩니다.
    // 수정은 호출자에게 영향을 주지 않습니다.
    return ptr;               // 이동 반환
}

void bar(std::unique_ptr& ptr) {
    // `ptr`은 여기서 소유되지 않습니다.
    // 수정은 호출자에게 영향을 줍니다.
}

5. 읽기 전용 접근 (비소유, const 참조)

객체를 관찰만 하면 될 때는 const 참조를 사용합니다.

void print_name(const std::string& str) {
    std::println("Name: {}", str);
}

print_name(std::string("Jane Doe"));   // rvalue – const ref에 바인딩
std::string name = "John Doe";
print_name(name);                      // lvalue – const ref에 바인딩

Summary of Rules of Thumb

SituationRecommended signatureReason
소유권을 가져가기 (인자를 저장하거나 이동할 경우)void f(T obj) (pass‑by‑value)로컬 복사/이동을 보장합니다; 호출자는 소유권이 이전됨을 알 수 있습니다.
읽기/관찰만void f(const T& obj)소유권이 변경되지 않으며, lvalues와 rvalues 모두에서 동작합니다.
소유권을 가져가지 않고 수정void f(T& obj)호출자는 소유권을 유지하고, 수정 내용이 반영됩니다.
lvalue와 rvalue를 효율적으로 지원Provide overloads: void f(T&); void f(T&&);각 오버로드는 매개변수에서 이동할 수 있어 불필요한 복사를 방지합니다.
move‑only 타입 처리Pass‑by‑value (or T&& overload)리소스를 함수로 이동시킬 수 있습니다.

By keeping ownership explicit in your API signatures, you avoid subtle bugs, make intent clear to users, and let the compiler help you enforce the correct semantics.
API 서명에서 소유권을 명시적으로 유지하면 미묘한 버그를 방지하고, 사용자의 의도를 명확히 하며, 컴파일러가 올바른 의미론을 강제하도록 도와줍니다.

Source:

매개변수 전달 및 전달 참조 이해

print_name(std::string("Jane Doe"));   // rvalue – binds to const ref
std::string name = "John Doe";
print_name(name);                      // lvalue – binds to const ref

경우들

#상황무슨 일이 일어나는가
1rvalue 전달const std::string&에 바인딩됩니다. 레퍼런스가 const이기 때문에 언어가 rvalue 바인딩을 허용합니다. 복사나 이동이 발생하지 않습니다.
2lvalue 전달역시 const std::string&에 바인딩됩니다. 복사나 이동이 발생하지 않습니다.

Note: 유일한 예외는 전달하는 타입이 레퍼런스보다 작거나 같은 크기(보통 ≤ 8 바이트)인 경우입니다. 예를 들어 int, char, bool 등은 값으로 전달하는 것이 더 바람직할 수 있습니다.

이 주제는 아직 논쟁 중이며, 최적화가 반드시 필요한 핫 경로가 아니라면 일관성을 위해 const T&를 유지하는 것이 좋습니다.

포워딩 레퍼런스를 사용해야 할 때

고도로 일반적인 메서드가 필요할 때는 다음을 사용하십시오:

  • 포워딩 레퍼런스
  • auto 반환 타입
  • 완벽 포워딩

주의: 포워딩 레퍼런스는 일반 래퍼팩토리‑스타일 유틸리티에만 사용하십시오. 일반 API에서 과도하게 사용하면 디버깅이 어려운 오버로드‑해석 문제를 일으키는 경우가 많습니다.

구문 (템플릿 전용)

template <class T>
decltype(auto) omega_foo(T&& t) {          // T&& is a forwarding reference
    // Forward the argument preserving its value‑category
    return omega_bar(std::forward<T>(t));
}

이 패턴은 STL 전반에 걸쳐 사용되며, 대표적인 예는 emplace_back입니다.

std::vector<std::pair<int,int>> v;

v.emplace_back(1, 1);           // perfect‑forwarded arguments
std::pair<int,int> x = {2, 2};

v.emplace_back(x);              // copy
v.emplace_back(std::move(x));   // move

각 호출마다 적절한 (보통 최적화된) 생성자가 선택됩니다.

Source:

전달 참조를 이용한 객체 생성

간단한 래퍼

template <class T, class U>
auto constructor_wrapper(U&& u) {
    // Build and return an object of type T initialized with `u`
    return T(std::forward<U>(u));
}

가변 인자 버전

template <class T, class... Args>
auto constructor_wrapper(Args&&... args) {
    // Build and return an object of type T initialized with whatever was passed
    return T(std::forward<Args>(args)...);
}

허용 인자 제한

가변 인자 버전은 모든 것을 받아들이므로, 이를 제한할 수 있습니다:

template <class T, class... Args>
concept ValidArg = !std::is_null_pointer_v<std::remove_cvref_t<Args>>;

template <class T, class... Args>
requires (ValidArg<Args> && ...)
auto constructor_wrapper(Args&&... args) {
    return T(std::forward<Args>(args)...);
}
  • 이 구현은 std::nullptr_t거부합니다.
  • NULL을 전달하면 정수 0으로 포워드됩니다.
  • std::initializer_list 추론과는 잘 동작하지 않습니다.

또 다른 미묘한 점: 래퍼는 괄호 생성자 T(...)를 사용합니다.
이는 균일 초기화 형태 T{...}와 다를 수 있습니다(예: std::vector의 경우).

올바른 매개변수 시그니처 선택

카테고리권장 시그니처이유
소유 메서드 (Sink)void f(T arg) + std::move 내부소유권을 효율적으로 가져감 (move 또는 copy).
파이프 메서드T& arg 또는 T f(T arg)소유 의도에 따라 제자리 수정 또는 “복사‑수정‑반환”.
비소유 메서드 (Read)const T& arg객체를 복사 없이 관찰.
비소유 메서드 (Small*)T arg작은 타입은 값으로 전달하는 것이 더 빠름.
제네릭 메서드template <class T> f(T&& arg)래퍼/팩토리에서 완벽 전달.

*Small types: int, double, bool, std::string_view, std::span, 등.

최종 생각

예제들에서 보이는 것만큼 쉽지는 않습니다. 개념과 경계 사례를 완전히 이해하려면 Modern Effective C++를 읽으세요.

기억하세요: “APIs live longer than optimizations.”

올바른 상황에 맞는 적절한 시그니처를 사용하고, 정말 필요할 때만 최적화하세요.

Back to Blog

관련 글

더 보기 »

C# 14 확장 블록

소개: C 14 확장 블록에 대해 알아보기 https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methodsdeclare-ex...