2023년 1월 1일
08:00 AM
Buffering ...

최근 글 👑

C++ 포인터와 참조 차이: null 가능성, 수명, 소유권으로 구분하기

2026. 6. 21. 17:58ㆍC++/C++ 기초
반응형

한 줄 요약: 포인터와 참조는 모두 기존 객체에 접근할 수 있지만, null 가능성과 재지정 여부, 객체 수명에 대한 계약을 서로 다르게 표현합니다.

왜 중요한가

  • 포인터와 참조를 문법 차이로만 외우면 함수 매개변수를 설계할 때 기준이 흐려집니다. 호출자가 값을 반드시 전달해야 하는지, 대상이 없을 수도 있는지, 함수가 객체를 소유하는지를 타입에서 읽을 수 있어야 코드를 사용하는 쪽에서도 안전한 판단을 내릴 수 있습니다.

  • 특히 규모가 커진 C++ 코드에서는 객체의 수명이 여러 함수와 스레드, 시스템 경계를 넘나듭니다. 포인터 또는 참조가 가리키는 객체가 이미 파괴되었는데도 접근하면 컴파일은 성공하더라도 실행 중 정의되지 않은 동작이 발생할 수 있으므로, 타입 선택은 곧 수명 계약을 설계하는 일과 연결됩니다.

  • 이 글에서는 C++17을 기준으로 원시 포인터와 lvalue 참조의 역할을 비교합니다. 소유권이 필요한 경우에는 원시 포인터만으로 해결하려 하지 않고 RAII와 스마트 포인터를 별도의 선택지로 다룹니다.

핵심 개념

포인터는 ‘대상이 없을 수 있음’을 표현할 수 있다

포인터는 객체가 저장된 메모리 주소를 값으로 보관합니다. 주소를 가진다는 말은 단순히 숫자를 저장한다는 뜻보다, 그 주소에 유효한 객체가 존재하는 동안 간접 접근할 수 있다는 의미에 가깝습니다.

`int* ptr = &value;`처럼 객체의 주소를 받은 뒤 `*ptr`로 접근하면, 프로그램은 ptr이 가리키는 위치에 실제 int 객체가 존재한다고 가정합니다.


포인터에는 `nullptr`도 저장할 수 있습니다. 대상이 아직 준비되지 않았거나, 검색 결과가 없거나, 선택적인 콜백이나 설정을 전달하는 상황에서는 ‘객체가 없음’을 별도 상태로 표현해야 합니다.

과거의 정수 리터럴 0이나 NULL보다 `nullptr`을 사용하면 이 값이 정수가 아니라 null pointer constant라는 의도가 타입 수준에서 더 분명해지고, 오버로드를 선택할 때 생길 수 있는 혼동도 줄일 수 있습니다.

이 유연성에는 확인 책임이 따라옵니다. `nullptr`이나 더 이상 유효하지 않은 주소를 `*ptr` 또는 `ptr->member`로 역참조하면 C++ 언어가 결과를 보장하지 않습니다.

흔히 접근 위반으로 즉시 종료되지만, 항상 눈에 띄게 실패하는 것은 아니며 다른 메모리를 훼손한 뒤 훨씬 늦게 문제가 드러날 수도 있습니다. 따라서 null을 허용하는 인터페이스라면 함수 입구에서 상태를 확인하고, null이 논리적으로 불가능하다면 애초에 참조처럼 더 강한 계약을 표현하는 타입을 고려하는 편이 좋습니다.

포인터 변수는 다른 주소를 대입해 대상을 바꿀 수도 있습니다. 반복 중 현재 노드를 이동하거나, 조건에 따라 두 객체 중 하나를 선택하는 로직에서는 이 성질이 자연스럽습니다.

반대로 대상이 바뀌면 안 되는 코드에서 포인터를 습관적으로 사용하면 독자는 null과 재지정 가능성을 모두 검토해야 하므로 인터페이스가 실제 요구보다 느슨해집니다.

참조는 이미 존재하는 객체의 별칭을 만든다

lvalue 참조는 `std::string& name = original;`처럼 선언하면서 기존 객체에 바인딩합니다.
이후 name을 읽거나 수정하는 작업은 별도 복사본이 아니라 original에 적용됩니다.

일반적인 참조 사용에는 null 상태가 없고 바인딩 대상을 다른 객체로 바꾸는 대입 문법도 제공되지 않으므로, 함수 매개변수에서 ‘이 객체는 반드시 존재한다’는 전제를 간결하게 표현할 수 있습니다.


`const T&`는 큰 객체를 복사하지 않고 읽기 전용으로 빌려 쓰는 인터페이스에 자주 사용됩니다. 여기서 중요한 점은 const가 객체의 수명을 연장해 주거나 참조를 안전하게 보관하게 만들어 주는 장치가 아니라는 것입니다.

함수 호출 동안 유효한 객체를 읽기만 한다는 계약은 잘 표현하지만, 참조를 멤버에 저장하거나 비동기 작업으로 넘긴다면 원본 객체가 그보다 오래 살아 있는지 별도로 확인해야 합니다.

참조도 내부적으로 객체 위치를 이용해 접근할 수 있지만, 소스 코드에서의 의미는 포인터와 다릅니다. 참조 매개변수에는 보통 null 검사를 넣지 않기 때문에 호출자는 유효한 객체를 제공해야 하고, 구현자는 그 전제 아래 본문을 단순하게 유지할 수 있습니다.

값이 없을 가능성까지 필요한데 참조를 억지로 사용하면 별도의 sentinel 객체나 숨은 전역 상태가 생기기 쉬우므로, 선택적인 입력에는 포인터나 `std::optional` 같은 표현이 더 솔직할 수 있습니다.

참조가 한 번 바인딩되었다고 해서 가리키는 객체가 영원히 살아 있는 것은 아닙니다. 지역 변수에 대한 참조를 반환하거나, 컨테이너 재할당으로 원소의 주소가 무효화된 뒤 참조를 계속 사용하면 댕글링 참조가 됩니다.

참조 문법에는 null 확인 지점이 보이지 않으므로, 오히려 API를 설계할 때 원본 객체의 수명 범위를 더 명확히 기록해야 합니다.

함수 인터페이스에서 드러나는 의도

매개변수 타입을 고를 때는 먼저 대상이 없을 수 있는지 묻는 것이 좋습니다. `const Widget&`는 호출에 Widget이 필요하고 함수가 읽기만 한다는 신호를 줍니다.

`Widget&`는 유효한 Widget을 받아 수정할 수 있다는 뜻이고, `const Widget*`는 읽기 전용이지만 null일 수 있거나 다른 대상을 선택할 가능성을 열어 둡니다. 타입만 보고도 호출 전제와 수정 가능성을 상당 부분 파악할 수 있습니다.


다음 기준은 재지정과 수명입니다. 함수 내부에서 포인터 매개변수 자체를 다른 주소로 바꾸는 것과, 포인터가 가리키는 객체를 수정하는 것은 다른 동작입니다. 호출자의 포인터 변수까지 바꾸려면 `T*&` 또는 반환값 같은 별도 설계가 필요합니다. 이런 차이를 명시하지 않으면 포인터를 넘겼으니 호출자의 모든 상태가 바뀔 것이라고 오해하기 쉽습니다.


마지막으로 소유권을 분리해서 판단해야 합니다. `T*`나 `T&`는 대개 객체를 잠시 빌린다는 의미로 사용되지만, 언어가 그 의미를 자동으로 보장하지는 않습니다. 새 객체의 수명을 넘겨받는 API라면 `std::unique_ptr<T>`를 값으로 받는 방식처럼 소유권 이전을 타입에 드러내는 편이 낫습니다.

여러 곳이 공동 수명을 가져야 할 때만 비용과 구조를 검토해 `std::shared_ptr<T>`를 고려하며, 단지 null 검사를 피하려는 이유로 스마트 포인터를 선택하지는 않습니다.

결국 포인터와 참조의 선택은 어느 쪽이 더 빠른지를 고르는 문제가 아닙니다. 최적화된 빌드에서 두 방식의 기계어가 비슷해질 수 있어도, 인터페이스가 표현하는 계약은 다릅니다. null 가능성, 재지정, 수정 가능성, 수명, 소유권을 순서대로 적어 보면 필요한 타입이 더 자연스럽게 드러납니다.

코드 예제

필수 입력은 참조, 선택 입력은 포인터로 표현하기

#include <iostream>
#include <string>

void PrintRequired(const std::string& text)
{
    std::cout << text << '\n';
}

void PrintOptional(const std::string* text)
{
    if (text == nullptr)
    {
        std::cout << "(no message)\n";
        return;
    }

    std::cout << *text << '\n';
}

int main()
{
    const std::string message = "Hello";
    PrintRequired(message);
    PrintOptional(&message);
    PrintOptional(nullptr);
}

`PrintRequired`의 시그니처는 호출자가 반드시 문자열 객체를 준비해야 한다는 사실을 보여 줍니다.
반면 `PrintOptional`은 nullptr을 정상 입력으로 받아들이고, 본문에서 그 상태를 먼저 처리합니다.
단순히 두 함수가 같은 결과를 출력한다는 점보다 각 함수가 허용하는 상태가 타입에 드러난다는 점이 핵심입니다.

재지정 가능한 선택과 필수 수정을 구분하기

#include <iostream>

void SelectWithPointer(int* candidate, bool useFallback, int* fallback)
{
    if (useFallback)
    {
        candidate = fallback;
    }

    if (candidate != nullptr)
    {
        std::cout << *candidate << '\n';
    }
}

void UpdateRequired(int& value)
{
    value += 10;
}

int main()
{
    int primary = 1;
    int fallback = 2;
    SelectWithPointer(&primary, true, &fallback);
    UpdateRequired(primary);
    std::cout << primary << '\n';
}

`SelectWithPointer`는 조건에 따라 지역 포인터가 fallback을 가리키도록 바꿀 수 있습니다.
이 대입은 호출자의 primary 포인터를 바꾸는 것이 아니라 함수 안의 포인터 복사본만 바꿉니다.

`UpdateRequired`는 대상이 반드시 존재하고 그 값을 수정한다는 계약을 `int&`로 더 직접적으로 표현합니다.

소유권과 빌려 쓰는 접근을 분리하기

#include <iostream>
#include <memory>

class Session
{
public:
    explicit Session(int id) : id_(id) {}
    int Id() const { return id_; }

private:
    int id_;
};

void Inspect(const Session& session)
{
    std::cout << session.Id() << '\n';
}

int main()
{
    auto owner = std::make_unique<Session>(42);
    Inspect(*owner);
}

main은 `std::unique_ptr`로 Session의 수명을 소유하고, `Inspect`는 호출 동안만 유효한 Session을 `const Session&`로 빌립니다. 소유자는 하나지만 관찰 함수마다 스마트 포인터를 전달할 필요는 없습니다.

자주 하는 실수

실수 1. nullptr 확인 없이 포인터를 역참조하기

문제 코드 — 잘못된 예시:

#include <iostream>
#include <string>

void Print(const std::string* text)
{
    // 잘못된 예시: text가 nullptr이면 역참조할 수 없습니다.
    std::cout << *text << '\n';
}

개선 코드:

#include <iostream>
#include <string>

void Print(const std::string* text)
{
    if (text == nullptr)
    {
        return;
    }

    std::cout << *text << '\n';
}

설명: 포인터 매개변수가 nullptr을 허용하는 설계라면 함수는 역참조 전에 그 상태를 처리해야 합니다.
확인하지 않은 `*text`는 null 입력에서 정의되지 않은 동작을 일으킵니다.

반대로 null이 호출 계약상 오류라면 모든 호출 지점에서 암묵적으로 기대하기보다 `const std::string&` 같은 참조 매개변수로 필수 입력임을 드러내는 설계도 검토할 수 있습니다.

실수 2. 지역 변수의 참조를 반환하기

문제 코드 — 잘못된 예시:

#include <string>

// 잘못된 예시: 지역 변수는 함수가 끝날 때 파괴됩니다.
const std::string& MakeName()
{
    const std::string name = "AutoBlog";
    return name;
}

개선 코드:

#include <string>

std::string MakeName()
{
    return "AutoBlog";
}

설명: 지역 변수 name의 수명은 함수가 반환될 때 끝납니다.
반환된 참조는 이미 파괴된 객체를 가리키므로 사용하는 순간 정의되지 않은 동작이 됩니다.

문자열을 값으로 반환하면 C++17의 반환값 최적화와 이동 의미론을 활용할 수 있고, 호출자가 결과 객체의 수명을 명확히 소유하게 됩니다.

실수 3. 원시 포인터를 복사 가능한 소유권처럼 다루기

문제 코드 — 잘못된 예시:

class Buffer
{
public:
    explicit Buffer(int size) : data_(new int[size]) {}
    ~Buffer() { delete[] data_; }

private:
    // 잘못된 예시: 기본 복사는 같은 주소를 두 객체가 소유하게 만듭니다.
    int* data_;
};

개선 코드:

#include <memory>

class Buffer
{
public:
    explicit Buffer(int size) : data_(std::make_unique<int[]>(size)) {}

    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;
    Buffer(Buffer&&) noexcept = default;
    Buffer& operator=(Buffer&&) noexcept = default;

private:
    std::unique_ptr<int[]> data_;
};

문제 코드의 Buffer는 컴파일러가 만든 복사 연산을 그대로 허용합니다.
복사된 두 객체가 같은 배열 주소를 갖게 되고 각 소멸자가 `delete[]`를 호출하면 이중 해제가 발생합니다.

개선 코드에서는 `std::unique_ptr<int[]>`가 해제를 담당하고 복사를 명시적으로 금지해 단일 소유권을 타입으로 표현합니다. 복사가 실제 요구라면 금지하는 대신 깊은 복사를 별도로 구현해야 합니다.

정리

  • 포인터는 대상 없음과 재지정 가능성을 표현할 수 있고, 참조는 유효한 기존 객체가 필요하다는 계약을 간결하게 드러냅니다.

  • 두 타입 모두 원본 객체보다 오래 사용하면 안전하지 않습니다. null 검사만큼이나 객체 수명과 컨테이너 무효화 조건을 함께 확인해야 합니다.

  • 소유권이 필요하면 원시 포인터나 참조에 숨기지 말고 RAII 타입으로 분리합니다. 인터페이스를 읽는 사람이 null 가능성, 수정 여부, 수명 책임을 예측할 수 있다면 타입 선택은 제 역할을 한 것입니다.

참고 자료

반응형