본문으로 건너뛰기

안동민 개발노트

본문 시작

포인터 기초

메모리 주소를 저장하는 포인터의 선언과 주소·간접 참조 연산을 익히고 nullptr를 포함한 안전 조건을 이해합니다.

C++ 프로그래밍에서 가장 강력하면서도 동시에 가장 어렵다고 여겨지는 개념 중 하나인 포인터(Pointer)에 대해 학습할 차례입니다.

포인터는 C++이 메모리를 직접 다룰 수 있게 해주는 핵심 도구이며, 이를 통해 프로그램의 성능을 최적화하거나 복잡한 자료 구조를 구현하는 등 다양한 고급 기능을 사용할 수 있습니다.

하지만 포인터는 오용 시 심각한 오류(예: 메모리 누수, 세그멘테이션 폴트)를 유발할 수 있으므로, 정확한 이해와 신중한 사용이 필수적입니다.

이 장에서는 포인터의 기본 개념, 선언 방식, 핵심 연산자를 다룹니다.

메모리와 주소의 개념

컴퓨터의 메모리는 마치 거대한 아파트 단지처럼 구성되어 있습니다.

각 아파트 호실에는 고유한 주소(Address)가 있고, 그 주소에 어떤 데이터(세입자)가 살고 있습니다.

C++에서 변수를 선언하면, 이 변수는 메모리 어딘가에 공간을 할당받고 그 공간에 값이 저장됩니다.

이 공간의 위치를 나타내는 것이 바로 메모리 주소입니다.

  • int num = 10;을 실행하면 num 객체가 저장 공간을 얻고 그곳에 값 10이 저장됩니다. 실제 주소 값과 출력 형식은 구현과 실행 환경에 따라 달라집니다.

이 절에서 다루는 int* 같은 객체 포인터 변수는 객체의 주소, 배열의 one-past 위치, 널 등의 포인터 값을 저장할 수 있습니다. 다른 객체의 주소를 저장하는 것은 그 대표적인 사용입니다.


포인터 선언하기

포인터 변수를 선언할 때는 가리킬 대상의 타입과 포인터 선언자 *를 사용합니다. 이때 *는 식에 적용하는 연산자가 아니라 선언의 일부입니다.

포인터 선언 형식
데이터타입* 포인터변수이름;
  • 데이터타입: 포인터가 가리킬 메모리 위치에 저장된 값의 타입입니다. (예: int*int형 데이터를 가리키는 포인터, double*double형 데이터를 가리키는 포인터).
  • *: 해당 선언자가 포인터임을 나타내는 기호입니다. int* pNum;*와 식 *pNum의 간접 참조 연산자는 모양은 같지만 문맥과 역할이 다릅니다.
포인터 선언 예시
int* pNum;          // int형 데이터를 가리킬 포인터 pNum 선언
double* pDouble;    // double형 데이터를 가리킬 포인터 pDouble 선언
char* pChar;        // char형 데이터를 가리킬 포인터 pChar 선언

포인터의 핵심 연산자

포인터의 핵심 연산자는 두 가지입니다.

식에서 &는 객체의 주소를 얻고, 단항 *는 유효한 포인터가 가리키는 객체를 나타내는 lvalue를 만듭니다. 이 lvalue로 값을 읽거나 쓸 수 있습니다.

C++에서 선언자 별표와 단항 간접 참조 연산자를 구분하고, 객체에서 주소를 얻어 포인터에 저장한 뒤 같은 객체를 읽고 쓰는 흐름

DECLARATOR · ADDRESS-OF · INDIRECTION · LVALUE

같은 *도 선언과 식에서 역할이 다르다

int* pNum*는 포인터 선언자이고, *pNum*는 단항 간접 참조 연산자입니다. 기호를 따로 외우기보다 &numpNum*pNum으로 이어지는 객체와 포인터 값의 관계를 추적합니다.

declaration

int* pNum = #

선언자 *pNumint 객체를 가리킬 수 있는 포인터 변수임을 나타냅니다. 여기서는 &num이 만든 포인터 값을 초기값으로 저장합니다.

expression

*pNum

단항 *는 유효한 pNum이 가리키는 int 객체를 나타내는 lvalue를 만듭니다. 그래서 값을 읽을 수도 있고 대입의 왼쪽에서 쓸 수도 있습니다.

object → pointer value → object

네 식을 한 객체의 상태 변화로 읽는다

  1. int num = 10;

    int 객체 num을 만들고 값 10을 저장합니다. 특정 숫자 주소를 가정할 필요는 없습니다.

  2. &numpNum에 저장

    주소 연산자는 num을 가리키는 포인터 값을 만들고, 포인터 객체 pNum이 그 값을 보관합니다.

  3. int observed = *pNum;

    *pNum이 나타내는 num에서 값을 읽으므로 observed10으로 초기화됩니다.

  4. *pNum = 20;

    같은 lvalue에 값을 대입하므로 포인터 자체가 아니라 num의 저장된 값이 20으로 바뀝니다.

포인터 예제의 네 표현을 읽는 관점
표현무엇을 나타내는가결과 타입과 용도
num원래 int 객체값을 읽거나 쓸 수 있습니다.
&numnum을 가리키는 포인터 값int* 값으로 pNum을 초기화합니다.
pNum포인터 값을 저장하는 객체가리키는 대상을 바꾸거나 간접 참조에 사용합니다.
*pNumpNum이 가리키는 numint lvalue로 읽기와 쓰기가 가능합니다.

num

원래 int 객체이며 값을 읽거나 쓸 수 있습니다.

&num

num을 가리키는 int* 값입니다.

pNum

포인터 값을 저장하는 별도 객체입니다.

*pNum

가리키는 num을 나타내는 int lvalue입니다.

포인터 타입은 간접 참조 결과와 허용되는 연산의 의미를 정하지만, 임의의 저장 공간을 저절로 int 객체로 만들지는 않습니다. 실제 객체의 존재, 수명, 접근 규칙이 먼저 성립해야 합니다.

포인터를 효과적으로 사용하기 위해서는 두 가지 중요한 연산자를 알아야 합니다.

주소 연산자 (&, Address-of Operator)
  • 어떤 변수의 메모리 주소를 얻는 데 사용됩니다.
  • 변수 이름 앞에 &를 붙이면 해당 변수가 메모리에 저장된 시작 주소를 반환합니다.
역참조 연산자 (*, Dereference Operator / Indirection Operator)
  • 유효한 포인터가 가리키는 객체를 나타내는 lvalue를 만듭니다.
  • 이후 문맥에 따라 그 객체의 값을 읽거나 대입의 왼쪽에서 값을 씁니다.
포인터와 주소 연산자, 역참조 연산자 사용 예시
#include <iostream>

int main() {
    int num = 10;        // 1. int형 변수 num 선언 및 초기화
    int* pNum = nullptr; // 2. int형 포인터 pNum 선언 및 nullptr로 초기화 (아무것도 가리키지 않음)

    std::cout << "변수 num의 값: " << num << std::endl; // 출력: 10
    std::cout << "변수 num의 주소: " << &num << std::endl; // 표기 형식은 구현에 따라 달라질 수 있음

    pNum = &num; // 3. pNum에 num 변수의 주소를 저장 (주소 연산자 & 사용)

    std::cout << "\n포인터 pNum의 값 (num의 주소): " << pNum << std::endl; // pNum이 저장하고 있는 주소 (num의 주소와 동일)
    std::cout << "포인터 pNum이 가리키는 값: " << *pNum << std::endl; // pNum이 가리키는 주소의 값 (역참조 연산자 * 사용) -> 10

    // 포인터를 통해 값을 변경
    *pNum = 20; // pNum이 가리키는 주소의 값을 20으로 변경
    std::cout << "\n*pNum = 20 후:" << std::endl;
    std::cout << "변수 num의 값: " << num << std::endl; // 출력: 20 (num의 값이 변경됨)
    std::cout << "포인터 pNum이 가리키는 값: " << *pNum << std::endl; // 출력: 20

    return 0;
}

nullptr (널 포인터)

자동 저장 기간의 포인터를 초기화하지 않으면 언어 버전과 상황에 따라 indeterminate 또는 erroneous 값을 가질 수 있습니다.

그 값을 읽거나 포인터 연산·간접 참조에 사용하면 정의되지 않은 동작 또는 erroneous behavior가 될 수 있으므로, 포인터는 선언과 함께 유효한 대상이나 nullptr로 초기화합니다.

안전한 프로그래밍을 위해, 포인터가 유효한 주소를 가리키지 않거나 아직 가리킬 주소가 없을 때는 nullptr (C++11부터 도입)로 초기화하는 것이 좋습니다.

nullptr은 C++에서 널 포인터를 나타내는 키워드입니다.

C 언어 스타일에서는 NULL 매크로(일반적으로 0으로 정의됨)를 사용했지만, C++에서는 nullptr이 타입 안전성을 제공하므로 더 권장됩니다.

nullptr 사용 예시
int* ptr = nullptr; // 아무것도 가리키지 않는 포인터로 초기화
if (ptr == nullptr) {
    std::cout << "ptr은 현재 아무것도 가리키지 않습니다." << std::endl;
}

널 포인터를 간접 참조하면 정의되지 않은 동작입니다. 흔히 segmentation faultaccess violation으로 드러나지만, 충돌이 보장되는 것은 아닙니다.

따라서 포인터를 간접 참조하기 전에 최소한 nullptr인지 확인해야 합니다. 다만 널이 아니라는 사실만으로 안전이 보장되지는 않습니다.

실제 객체를 가리키는지(배열의 one-past 위치가 아닌지), 대상 수명이 계속되는지, 배열 범위 안인지, 타입·정렬 조건이 맞는지, 쓰기 가능한 대상인지 확인해야 합니다. 소유권과 해제 책임은 이 유효성 조건과 구분되는 자원 관리 정책입니다.

C++ 포인터가 널이 아니라는 검사만으로는 간접 참조가 안전하지 않으며, 실제 객체와 수명, 범위, 타입, 정렬, 쓰기 가능성을 별도로 확인해야 한다는 점검표

NONNULL ≠ DEREFERENCEABLE

널 검사만으로 안전을 증명할 수 없다

비교 가능한 포인터 값이라는 전제에서 ptr != nullptr는 널 포인터 값이 아니라는 사실만 확인합니다. 객체 포인터를 간접 참조하려면 지금 접근 가능한 실제 객체의 유효한 위치를 가리키는지까지 따져야 합니다.

nonnull, still invalid

널이 아니어도 간접 참조할 수 없는 예

배열의 one-past 포인터는 같은 배열 범위에서 허용되는 비교·산술에는 쓸 수 있지만 간접 참조할 수 없습니다. 수명이 끝난 객체나 해제된 저장 공간을 가리키던 포인터 값은 사용을 피해야 합니다.

dereferenceable

유효한 대상과 허용된 접근이 함께 필요하다

대상 객체가 살아 있고, 현재 포인터가 그 객체의 유효한 범위를 가리키며, 타입·정렬·접근 권한이 연산과 맞을 때만 읽거나 쓸 수 있습니다.

pre-dereference checklist

간접 참조 직전에는 네 질문을 순서대로 확인한다

  1. 실제 객체를 가리키는가?

    nullptr가 아니면서, 배열 끝 바로 다음을 뜻하는 one-past 위치가 아니라 간접 참조 가능한 객체 또는 하위 객체를 가리켜야 합니다.

  2. 대상의 수명과 저장 공간이 유효한가?

    지역 범위가 끝났거나 delete된 대상처럼 이미 수명이 끝난 객체의 주소를 보관하고 있지 않은지 확인합니다.

  3. 현재 접근이 범위 안에 있는가?

    배열 원소나 객체의 하위 영역을 다룰 때 포인터가 해당 유효 범위 안을 가리키는지 확인합니다.

  4. 타입·정렬·쓰기 조건이 맞는가?

    가리키는 객체를 해당 타입으로 접근할 수 있고 정렬 조건이 맞는지, 쓰기라면 const 객체나 읽기 전용 저장 공간이 아닌지 확인합니다.

access validity

“지금 이 포인터로 읽고 써도 되는가?”

널, 수명, 범위, 타입, 정렬, 변경 가능성은 한 번의 접근이 C++ 객체 모델에서 유효한지를 판단하는 조건입니다.

resource policy

“누가 자원을 소유하고 언제 해제하는가?”

소유권은 별도 설계 문제입니다. RAII 컨테이너와 스마트 포인터에 소유를 맡기고 raw T*를 비소유 관찰자로 쓰는 방식은 널리 쓰이는 Core Guidelines 관례이지, 모든 raw 포인터에 적용되는 ISO C++ 언어 규칙은 아닙니다.

널 포인터 간접 참조와 댕글링·범위 밖·잘못된 타입 접근은 정의되지 않은 동작이 될 수 있습니다. 충돌은 흔한 증상일 뿐 보장된 진단이 아니므로, 실행 결과로 유효성을 판정하지 않습니다.


포인터와 메모리 주소 크기

포인터의 크기와 표현은 구현이 정합니다. 많은 일반적인 64비트 ABI에서는 여러 객체 포인터가 8바이트이지만, C++ 표준이 모든 포인터 타입의 크기가 같거나 64비트 환경에서 반드시 8바이트라고 보장하지는 않습니다.

따라서 필요한 가정은 sizeof로 현재 대상 구현에서 확인하고, 직렬화나 외부 형식에 포인터 표현을 고정값으로 기록하지 않습니다.

포인터 크기 확인 예시
#include <iostream>

int main() {
    int* pInt;
    double* pDouble;
    char* pChar;

    std::cout << "sizeof(int*): " << sizeof(pInt) << " bytes" << std::endl;
    std::cout << "sizeof(double*): " << sizeof(pDouble) << " bytes" << std::endl;
    std::cout << "sizeof(char*): " << sizeof(pChar) << " bytes" << std::endl;
    // 결과는 바이트 단위이며 구현과 포인터 타입에 따라 달라질 수 있음

    return 0;
}

포인터의 대상 타입(int, double, char 등)은 간접 참조 식의 타입과 허용되는 포인터 연산의 의미를 정합니다. 그렇다고 임의의 저장 공간이 자동으로 그 타입의 객체가 되는 것은 아닙니다. 간접 참조하려면 해당 위치에 수명과 접근 규칙을 만족하는 객체가 실제로 존재해야 합니다.


왜 포인터를 사용하는가?

포인터는 직접 메모리를 다루는 저수준 구현과 API 경계에서 다음과 같이 등장합니다. 다만 현대 C++에서는 소유하는 raw 포인터와 직접적인 new/delete를 기본 선택으로 삼지 않습니다.

  • 동적 자원 관리: 언어의 동적 할당 기법은 포인터를 사용하지만, 애플리케이션 코드는 컨테이너와 RAII, std::unique_ptr·std::shared_ptr 같은 스마트 포인터로 소유권을 표현하는 편이 안전합니다.
  • 배열 및 문자열 처리: 배열과 포인터는 서로 다른 타입이지만, 배열 표현식은 여러 문맥에서 첫 원소를 가리키는 포인터로 변환됩니다. 범위가 필요하면 std::span, 문자열 보기는 std::string_view, 소유 데이터는 std::vector·std::string 같은 타입을 우선합니다.
  • 함수 경계: C API와 상호 운용하거나 “대상이 없을 수 있음”을 나타낼 때 포인터가 유용합니다. 반드시 존재하는 입출력 대상에는 참조를, 소유권 전달에는 값·이동·스마트 포인터를 의도에 맞게 사용합니다.
  • 자료 구조와 시스템 코드: 연결 구조의 내부 구현, 하드웨어 제어, 운영체제 인터페이스처럼 주소 자체를 다뤄야 하는 곳에서 포인터가 사용됩니다.

포인터는 C++의 깊은 이해를 위한 필수적인 관문입니다.

처음에는 어렵게 느껴질 수 있지만, 개념을 명확히 하고 꾸준히 연습하는 것이 중요합니다.

포인터 코드를 읽을 때는 선언의 *와 식의 *를 구분하고, &num → pNum → *pNum으로 이어지는 주소와 객체의 관계를 먼저 잡으면 훨씬 덜 혼란스럽습니다.