본문으로 건너뛰기

안동민 개발노트

본문 시작

참조자

기존 변수의 별명인 참조자의 초기화와 원본 변경 방식을 익히고 값·참조 전달 및 포인터와의 차이를 구분합니다.

포인터는 메모리 주소를 직접 다루는 도구이지만, 그만큼 복잡하고 오용의 위험이 따릅니다.

C++에서는 포인터의 일부 기능을 더 안전하고 편리하게 사용할 수 있도록 참조자(Reference)라는 개념을 제공합니다.

이 절에서 다루는 C++ 참조자는 기존 변수에 대한 별명(alias)을 만드는 언어 기능입니다.

포인터와 유사하지만, 더 안전하고 간결한 문법을 제공하여 코드의 가독성을 높여줍니다.

먼저 참조자가 원본 객체를 다른 이름으로 다루는 바인딩 관계를 그림으로 정리합니다.

C++ 참조가 기존 객체에 한 번 바인딩되어 같은 객체를 읽고 쓰는 관계와, 널 상태와 재지정을 표현할 수 있는 포인터 및 읽기 전용 const 참조의 계약을 비교한 표

OBJECT IDENTITY · FIXED BINDING · OPTIONAL ADDRESS

참조자는 객체의 별명이고 포인터는 주소 값이다

T& ref = object;는 선언 순간 기존 객체에 바인딩되고, 이후 ref를 읽거나 쓰는 일은 그 객체를 읽거나 쓰는 일입니다. 포인터는 주소 값을 저장하므로 비어 있거나 다른 주소로 바뀔 수 있으며, 사용 전에 대상과 수명을 확인해야 합니다.

reference · required

int& ref = value;

참조는 독립된 값을 가진 새 객체가 아니라 value를 가리키는 언어 수준의 별명입니다. 한 번 바인딩된 뒤 다른 객체의 별명으로 재지정되지 않습니다.

pointer · optional

int* ptr = &value;

포인터는 주소 값을 보유합니다. nullptr로 대상 부재를 표현하거나 &other를 대입해 다른 객체를 가리킬 수 있습니다.

assignment is not rebinding

참조자 대입은 원본 객체의 값을 바꾼다

int first = 5;
int second = 15;
int& ref = first;

ref = second;  // first가 15가 됨
               // ref는 여전히 first의 별명

&ref == &first 관계는 유지됩니다. 대입문의 오른쪽 값이 참조 대상에 쓰일 뿐, 바인딩이 second로 이동하지 않습니다.

대상 존재성, 변경 권한, 재지정 가능성으로 읽는 세 가지 간접 접근
형태대상 계약사용과 변경수명 점검
T&호출 시 실제 대상이 필요하며 널 상태를 표현하지 않습니다.역참조 문법 없이 읽고 쓰며, 다른 대상으로 재바인딩되지 않습니다.대상 객체가 먼저 소멸하면 참조는 댕글링 상태가 됩니다.
const T&실제 대상을 읽기 전용으로 빌립니다.복사 없이 관찰하지만 이 참조를 통해서는 수정할 수 없습니다.소유권을 갖지 않으므로 원본 또는 바인딩된 임시 객체의 수명 규칙을 따라야 합니다.
T*nullptr로 대상 부재를 표현할 수 있습니다.다른 주소로 재지정할 수 있고, 접근할 때 * 또는 ->가 필요합니다.널 여부만으로 충분하지 않으며 객체 존재·수명·범위·타입을 확인해야 합니다.
T&

필수이며 수정 가능한 고정 대상

직접 읽고 쓰지만 재바인딩되지 않습니다. 대상이 먼저 소멸하면 댕글링 참조가 됩니다.

const T&

필수이며 읽기 전용인 빌림

복사를 피하면서 관찰하지만 소유권이나 원본 수명을 늘려 주지는 않습니다.

T*

선택 가능하고 재지정 가능한 주소

nullptr와 다른 주소를 표현할 수 있으며, 역참조 전에 대상과 수명을 검증합니다.

참조에 포인터 같은 well-defined 널 상태는 없지만, 이것이 런타임 유효성을 자동으로 증명한다는 뜻은 아닙니다. 참조를 만드는 코드와 사용하는 코드는 대상 객체가 살아 있다는 수명 계약을 함께 지켜야 합니다.


참조자란 무엇인가?

참조자이미 존재하는 변수에 대한 다른 이름, 즉 별명입니다.

참조자를 선언할 때 & (앰퍼샌드) 기호를 사용하며, 이는 포인터의 주소 연산자와 동일한 기호이지만 사용되는 맥락이 다릅니다.

참조자가 만들어지면, 참조자와 원래 변수는 동일한 객체를 나타냅니다.

따라서 참조자를 통해 값을 변경하면 원본 변수의 값도 변경되고, 그 반대도 마찬가지입니다.

주요 특징
  • 별명 (Alias): 참조자는 독립된 값을 갖는 새 객체가 아니라 기존 객체를 다른 이름으로 다루는 언어 수준의 별명입니다. 참조가 내부적으로 저장 공간을 사용하는지는 구현에 따라 달라질 수 있습니다.
  • 선언 시 반드시 초기화: 참조자는 선언과 동시에 반드시 어떤 변수를 참조할 것인지 초기화해야 합니다. 한 번 초기화되면 다른 변수를 참조하도록 변경할 수 없습니다. (포인터는 언제든 다른 주소로 변경 가능)
  • 널 상태가 없음: 올바르게 생성된 참조자에는 포인터의 nullptr 같은 빈 상태가 없습니다. 다만 원본 객체의 수명이 끝난 뒤에도 참조자를 사용하면 댕글링 참조가 되어 정의되지 않은 동작이 발생하므로, 대상의 수명은 별도로 지켜야 합니다.
  • 역참조 불필요: 참조자는 마치 원본 변수처럼 직접 사용할 수 있습니다. 포인터처럼 * 연산자를 사용하여 역참조할 필요가 없습니다.

참조자 선언 및 사용 예시

참조자를 선언할 때는 데이터 타입 뒤에 &를 붙입니다.

참조자 선언 형식
데이터타입& 참조자이름 = 참조할_변수;
참조자 예시 - 정수형 변수
#include <iostream>

int main() {
    int num = 10;     // int형 변수 num 선언 및 초기화
    int& ref = num;   // num에 대한 참조자 ref 선언 및 초기화

    std::cout << "num의 값: " << num << std::endl;      // 출력: 10
    std::cout << "ref의 값: " << ref << std::endl;      // 출력: 10 (num과 동일한 값)

    // num의 주소와 ref의 주소는 동일합니다.
    std::cout << "num의 주소: " << &num << std::endl;  // (예: 0x7ffee0000010)
    std::cout << "ref의 주소: " << &ref << std::endl;  // (예: 0x7ffee0000010, num과 동일)

    // 참조자를 통해 값 변경
    ref = 20; // ref를 통해 값을 변경하면 num의 값도 변경됨
    std::cout << "\nref = 20 후:" << std::endl;
    std::cout << "num의 값: " << num << std::endl;      // 출력: 20
    std::cout << "ref의 값: " << ref << std::endl;      // 출력: 20

    // num을 통해 값 변경
    num = 30; // num을 통해 값을 변경하면 ref의 값도 변경됨
    std::cout << "\nnum = 30 후:" << std::endl;
    std::cout << "num의 값: " << num << std::endl;      // 출력: 30
    std::cout << "ref의 값: " << ref << std::endl;      // 출력: 30

    return 0;
}

참조자와 포인터의 차이점

참조자는 포인터처럼 간접 접근을 제공하지만, 한 번 묶이면 다른 대상으로 바뀌지 않는 별명이라는 점이 핵심입니다.

이 차이는 바인딩, 접근 방식, const 참조 관점에서 읽을 수 있습니다.

참조자와 포인터는 둘 다 다른 변수에 간접적으로 접근하는 기능을 제공하지만, 중요한 차이점들이 있습니다.

특징포인터 (Pointer)참조자 (Reference)
선언데이터타입* 변수이름;데이터타입& 변수이름 = 변수;
표현 의미객체 주소를 값으로 저장하고 간접 접근독립 객체가 아니라 이미 존재하는 객체의 별명
초기화선언 후 나중에 초기화 가능 (nullptr 가능)선언 시 반드시 초기화해야 함
재지정다른 변수의 주소를 저장하여 재지정 가능한 번 초기화되면 재지정 불가능 (항상 동일한 변수 참조)
널 상태nullptr이 될 수 있음 (아무것도 가리키지 않음)well-defined한 널 상태는 없지만 대상 수명 종료 후 댕글링 가능
접근* (역참조) 연산자 필요* (역참조) 연산자 없이 직접 변수처럼 사용
산술 연산같은 배열 객체의 허용 범위 안에서 포인터 산술 가능참조자 산술 연산 불가능
참조자 초기화 및 재지정 불가 예시
#include <iostream>

int main() {
    int val1 = 5;
    int val2 = 15;

    // int& ref_uninitialized; // 컴파일 오류: 참조자는 선언 시 반드시 초기화해야 함

    int& ref = val1; // OK: val1을 참조하도록 초기화
    std::cout << "ref (val1): " << ref << std::endl; // 출력: 5

    ref = val2; // val2로 재바인딩하지 않고, ref가 가리키는 val1에 값 15를 대입
    std::cout << "ref = val2 후, val1: " << val1 << std::endl; // 출력: 15 (val1의 값이 15로 변경됨)
    std::cout << "ref = val2 후, ref: " << ref << std::endl;   // 출력: 15
    std::cout << "ref = val2 후, val2: " << val2 << std::endl; // 출력: 15

    // ref 자체의 수명 동안 다른 대상으로 재바인딩되지 않습니다.
    // 대상이 먼저 소멸하면 ref를 사용해서는 안 됩니다.

    return 0;
}

참조자를 활용하는 주요 시나리오

참조자는 주로 다음 두 가지 상황에서 매우 유용하게 사용됩니다.

함수 매개변수로 전달 (Pass-by-Reference): 함수에 인자를 전달할 때, 값의 복사 없이 원본 변수를 직접 참조하여 함수 내에서 변경할 수 있도록 합니다.

이는 큰 객체를 전달할 때 복사 오버헤드를 줄여 성능을 향상시키고, 함수가 원본 값을 변경해야 할 때 유용합니다.

참조자를 함수 매개변수로 전달
#include <iostream>

void increment(int& num) { // int& num: num은 호출자의 원래 변수를 참조
    num++; // 원본 변수의 값이 증가
}

int main() {
    int val = 10;
    increment(val); // val을 참조로 전달
    std::cout << val << std::endl; // 출력: 11 (val이 변경됨)
    return 0;
}

함수의 반환 값으로 사용: 함수가 어떤 변수에 대한 참조자를 반환하여, 함수 호출자가 반환된 참조자를 통해 해당 변수를 직접 수정할 수 있도록 합니다.

이는 특히 클래스에서 멤버 변수에 대한 접근자(accessor)나 연산자 오버로딩 시 유용하게 사용됩니다.

참조자를 함수 반환 값으로 사용
#include <iostream>

int global_val = 100;

int& getGlobalVal() { // int& 반환: 전역 변수 global_val에 대한 참조 반환
    return global_val;
}

int main() {
    getGlobalVal() = 200; // 반환된 참조자를 통해 global_val의 값을 변경
    std::cout << global_val << std::endl; // 출력: 200
    return 0;
}

주의: 함수가 지역 변수에 대한 참조자를 반환하는 것은 매우 위험합니다.

지역 변수의 수명은 함수가 끝날 때 종료되므로, 반환된 참조자는 댕글링 참조(dangling reference)가 됩니다. 이후 그 참조자를 사용하면 정의되지 않은 동작입니다.

함수 매개변수를 설계할 때는 값, 포인터, 참조, const 참조 중 의도를 가장 정확히 드러내는 형태를 고르는 것이 중요합니다.

C++ 함수 매개변수에서 값, const 참조, 수정 참조, 포인터를 대상 존재성, 소유와 복사, 변경 의도로 선택하고 반환 참조는 대상 수명이 호출자의 사용보다 길 때만 안전하다는 계약표

VALUE · BORROW · MUTATION · OPTIONAL TARGET

매개변수 형태는 함수가 원본을 어떻게 다룰지 선언한다

값, const T&, T&, T*의 차이는 성능만이 아니라 소유·변경·대상 존재성에 대한 API 계약입니다. 호출자가 시그니처만 보고도 사본을 만드는지, 원본을 바꾸는지, 대상이 없을 수 있는지를 알 수 있어야 합니다.

함수가 필요로 하는 관계에 따른 매개변수 선택
형태선택 조건호출자에게 보이는 계약주의할 수명·비용
T value작은 값이거나 함수가 독립 사본·소유 값을 필요로 합니다.함수 내부 변경은 호출자의 원본을 직접 바꾸지 않습니다.호출 과정에서 복사 또는 이동이 일어날 수 있습니다.
const T&대상이 반드시 있고 함수가 읽기만 하며 복사를 피하려 합니다.원본을 빌려 관찰하고 이 참조를 통해 수정하지 않습니다.큰 문자열·컨테이너에 유용하지만 소유권은 없으며, 호출 뒤 보관하려면 별도 수명 계약이 필요합니다.
T&함수가 반드시 존재하는 호출자 객체를 수정해야 합니다.호출 뒤 원본 값이 달라질 수 있음을 시그니처가 드러냅니다.대상 수명은 호출 전체를 포함해야 하며 재바인딩은 일어나지 않습니다.
T*대상이 없을 수 있거나 주소를 다루는 API 경계입니다.nullptr 처리 정책과 수정 가능성을 문서화해야 합니다.역참조 전에 대상 존재·수명·범위·타입을 검증합니다.
value

독립 사본 또는 소유 값

작은 값이나 함수 내부 보관이 필요할 때 선택합니다. 원본을 직접 수정하지 않으며 복사·이동 비용을 고려합니다.

const T&

필수 대상, 무복사 읽기

큰 객체를 빌려 읽되 수정하지 않습니다. 호출 뒤 보관하려면 대상이 더 오래 산다는 별도 계약이 필요합니다.

T&

필수 대상, 원본 수정

함수가 호출자의 객체를 바꾼다는 사실을 시그니처로 드러냅니다.

T*

선택적 대상

대상이 없을 수 있을 때 사용하고 nullptr 정책과 역참조 조건을 명시합니다.

return by reference

호출 뒤에도 살아 있는 객체만 참조로 반환한다

호출자가 소유한 객체, 수명이 충분한 멤버, 정적 저장 기간 객체처럼 반환 참조의 사용보다 오래 살아 있는 대상이어야 합니다. 반환 타입은 복사가 아니라 그 객체에 대한 접근을 제공합니다.

dangling · undefined behavior

지역 변수의 참조를 반환하지 않는다

함수가 끝나면 지역 객체의 수명이 종료됩니다. 그 참조를 반환하면 즉시 댕글링 상태가 되고, 호출자가 읽거나 쓰는 순간 정의되지 않은 동작입니다.

const reference and temporaries

const T&는 임시 값을 받을 수 있지만 보관 권한은 주지 않는다

참조 매개변수에 바인딩된 임시 객체는 그 함수 호출을 포함하는 전체 표현식이 끝날 때까지 유효합니다. 함수가 그 참조나 포인터를 이후 사용하려고 저장하면 수명 계약을 위반합니다.

“복사가 싫다”만으로 참조를 선택하지 않습니다. 함수가 대상을 필수로 요구하는지, 원본 변경을 약속하는지, 호출 뒤 값을 보관해야 하는지를 먼저 정하면 형태와 수명 책임이 함께 선명해집니다.


const 참조자 (const reference)

참조자를 const로 선언하면, 해당 참조자를 통해 원본 변수의 값을 변경할 수 없게 됩니다.

이는 함수에 인자를 전달할 때 유용합니다.

복사를 피하면서도 원본 데이터가 함수 내에서 실수로 변경되는 것을 방지할 수 있습니다.

const 참조자 예시
#include <iostream>

void printConstRef(const int& num) { // const int&: num을 변경할 수 없음
    // num = 20; // 컴파일 오류! const 참조자는 값을 변경할 수 없음
    std::cout << "상수 참조로 받은 값: " << num << std::endl;
}

int main() {
    int data = 10;
    printConstRef(data); // data를 변경 없이 참조로 전달
    printConstRef(20);   // 임시 객체도 const 참조로 받을 수 있음
    return 0;
}

const 참조자는 함수가 소유권을 넘겨받지 않고 읽기만 하며 복사 비용을 피하려는 경우, std::vector, std::string과 같은 큰 객체를 전달하는 흔한 선택입니다.

복사 비용과 수정 가능성을 함께 판단하면 const 참조자를 언제 선택해야 하는지 더 선명해집니다.