본문으로 건너뛰기

안동민 개발노트

본문 시작

참조자

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

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

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

참조자는 C++에만 존재하는 독특한 기능으로, 기존 변수에 대한 별명(alias)을 만드는 것입니다.

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

먼저 참조자가 원본 변수와 같은 메모리 공간을 다른 이름으로 바라보는 구조를 그림으로 정리합니다.

참조자는 원본 변수에 붙인 두 번째 이름이다

참조자는 새 값을 따로 저장하지 않습니다. 선언 순간 원본 변수와 묶이고, 그 뒤에는 같은 메모리 칸을 다른 이름으로 읽고 씁니다.

  1. 원본 변수

    int score = 80; 실제 값이 저장된 이름입니다.

  2. 참조자

    int& ref = score; score에 붙은 별명입니다.


참조자란 무엇인가?

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

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

참조자가 만들어지면, 참조자와 참조하는 변수는 동일한 메모리 공간을 공유합니다.

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

주요 특징
  • 별명 (Alias): 참조자는 새로운 메모리 공간을 할당받지 않고, 기존 변수의 메모리 공간을 그대로 사용합니다.
  • 선언 시 반드시 초기화: 참조자는 선언과 동시에 반드시 어떤 변수를 참조할 것인지 초기화해야 합니다. 한 번 초기화되면 다른 변수를 참조하도록 변경할 수 없습니다. (포인터는 언제든 다른 주소로 변경 가능)
  • nullptr이 될 수 없음: 참조자는 항상 유효한 변수를 참조해야 합니다. 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 참조 관점에서 비교합니다.

참조자는 필수 대상, 포인터는 선택 가능한 주소를 표현한다

둘 다 다른 값을 간접적으로 다루지만, 코드가 독자에게 약속하는 의미가 다릅니다. 존재가 보장되면 참조자, 없을 수도 있거나 대상을 바꿔야 하면 포인터가 더 정확합니다.

  1. 참조자

    T& ref = obj; 계약 대상이 반드시 존재한다. 변경 원본을 직접 읽고 쓴다. 제약 다른 대상에 다시 묶이지 않는다.

  2. 포인터

    T* ptr = &obj; 계약 null일 수 있음을 확인해야 한다. 변경 주소를 바꿔 다른 대상을 가리킬 수 있다. 제약 접근 전 역참조와 유효성 판단이 필요하다.

  3. const 참조

    const T& ref 계약 복사 없이 읽기만 한다. 변경 함수 내부에서 원본 수정을 막는다. 제약 큰 객체 입력에서 가장 흔한 읽기 전달 방식이다.

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

특징포인터 (Pointer)참조자 (Reference)
선언데이터타입* 변수이름;데이터타입& 변수이름 = 변수;
메모리주소를 저장할 별도의 메모리 공간 필요별도의 메모리 공간 없이 원본 변수와 동일한 공간 사용
초기화선언 후 나중에 초기화 가능 (nullptr 가능)선언 시 반드시 초기화해야 함
재지정다른 변수의 주소를 저장하여 재지정 가능한 번 초기화되면 재지정 불가능 (항상 동일한 변수 참조)
널 상태nullptr이 될 수 있음 (아무것도 가리키지 않음)nullptr이 될 수 없음 (항상 유효한 변수 참조)
접근* (역참조) 연산자 필요* (역참조) 연산자 없이 직접 변수처럼 사용
산술 연산포인터 산술 연산 가능 (ptr++, ptr--)참조자 산술 연산 불가능
참조자 초기화 및 재지정 불가 예시
#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; // ERROR! (실제로는 val1의 값을 val2의 값으로 변경함. ref가 val2를 참조하도록 재지정하는 것이 아님!)
                  // 이 코드는 ref가 val2를 참조하도록 재지정하는 것이 아니라,
                  // ref가 참조하는 변수(즉, val1)에 val2의 값(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

    // 참조자는 한 번 특정 변수를 참조하도록 초기화되면 평생 그 변수만을 참조합니다.

    return 0;
}

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

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

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

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

참조자를 함수 매개변수로 전달
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)나 연산자 오버로딩 시 유용하게 사용됩니다.

참조자를 함수 반환 값으로 사용
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 참조 중 의도를 가장 정확히 드러내는 형태를 고르는 것이 중요합니다.

매개변수 형태는 함수가 원본을 어떻게 다룰지 말해준다

값, const 참조, 참조, 포인터는 성능 차이만이 아니라 함수의 의도를 드러내는 계약입니다. 선택 기준을 먼저 정하면 호출 코드가 더 읽기 쉬워집니다.

  1. 복사해서 독립 사용

    value 작은 값이거나 함수 내부 사본이 필요할 때.

  2. 읽기만 관찰

    const T& 큰 객체를 복사하지 않고 안전하게 읽을 때.

  3. 원본을 반드시 수정

    T& null 확인 없이 호출자의 값을 바꿔야 할 때.

  4. 대상이 선택적

    T* 없을 수도 있거나 다른 대상을 가리킬 수 있을 때.


const 참조자 (const reference)

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

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

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

const 참조자 예시
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 참조자는 매우 중요하며, C++에서 std::vector, std::string과 같은 큰 객체를 함수에 전달할 때 가장 일반적이고 효율적인 방법입니다.

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

const 참조는 큰 객체를 복사하지 않고 읽기만 하겠다는 계약이다

함수가 입력을 관찰만 한다면 const 참조는 복사 비용을 줄이면서도 원본 변경을 막습니다. 그래서 문자열, 벡터, 사용자 정의 객체를 받을 때 자주 사용합니다.

  1. 호출자

    std::string name 큰 객체를 보유합니다.

  2. 함수 매개변수

    const std::string& name 복사 없이 읽고, 수정은 금지합니다.