C++ 레퍼런스란? 참조의 기본 개념과 사용 방법

앞선 글에서는 포인터에 변수의 메모리 주소를 저장하고, 역참조 연산자 *를 사용해 원래 값에 접근하는 방법을 알아보았습니다.

int number = 5;
int* pointer = &number;

*pointer = 10;

C++에서는 레퍼런스(Reference)를 사용해 기존 변수에 직접 접근할 수도 있습니다.

int number = 5;
int& reference = number;

reference = 10;

레퍼런스는 기존 변수에 새로운 이름을 붙여, 포인터보다 간결한 문법으로 해당 변수에 접근할 수 있게 해주는 도구입니다.

이번 글에서는 레퍼런스의 기본 개념과 선언 방법, 초기화 및 재지정에 관한 제약, 함수 매개변수에서 레퍼런스를 사용하는 방법을 알아보겠습니다.


레퍼런스란?

레퍼런스는 기존 변수의 별칭(Alias)입니다.

별칭은 이미 존재하는 대상을 가리키는 또 다른 이름을 의미합니다.

int number = 5;
int& reference = number;

위 코드에서 reference는 새로운 정수 값을 저장하는 독립적인 변수가 아니라, 기존 변수 number를 나타내는 또 다른 이름으로 사용됩니다.

number = 10;
std::cout << reference << '\n';

number의 값을 변경한 뒤 reference를 출력하면 같은 변수의 값인 10이 출력됩니다.

반대로 레퍼런스를 통해 값을 변경하면 원래 변수의 값도 변경됩니다.

reference = 20;
std::cout << number << '\n';

referencenumber는 서로 다른 값을 저장하는 두 변수가 아니라 같은 객체를 나타내기 때문입니다.


레퍼런스 선언하기

L-value 레퍼런스를 선언할 때는 참조할 데이터의 타입 뒤에 &를 작성합니다.

데이터 타입& 레퍼런스 이름 = 참조할 변수;

정수 변수의 레퍼런스는 다음과 같이 선언할 수 있습니다.

int number = 5;
int& reference = number;

reference의 타입은 int&입니다. 이는 int 타입의 객체를 참조하는 L-value 레퍼런스를 의미합니다.

다른 타입의 변수도 같은 방식으로 참조할 수 있습니다.

float speed = 5.0f;
float& speedReference = speed;

char grade = 'A';
char& gradeReference = grade;

포인터를 선언할 때 사용하는 *와 마찬가지로 &를 타입과 변수 이름 중 어느 쪽에 붙여도 문법상 의미는 같습니다.

int& firstReference = number;
int &secondReference = number;

다만 한 문장에서 여러 변수를 선언하면 각 선언자에 &가 개별적으로 적용됩니다.

int& first = number, second = number;

위 코드에서 firstint&이지만, second는 별도의 int 변수입니다. 혼동을 줄이려면 다음과 같이 선언문을 나누는 것이 좋습니다.

int& first = number;
int& second = number;

레퍼런스 타입을 선언할 때 사용하는 &와 표현식에서 변수의 주소를 구할 때 사용하는 주소 연산자 &는 기호는 같지만 역할이 다릅니다.

int number = 5;

int& reference = number; // 레퍼런스 타입 선언
int* pointer = &number;   // number의 주소를 구하는 연산자

레퍼런스로 원래 변수 변경하기

레퍼런스를 통해 값을 변경하면 레퍼런스가 참조하는 원래 변수의 값이 변경됩니다.

#include <iostream>

#define LOG(x) std::cout << x << '\n'

int main()
{
    int number = 5;
    int& reference = number;

    reference = 10;

    LOG(number);
    LOG(reference);

    std::cin.get();
    return 0;
}

reference = 10은 별도의 복사본에 값을 저장하는 코드가 아닙니다. reference가 나타내는 원래 변수 number10을 저장합니다.

출력 결과

10
10

포인터로 같은 작업을 처리하려면 역참조 연산자가 필요합니다.

int number = 5;
int* pointer = &number;

*pointer = 10;

레퍼런스를 사용하면 일반 변수와 같은 문법으로 원래 값에 접근할 수 있습니다.

int number = 5;
int& reference = number;

reference = 10;
방식 원래 변수의 값 변경
포인터 *pointer = 10;
레퍼런스 reference = 10;

레퍼런스는 반드시 초기화해야 한다

일반 변수는 선언한 뒤 나중에 값을 대입할 수 있습니다.

int number;
number = 10;

포인터도 우선 nullptr로 초기화한 뒤 나중에 유효한 주소를 저장할 수 있습니다.

int* pointer = nullptr;

int number = 10;
pointer = &number;

하지만 레퍼런스는 선언과 동시에 참조할 대상을 지정해야 합니다.

// int& reference; // 오류

레퍼런스는 기존 객체의 별칭으로 만들어져야 하므로 참조할 대상 없이 선언만 해둘 수 없습니다.

int number = 10;
int& reference = number;

일반적인 L-value 레퍼런스에는 nullptr와 같은 비어 있는 상태도 없습니다.

// int& reference = nullptr; // 오류

따라서 레퍼런스를 정상적인 방법으로 초기화했다면 해당 레퍼런스는 유효한 객체를 참조한다고 기대할 수 있습니다.

레퍼런스가 항상 안전하다는 의미는 아닙니다. 참조하던 객체의 수명이 끝난 뒤에도 레퍼런스를 사용하면 댕글링 레퍼런스가 되어 정의되지 않은 동작이 발생할 수 있습니다.


L-value 레퍼런스란?

지금까지 살펴본 int&와 같은 타입을 L-value 레퍼런스라고 합니다.

int number = 10;
int& reference = number;

간단히 설명하면 L-value는 메모리에 존재하며 이름을 통해 다시 접근할 수 있는 객체를 나타냅니다.

변수 number는 이름이 있고 여러 표현식에서 반복해서 접근할 수 있으므로 L-value입니다. 따라서 일반적인 int&에 연결할 수 있습니다.

int value = 10;
int& reference = value;

반면 정수 리터럴 10은 표현식에서 만들어지는 임시 값이므로 일반적인 int&로 참조할 수 없습니다.

// int& reference = 10; // 오류

지금까지의 제약을 함께 살펴보면 다음과 같습니다.

int main()
{
    // int& firstReference;     // 오류: 초기화하지 않았다.
    // int& secondReference = 10; // 오류: 일반 L-value 레퍼런스로 임시 값을 참조할 수 없다.

    int value = 10;
    int& valueReference = value;
}

const int&와 같은 상수 L-value 레퍼런스는 임시 값도 참조할 수 있습니다. C++11부터 제공되는 R-value 레퍼런스 int&&도 임시 객체와 관련된 작업에 사용됩니다. 이번 글에서는 변경 가능한 일반 L-value 레퍼런스의 기본 동작에 집중합니다.


레퍼런스는 다른 변수를 다시 참조할 수 없다

레퍼런스가 한 번 초기화되면 이후에 다른 변수를 참조하도록 변경할 수 없습니다.

int first = 5;
int second = 10;

int& reference = first;

위 코드에서 referencefirst의 별칭으로 초기화되었습니다.

이후 다음과 같이 다른 변수를 대입하더라도 참조 대상이 바뀌지는 않습니다.

reference = second;

이 코드는 referencesecond를 새로 참조하도록 만드는 것이 아닙니다.

reference는 계속 first를 나타내므로, second의 값인 10first에 대입합니다.

#include <iostream>

int main()
{
    int first = 5;
    int second = 10;

    int& reference = first;
    reference = second;

    std::cout << first << '\n';
    std::cout << second << '\n';
    std::cout << reference << '\n';

    return 0;
}

출력 결과

10
10
10

firstsecond의 값이 모두 10이 되어 출력 결과만으로는 참조 대상을 구분하기 어려울 수 있습니다.

두 변수의 값을 다시 다르게 변경하면 reference가 계속 first를 참조한다는 사실을 확인할 수 있습니다.

first = 20;
second = 30;

std::cout << reference << '\n'; // 20

포인터는 저장된 주소를 변경해 다른 변수를 가리키게 할 수 있지만, 레퍼런스는 초기화된 뒤 참조 대상을 변경할 수 없습니다.


레퍼런스와 원래 변수의 주소

레퍼런스는 기존 변수의 별칭이므로 레퍼런스에 주소 연산자 &를 적용하면 원래 변수의 주소를 얻습니다.

#include <iostream>

int main()
{
    int number = 10;
    int& reference = number;

    std::cout << &number << '\n';
    std::cout << &reference << '\n';

    return 0;
}

&number&reference는 같은 객체의 주소를 나타내므로 같은 주소가 출력됩니다.

출력 결과 예시

000000A47FD9F684
000000A47FD9F684

실제 주소는 프로그램을 실행할 때마다 달라질 수 있습니다.

컴파일러는 레퍼런스를 구현하기 위해 내부적으로 주소와 유사한 정보를 사용할 수 있습니다. 그러나 C++ 언어의 관점에서는 레퍼런스를 포인터 변수 자체로 다루지 않고, 기존 객체의 별칭으로 이해하는 것이 적절합니다.


포인터와 레퍼런스 비교하기

포인터와 레퍼런스는 모두 다른 객체에 간접적으로 접근할 때 사용할 수 있지만 문법과 제약이 다릅니다.

구분 포인터 레퍼런스
선언 int* pointer int& reference
저장하거나 나타내는 대상 객체의 메모리 주소 기존 객체의 별칭
초기화 nullptr로 초기화 가능 선언과 동시에 객체를 지정해야 함
대상 변경 다른 주소를 저장할 수 있음 초기화 후 참조 대상 변경 불가
값 접근 *pointer로 역참조 일반 변수와 같은 문법 사용
비어 있는 상태 nullptr로 표현 가능 일반적으로 제공되지 않음
포인터 연산 가능 레퍼런스 자체에는 적용하지 않음

다음 두 코드는 모두 number의 값을 10으로 변경합니다.

int number = 5;

int* pointer = &number;
*pointer = 10;
int number = 5;

int& reference = number;
reference = 10;

대상이 반드시 존재하고 도중에 변경되지 않아야 한다면 레퍼런스를 사용해 의도를 명확하게 표현할 수 있습니다.

대상이 없을 수 있거나, 실행 중에 다른 대상을 가리켜야 한다면 포인터가 더 적합할 수 있습니다.


값에 의한 전달

함수의 일반 매개변수는 전달받은 인자의 값을 복사해서 초기화됩니다. 이를 값에 의한 전달(Pass by Value)이라고 합니다.

#include <iostream>

#define LOG(x) std::cout << x << '\n'

void Increment(int value)
{
    value++;
}

int main()
{
    int number = 5;

    Increment(number);

    LOG(number);

    std::cin.get();
    return 0;
}

Increment를 호출하면 number의 값인 5가 매개변수 value에 복사됩니다.

함수 안에서 증가하는 대상은 복사본인 value이므로 호출한 쪽의 number는 변경되지 않습니다.

출력 결과

5

함수가 종료되면 매개변수 value도 사라지지만, 원래 변수 number는 그대로 유지됩니다.


레퍼런스에 의한 전달

함수의 매개변수를 레퍼런스로 선언하면 호출할 때 전달한 원래 변수를 참조할 수 있습니다.

void Increment(int& value)
{
    value++;
}

value는 전달받은 값을 저장하는 복사본이 아니라 호출한 쪽에 있는 변수의 별칭이 됩니다.

#include <iostream>

#define LOG(x) std::cout << x << '\n'

void Increment(int& value)
{
    value++;
}

int main()
{
    int number = 5;

    Increment(number);

    LOG(number);

    std::cin.get();
    return 0;
}

Increment(number)를 호출하면 매개변수 valuenumber를 참조합니다.

따라서 함수 안에서 실행한 value++는 원래 변수 number의 값을 증가시킵니다.

출력 결과

6

이처럼 함수가 레퍼런스 매개변수를 통해 호출한 쪽의 원래 객체에 접근하는 방식을 레퍼런스에 의한 전달(Pass by Reference)이라고 합니다.

전달 방식 매개변수 원래 변수 변경 여부
값에 의한 전달 void Increment(int value) 변경되지 않음
레퍼런스에 의한 전달 void Increment(int& value) 변경됨

레퍼런스 매개변수 사용 시 주의점

레퍼런스 매개변수는 함수 호출 문법만 보았을 때 값 전달과 구분하기 어려울 수 있습니다.

Increment(number);

함수 선언을 확인하지 않으면 number가 복사되는지, 함수 안에서 변경될 수 있는지 바로 알기 어렵습니다.

void Increment(int& value);

따라서 변경 가능한 레퍼런스 매개변수를 사용하는 함수는 이름과 역할을 명확하게 작성하는 것이 좋습니다.

함수가 원래 객체를 변경하지 않고 읽기만 해야 한다면 const 레퍼런스를 사용할 수 있습니다.

void PrintValue(const int& value)
{
    std::cout << value << '\n';
}

const int&를 사용하면 객체를 복사하지 않고 전달하면서 함수 안에서 값을 변경하지 못하도록 제한할 수 있습니다.

다만 int처럼 크기가 작은 기본 타입은 일반적으로 값으로 전달하는 편이 더 단순합니다. const 레퍼런스는 크기가 큰 클래스나 구조체를 복사하지 않고 전달할 때 주로 활용됩니다.


레퍼런스는 언제 사용할까?

레퍼런스는 참조 대상이 반드시 존재하며, 다른 대상으로 변경할 필요가 없을 때 사용할 수 있습니다.

함수의 매개변수에서 레퍼런스를 사용하는 대표적인 목적은 다음과 같습니다.

  • 호출한 쪽의 원래 변수를 함수 안에서 변경합니다.
  • 크기가 큰 객체를 복사하지 않고 전달합니다.
  • 포인터와 역참조 문법 없이 원래 객체에 간결하게 접근합니다.
  • 참조 대상이 반드시 존재한다는 의도를 표현합니다.

함수에서 원래 값을 변경하려면 일반 레퍼런스를 사용할 수 있습니다.

void Reset(int& value)
{
    value = 0;
}

복사 비용을 줄이면서 읽기만 하려면 const 레퍼런스를 사용할 수 있습니다.

void PrintCharacter(const Character& character)
{
    character.Print();
}

반면 대상이 존재하지 않을 수 있음을 표현해야 한다면 포인터를 고려할 수 있습니다.

void SetTarget(Character* target)
{
    if (target)
    {
        // target 사용
    }
}

포인터와 레퍼런스 중 무엇을 사용할지는 단순히 문법이 짧은지를 기준으로 결정하기보다, 대상의 존재 여부와 변경 가능성, 소유 관계를 고려해서 결정해야 합니다.


정리

  • 레퍼런스는 기존 변수 또는 객체의 별칭입니다.
  • L-value 레퍼런스는 타입 뒤에 &를 작성해 선언합니다.
  • 레퍼런스를 통해 값을 읽거나 변경하면 원래 변수의 값을 읽거나 변경하게 됩니다.
  • 레퍼런스는 선언과 동시에 참조할 대상을 지정해야 합니다.
  • 일반적인 레퍼런스에는 포인터의 nullptr와 같은 비어 있는 상태가 없습니다.
  • 일반적인 변경 가능한 L-value 레퍼런스는 임시 값을 직접 참조할 수 없습니다.
  • 한 번 초기화된 레퍼런스는 다른 변수를 참조하도록 변경할 수 없습니다.
  • 레퍼런스에 다른 변수를 대입하면 참조 대상이 바뀌는 것이 아니라 해당 변수의 값이 원래 참조 대상에 대입됩니다.
  • 레퍼런스에 주소 연산자를 적용하면 원래 객체의 주소를 얻습니다.
  • 포인터는 역참조 연산자가 필요하지만 레퍼런스는 일반 변수와 같은 문법으로 사용할 수 있습니다.
  • 포인터는 다른 주소를 저장할 수 있지만 레퍼런스는 참조 대상을 변경할 수 없습니다.
  • 값에 의한 전달에서는 인자의 값이 매개변수에 복사됩니다.
  • 레퍼런스에 의한 전달에서는 매개변수가 호출한 쪽의 원래 변수를 참조합니다.
  • 변경 가능한 레퍼런스 매개변수를 사용하면 함수 안에서 원래 변수의 값을 변경할 수 있습니다.
  • const 레퍼런스를 사용하면 객체를 복사하지 않고 읽기 전용으로 전달할 수 있습니다.
  • 참조 대상이 반드시 존재하고 다른 대상으로 변경할 필요가 없다면 레퍼런스를 사용할 수 있습니다.
  • 참조 대상이 존재하지 않을 수 있거나 다른 대상을 가리켜야 한다면 포인터가 더 적합할 수 있습니다.

이전 글: C++ 포인터란? 주소와 역참조부터 동적 메모리 할당까지

다음 글: C++ 함수에서 포인터와 레퍼런스 사용하기

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다