← 모든 글

C++ 참조는 원본이 사라진 뒤에도 사용할 수 있을까?

지역 변수의 수명과 값·참조 반환의 차이를 비교하고, Chromium 코드에서 참조의 수명 관계를 읽어봅니다.

C++Chromium

이전 글에서는 참조를 기존 변수에 붙인 다른 이름으로 이해했다. 함수가 원본을 참조하면 복사본 대신 원본에 접근할 수 있고, const 참조를 사용하면 그 경로를 통한 변경을 제한할 수 있었다.

그런데 이 설명에는 전제가 하나 있다. 참조를 사용할 때 대상이 아직 살아 있어야 한다.

이번에는 『열혈 C++ 프로그래밍』 2장의 참조자와 함수를 바탕으로, 함수가 끝난 뒤 반환받은 참조를 사용할 수 있는지 살펴본다. C의 기본 문법과 함수, C++의 참조를 알고 있는 독자를 대상으로 한다. 실행 명령은 macOS의 Clang과 C++17을 기준으로 한다.

먼저, 정수도 수명이 있는 객체다

C++의 객체(object)는 클래스의 인스턴스만 뜻하지 않는다. 다음 코드의 정수 변수도 객체다.

int number = 10;

여기서 수명(lifetime)은 해당 객체가 존재하며 정상적으로 사용할 수 있는 기간을 뜻한다. 이번 글에서는 static을 붙이지 않은 일반적인 지역 변수부터 생각하자.

{
    int number = 10;
    // 이 안에서는 number를 사용할 수 있다.
}
// 블록을 벗어나면 이 지역 정수 객체의 수명도 끝난다.

중괄호로 묶인 영역을 블록이라고 한다. 이런 지역 변수는 실행이 그 선언에 도달해 초기화되면서 사용할 수 있게 되고, 블록을 벗어나면 수명이 끝난다.

이때 **범위(scope)**와 **수명(lifetime)**을 구분해 두면 좋다. 범위는 코드에서 어떤 이름을 사용할 수 있는 영역이고, 수명은 실행 중 객체가 존재하는 기간이다. 지금 예제에서는 둘의 끝이 함께 나타나지만, 다른 함수에서 만든 객체를 참조하는 경우처럼 항상 같은 개념은 아니다. C++ 표준 초안: 객체의 수명

값을 반환하면 함수 안의 변수가 사라져도 괜찮다

먼저 정상적인 값 반환을 보자. 다음 코드를 return_value.cc에 저장한다.

#include <iostream>

int GetNumber() {
    int number = 10;
    return number;
}

int main() {
    int result = GetNumber();
    std::cout << result << "\n";
}
clang++ -std=c++17 -Wall -Wextra return_value.cc -o return_value
./return_value

출력은 다음과 같다.

10

함수가 끝나면 지역 변수 number의 수명은 끝난다. 그런데 호출자는 어떻게 10을 읽을 수 있을까?

반환 타입이 int이므로 호출자에게 전달하는 것은 지역 변수에 대한 참조가 아니라 정수 값이다. 반환할 값을 준비하는 일이 지역 변수의 수명이 끝나기 전에 이루어지고, main의 result는 그 값으로 초기화된다.

GetNumber 안의 number: 10
           ↓ 정수 값을 반환
main 안의 result: 10

함수가 끝나 number의 수명이 끝나도 result는 별도로 존재한다.

이 그림은 변수의 관계를 설명한 것이며 실제 기계어의 복사 횟수를 나타내지는 않는다. 객체를 반환할 때의 최적화 등 세부 규칙은 이후에 살펴봐도 된다. C++ 표준 초안: return의 처리 순서

지역 변수의 참조를 반환하면 무엇이 달라질까?

반환 타입을 int에서 int&로 바꿔 보자.

int& GetNumberReference() {
    int number = 10;
    return number;
}

이번 함수는 10이라는 값을 반환하려는 것이 아니라, 함수 안의 number를 참조해서 반환하려고 한다. 문제는 함수가 끝나면서 바로 그 대상의 수명이 끝난다는 점이다.

함수 실행 중:
  반환할 참조 ──→ number: 10

함수 종료 후:
  반환받은 참조 ──→ 수명이 끝난 대상

대상이 이미 사라져 유효하게 사용할 수 없는 이런 참조를 **댕글링 참조(dangling reference)**라고 부른다.

위 함수를 return_reference_bad.cc에 저장하고, 실행 파일을 만들지 않고 진단만 확인해 보자.

clang++ -std=c++17 -Wall -Wextra -fsyntax-only return_reference_bad.cc

-Wall -Wextra는 경고 옵션이고, -fsyntax-only는 코드를 검사하되 실행 파일을 만들지 않는 옵션이다. 확인한 Clang에서는 다음 경고가 발생했다.

warning: reference to stack memory associated with local variable 'number' returned

경고 문구의 stack memory는 이 컴파일러가 지역 변수의 저장 공간을 설명하는 표현이다. 핵심은 저장 위치의 이름보다 반환한 참조의 대상이 함수 종료 후에도 존재하는가다.

이 참조를 받아 정수 값을 읽거나 쓰면 정의되지 않은 동작(undefined behavior)이 된다. C++이 그 동작의 결과를 보장하지 않는다는 뜻이다. 예전에 저장한 10이 보이거나, 다른 값이 나오거나, 실행이 중단될 수 있으므로 특정 출력을 정답으로 제시할 수 없다.

본문의 잘못된 예제는 경고를 확인하는 용도로만 사용했다. 경고가 발생해도 오류로 처리하지 않는 설정에서는 검사가 성공할 수 있고, 진단하지 못한 코드라고 해서 안전한 것도 아니다.

const를 붙여도 사라진 대상이 살아나지는 않는다

이전 글에서는 const 참조를 사용하면 해당 경로로 원본을 바꿀 수 없다고 설명했다. 그렇다면 아래처럼 바꾸면 안전할까?

const int& GetConstNumberReference() {
    int number = 10;
    return number;
}

이 함수도 잘못됐다. const는 반환받은 참조를 통한 변경을 제한하지만, 지역 변수 number의 수명을 늘려 주지 않는다.

다음 두 질문은 별도로 확인해야 한다.

  • 이 참조를 통해 값을 변경할 수 있는가?
  • 이 참조의 대상이 아직 존재하는가?

읽기 전용으로 접근하더라도 읽을 대상 자체가 사라졌다면 문제가 된다.

참조 반환 자체가 잘못된 것은 아니다

참조를 반환하려면 호출자가 사용할 때 대상이 살아 있어야 한다. 아래 예제에서는 함수 안에서 새로 만든 지역 변수가 아니라, 호출자가 전달한 기존 변수를 참조해서 반환한다.

return_caller_reference.cc에 저장한다.

#include <iostream>

int& SameNumber(int& value) {
    return value;
}

int main() {
    int number = 10;
    int& result = SameNumber(number);

    result = 20;
    std::cout << number << "\n";
}
clang++ -std=c++17 -Wall -Wextra return_caller_reference.cc -o return_caller_reference
./return_caller_reference

출력은 다음과 같다.

20

SameNumber의 참조 매개변수는 함수가 끝나면 더 이상 사용할 수 없지만, 그 대상인 main의 number는 아직 살아 있다. 반환받은 result도 같은 number를 참조한다.

main의 number ← SameNumber의 value
      ↑
main의 result

함수의 참조 매개변수가 사라지는 것과 그 매개변수가 가리키던 원본이 사라지는 것은 다른 일이다. 이 예제는 number가 살아 있는 동안 result를 사용하므로 정상적으로 동작한다.

다만 “인자로 받은 참조를 반환하면 언제나 안전하다”로 일반화하면 안 된다. 대상의 수명을 확인해야 한다는 조건은 그대로다.

Chromium 코드에서 참조의 대상을 찾아보자

Chromium의 FilePath::value()는 다음처럼 정의되어 있다.

const StringType& value() const LIFETIME_BOUND { return path_; }

아직 클래스 문법을 배우지 않았다면 세부 문법보다 아래 관계에 집중하자.

  • StringType은 이 클래스에서 사용하는 문자열 타입의 이름이다.
  • 반환 타입 const StringType&는 문자열의 const 참조다.
  • return path_;는 해당 FilePath 객체 안의 문자열을 참조해서 반환한다.

앞의 GetNumberReference와 다른 점은 path_가 함수 안에서 잠깐 만든 지역 변수가 아니라 그 함수를 호출한 객체에 속한 데이터라는 것이다. 따라서 반환된 참조를 사용할 때 그 FilePath 객체와 내부 문자열이 살아 있어야 한다.

여기에 붙은 LIFETIME_BOUND도 수명 관계에 관한 표시다. Chromium의 매크로 설명은 이런 표시를 일부 수명 오류의 진단을 돕는 용도로 설명한다. 지원하는 Clang에서는 [[clang::lifetimebound]]로 확장된다.

이 표시는 객체를 더 오래 살려 두는 기능이 아니다. 반환값이 호출한 객체의 수명에 의존한다는 정보를 컴파일러에 전달하는 것으로 읽으면 된다. Clang의 lifetimebound 설명

이 사례가 보여주는 것은 참조 반환이 위험하니 모두 없애야 한다는 주장이 아니다. 반환 타입에서 &를 발견하면 그 참조는 어느 객체를 가리키고, 그 객체는 언제까지 살아 있는가를 함께 찾아야 한다는 점이다.

가이드에서는 어떤 점을 주의하라고 할까?

Chromium은 자체 예외를 제외하고 Google C++ 스타일 가이드를 따른다. Google 가이드의 함수 입력·출력 항목은 반환값으로 결과를 전달하는 방식을 권장하며, 가능하면 값 반환을 우선하도록 설명한다.

또한 함수가 받은 참조가 호출 이후에도 계속 유효해야 하는 형태는 피하라고 권고한다. 참조 매개변수에는 임시 객체가 연결될 수도 있어, 함수가 그 참조를 나중에 사용하려고 보관하면 수명 문제가 생길 수 있기 때문이다. Google C++ 스타일 가이드

이 글의 예제에 적용하면, 단지 계산한 정수를 돌려주려는 함수는 먼저 int 반환으로 목적을 달성할 수 있는지 검토하면 된다. 기존 객체를 참조해서 반환해야 한다면 그 대상과 호출자에게 필요한 수명 조건을 분명히 해야 한다.

비동기 작업이나 콜백은 참조를 보관하는 상황이 더 복잡해지는 후속 주제다. 이번 글에서는 함수의 호출과 반환을 기준으로 수명을 읽는 데 집중한다.

핵심 요약

  • 값 반환은 정수 값을 전달하므로, 함수 안의 지역 정수 변수가 사라져도 호출자가 받은 값을 사용할 수 있다.
  • 지역 변수의 참조를 반환하면, 함수가 끝난 뒤 대상이 사라진 참조가 남는다.
  • 참조 반환이 필요하다면 사용 시점까지 대상이 살아 있는지 확인해야 한다.
  • const는 변경 가능성과 관련된 제한이며, 대상의 수명을 자동으로 보장하지 않는다.

주의할 점

  1. 컴파일 성공은 수명 안전성의 증명이 아니다. 경고를 확인하고 참조 대상의 수명을 따로 생각해야 한다.
  2. 우연히 예상한 값이 나와도 안전한 코드는 아니다. 수명이 끝난 정수 객체를 참조해 읽는 결과는 보장되지 않는다.
  3. 참조를 다시 받아도 원본의 수명이 자동으로 연장되지는 않는다. 임시 객체의 수명 연장에도 적용 조건이 있다.
  4. 실제 코드의 참조 반환을 보면 먼저 대상을 찾는다. 지역 변수인지, 호출자가 제공한 객체인지, 다른 객체 안의 데이터인지 구분한다.

더 알아보기: 임시 객체와 const 참조

아래는 지역 const 참조에 임시 정수를 직접 연결하는 경우다.

const int& value = 10;

이 경우에는 임시 객체의 수명이 그 참조의 수명에 맞춰 연장된다. 하지만 이 규칙을 모든 참조 반환에 적용할 수는 없다.

const int& SameConstNumber(const int& value) {
    return value;
}

int main() {
    const int& result = SameConstNumber(10);
    // 이 초기화 문장이 끝나면 인자로 만든 임시 객체의 수명이 끝난다.
    // 다음 문장에서 result로 값을 읽으면 안 된다.
}

참조 매개변수에 연결된 임시 객체는 이 예제에서 함수 호출을 포함하는 전체 식이 끝날 때까지 존재한다. return value;로 그 참조를 전달하고 다른 참조에 연결했다고 해서 다시 수명이 연장되지는 않는다. 읽지 않고 참조를 선언하는 예제와, 수명이 끝난 뒤 그 참조를 사용해 값을 읽는 동작도 구분해야 한다. C++ 표준 초안: 임시 객체의 수명

처음에는 “const 참조를 사용하면 객체가 무조건 오래 살아 있다”라고 외우지 않는 것이 중요하다. 다음 글에서는 포인터 변수와 new로 만든 객체의 수명이 어떻게 다른지 살펴본다.