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

프로그램에서 사용하는 변수와 객체는 메모리의 특정 위치에 저장됩니다.

일반 변수에는 정수나 문자와 같은 값이 저장되지만, 포인터(Pointer)에는 다른 데이터가 위치한 메모리 주소를 저장할 수 있습니다.

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

위 코드에서 pointernumber가 저장된 메모리 주소를 가리킵니다. 이 주소를 이용하면 number의 값을 읽거나 변경할 수 있습니다.

이번 글에서는 포인터의 기본 개념부터 주소 연산자와 역참조 연산자, 포인터 타입, 동적 메모리 할당과 더블 포인터까지 알아보겠습니다.

목차

  1. 포인터란?
  2. 포인터 변수 선언하기
  3. nullptr로 포인터 초기화하기
  4. 주소 연산자로 메모리 주소 얻기
  5. 포인터에 저장된 주소 출력하기
  6. 역참조 연산자로 값에 접근하기
  7. 포인터에 타입이 필요한 이유
  8. void 포인터란?
  9. 포인터의 크기
  10. 포인터 연산과 타입의 관계
  11. new를 사용한 동적 메모리 할당
  12. new[]를 사용한 동적 배열 할당
  13. 댕글링 포인터와 nullptr
  14. 더블 포인터란?
  15. 포인터를 사용할 때 주의할 점
  16. 정리

포인터란?

포인터는 메모리 주소를 저장하는 변수입니다.

다음과 같이 일반 변수에는 값이 저장됩니다.

int number = 8;

number에는 정수 값 8이 저장됩니다. 이 변수 역시 컴퓨터 메모리의 특정 위치에 만들어지므로 자신만의 메모리 주소를 가집니다.

포인터 변수에는 이러한 메모리 주소를 저장할 수 있습니다.

int number = 8;
int* pointer = &number;

&numbernumber의 메모리 주소를 구합니다. pointer에는 정수 값 8이 직접 저장되는 것이 아니라, 해당 값이 위치한 주소가 저장됩니다.

변수 저장하는 내용 예시
일반 변수 프로그램에서 사용하는 값 8
포인터 변수 다른 데이터가 위치한 메모리 주소 0x000001F3...

포인터에 저장된 주소를 이용하면 해당 위치의 값을 읽거나 변경할 수 있습니다.


포인터 변수 선언하기

포인터 변수를 선언할 때는 가리킬 데이터의 타입과 포인터 기호 *를 작성합니다.

가리키는 데이터 타입* 포인터 변수 이름;

정수가 저장된 위치를 가리키는 포인터는 다음과 같이 선언합니다.

int* numberPointer;

int*int 데이터가 위치한 주소를 저장하는 포인터 타입입니다.

다른 자료형을 가리키는 포인터도 같은 방식으로 선언할 수 있습니다.

char* characterPointer;
float* speedPointer;
double* positionPointer;

포인터 기호를 타입과 변수 이름 중 어느 쪽에 붙여도 C++ 문법상 의미는 같습니다.

int* firstPointer;
int *secondPointer;

다만 한 문장에서 여러 변수를 선언할 때는 주의해야 합니다.

int* first, second;

위 코드에서 firstint*이고, second는 일반적인 int입니다. *가 각 변수 선언자에 개별적으로 적용되기 때문입니다.

혼동을 줄이려면 변수마다 선언문을 나누는 것이 좋습니다.

int* first;
int* second;

nullptr로 포인터 초기화하기

포인터를 선언한 뒤 유효한 주소를 바로 저장하지 않을 수도 있습니다.

int* pointer;

지역 포인터를 초기화하지 않으면 불확정한 값을 가질 수 있습니다. 이 값을 유효한 메모리 주소로 착각하고 사용하면 프로그램에 문제가 발생할 수 있습니다.

아직 아무 주소도 가리키지 않는 포인터는 nullptr로 초기화하는 것이 좋습니다.

int* pointer = nullptr;

nullptr는 포인터가 유효한 객체나 메모리를 가리키지 않는다는 것을 나타내는 널 포인터 상수입니다.

포인터를 사용하기 전에 nullptr인지 검사할 수 있습니다.

if (pointer != nullptr)
{
    // pointer가 가리키는 메모리를 사용한다.
}

조건식에서는 다음과 같이 간결하게 작성할 수도 있습니다.

if (pointer)
{
    // pointer가 nullptr이 아닐 때 실행된다.
}

nullptr는 C++11부터 제공됩니다. 현대 C++에서는 널 포인터를 표현할 때 정수 0이나 NULL보다 nullptr를 사용하는 것이 좋습니다.


주소 연산자로 메모리 주소 얻기

변수가 저장된 메모리 주소를 얻을 때는 주소 연산자(Address-of Operator) &를 사용합니다.

int number = 8;
int* pointer = &number;

&numbernumber가 저장된 메모리 주소를 반환합니다. 반환된 주소는 int* 타입의 pointer에 저장됩니다.

#include <iostream>

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

int main()
{
    int var = 8;
    int* pointer = &var;

    LOG(var);
    LOG(pointer);

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

var를 출력하면 저장된 값인 8이 나오고, pointer를 출력하면 var가 위치한 메모리 주소가 나옵니다.

표현식에서 변수 앞에 사용하는 &는 주소를 구하는 연산자입니다. 참조 변수를 선언할 때 타입에 작성하는 &와 기호는 같지만, 문맥에 따른 역할은 다릅니다.

int number = 10;

int* pointer = &number; // 주소 연산자
int& reference = number; // 참조 타입 선언

포인터를 출력하면 포인터가 가리키는 메모리 주소를 확인할 수 있습니다.

#include <iostream>

int main()
{
    int number = 8;
    int* pointer = &number;

    std::cout << "Value: " << number << '\n';
    std::cout << "Address: " << pointer << '\n';

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

출력 결과

Value: 8
Address: 000000A3F81BF884

표시되는 주소는 실행 환경과 실행 시점에 따라 달라질 수 있습니다.

Visual Studio에서 중단점을 설정한 뒤 디버거의 메모리 창에 pointer의 값을 입력하면 해당 주소의 메모리를 확인할 수 있습니다.

numberint이고 값이 8이라면, 해당 주소에서 정수 값 8에 대응하는 바이트를 확인할 수 있습니다.


역참조 연산자로 값에 접근하기

포인터에 저장된 주소를 따라가 해당 메모리의 값에 접근하는 것을 역참조(Dereference)라고 합니다.

포인터를 역참조할 때는 역참조 연산자 *를 사용합니다.

int number = 8;
int* pointer = &number;

std::cout << *pointer << '\n';

pointer에는 number의 주소가 저장되어 있습니다. *pointer는 그 주소를 따라가 해당 위치에 저장된 정수 값에 접근합니다.

포인터가 가리키는 값 변경하기

역참조한 결과는 값을 읽을 때뿐만 아니라 변경할 때도 사용할 수 있습니다.

#include <iostream>

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

int main()
{
    int var = 8;
    int* pointer = &var;

    *pointer = 10;

    LOG(var);

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

*pointer = 10pointer 변수 자체에 10을 저장하는 코드가 아닙니다.

pointer에 저장된 주소를 따라가 그 위치의 정수 값에 10을 저장합니다. 이 메모리는 var가 사용하는 메모리이므로 var의 값도 10으로 변경됩니다.

출력 결과

10
표현식 의미
pointer 포인터에 저장된 메모리 주소
*pointer 포인터가 가리키는 위치에 저장된 값
&var var가 저장된 메모리 주소

nullptr이거나 더 이상 유효하지 않은 주소를 가진 포인터를 역참조하면 정의되지 않은 동작이 발생합니다. 포인터를 역참조하기 전에는 유효한 메모리를 가리키는지 확인해야 합니다.


포인터에 타입이 필요한 이유

모든 포인터는 메모리 주소를 저장하지만, 가리키는 데이터에 따라 서로 다른 타입을 가집니다.

int number = 8;
int* numberPointer = &number;

char grade = 'A';
char* gradePointer = &grade;

numberPointerint가 저장된 위치를 가리키는 int*이고, gradePointerchar가 저장된 위치를 가리키는 char*입니다.

포인터의 타입은 컴파일러가 다음 작업을 처리하는 데 사용됩니다.

  • 역참조한 메모리를 어떤 자료형으로 해석할지 결정합니다.
  • 해당 메모리에서 몇 바이트를 하나의 값으로 읽고 쓸지 결정합니다.
  • 포인터 연산을 수행할 때 주소를 몇 바이트씩 이동할지 결정합니다.
  • 서로 맞지 않는 타입의 주소를 잘못 저장하는 것을 검사합니다.

예를 들어 int*를 역참조하면 컴파일러는 해당 위치의 메모리를 int 값으로 해석합니다.

int number = 8;
int* pointer = &number;

int copiedNumber = *pointer;

반면 char*를 역참조하면 해당 위치를 char 값으로 해석합니다.

char grade = 'A';
char* pointer = &grade;

char copiedGrade = *pointer;

서로 맞지 않는 타입의 포인터에는 주소를 그대로 대입할 수 없습니다.

int number = 8;

// char* pointer = &number; // 오류

&number의 타입은 int*이지만 pointer의 타입은 char*이므로 타입이 일치하지 않습니다.


void 포인터란?

void*는 특정한 데이터 타입을 지정하지 않은 범용 포인터입니다.

void* pointer = nullptr;

void*에는 다양한 객체의 주소를 저장할 수 있습니다.

int number = 8;
void* pointer = &number;

하지만 void*만으로는 가리키는 데이터의 타입을 알 수 없습니다. 따라서 그대로 역참조할 수 없습니다.

int number = 8;
void* pointer = &number;

// std::cout << *pointer; // 오류

컴파일러는 해당 주소에서 몇 바이트를 읽어야 하는지, 읽은 데이터를 어떤 타입으로 해석해야 하는지 판단할 수 없기 때문입니다.

값에 접근하려면 원래 포인터 타입으로 명시적으로 변환해야 합니다.

int number = 8;
void* pointer = &number;

int* numberPointer = static_cast<int*>(pointer);
std::cout << *numberPointer << '\n';

다만 잘못된 타입으로 변환하면 메모리를 잘못 해석할 수 있습니다. 따라서 void*를 사용할 때는 실제로 저장된 주소의 원래 타입을 정확히 알고 있어야 합니다.

void*는 C 스타일 API나 타입이 정해지지 않은 메모리를 다루는 저수준 코드에서 사용될 수 있습니다. 일반적인 C++ 코드에서는 타입 정보가 유지되는 포인터나 안전한 추상화를 우선 사용하는 것이 좋습니다.


포인터의 크기

포인터가 가리키는 데이터의 타입이 다르더라도 포인터 변수 자체에는 메모리 주소가 저장됩니다.

int* intPointer = nullptr;
char* charPointer = nullptr;
double* doublePointer = nullptr;

일반적인 64비트 프로그램에서 포인터의 크기는 보통 8바이트이고, 32비트 프로그램에서는 보통 4바이트입니다.

#include <iostream>

int main()
{
    std::cout << sizeof(int*) << '\n';
    std::cout << sizeof(char*) << '\n';
    std::cout << sizeof(double*) << '\n';

    return 0;
}

64비트 환경에서는 다음과 같이 출력될 수 있습니다.

8
8
8

int*char*의 크기가 같더라도 두 타입의 의미까지 같아지는 것은 아닙니다. 포인터 타입은 역참조와 포인터 연산에서 메모리를 해석하는 방법을 결정합니다.

포인터의 정확한 크기는 데이터 타입이 아니라 프로그램이 실행되는 플랫폼과 데이터 모델에 따라 달라질 수 있습니다. 항상 4바이트 또는 8바이트라고 가정하기보다 필요할 때 sizeof로 확인해야 합니다.


포인터 연산과 타입의 관계

포인터에 정수를 더하면 단순히 주소가 1바이트씩 이동하는 것이 아닙니다. 포인터가 가리키는 데이터 타입의 크기만큼 이동합니다.

int numbers[3] = { 10, 20, 30 };

int* pointer = numbers;

std::cout << *pointer << '\n';
std::cout << *(pointer + 1) << '\n';
std::cout << *(pointer + 2) << '\n';

출력 결과

10
20
30

pointerint*이므로 pointer + 1은 다음 int 요소가 있는 위치로 이동합니다.

만약 sizeof(int)가 4라면 실제 주소는 일반적으로 4바이트만큼 증가합니다.

char characters[3] = { 'A', 'B', 'C' };

char* pointer = characters;

char*1을 더하면 다음 char 요소로 이동합니다. sizeof(char)는 1이므로 주소도 1바이트만큼 이동합니다.

포인터 타입 pointer + 1의 이동 단위
char* sizeof(char)
int* sizeof(int)
double* sizeof(double)

포인터의 타입이 주소를 저장하는 데만 필요한 것이 아니라, 포인터 연산의 이동 단위를 결정하는 데도 사용되는 이유입니다.


new를 사용한 동적 메모리 할당

프로그램 실행 중에 필요한 메모리를 직접 할당할 때 new 연산자를 사용할 수 있습니다.

int* number = new int;

new int는 정수 하나를 저장할 수 있는 메모리를 할당하고, 그 메모리의 주소를 반환합니다. 반환된 주소는 int* 타입의 number에 저장됩니다.

할당과 동시에 값을 초기화할 수도 있습니다.

int* number = new int(10);

std::cout << *number << '\n';

동적으로 할당한 메모리를 더 이상 사용하지 않을 때는 delete로 해제해야 합니다.

delete number;
number = nullptr;

단일 객체를 new로 할당했다면 delete로 해제합니다.

int* number = new int(10);

// number 사용

delete number;
number = nullptr;

new[]를 사용한 동적 배열 할당

프로그램 실행 중에 결정된 크기의 배열이 필요하다면 new[]를 사용해 메모리를 동적으로 할당할 수 있습니다.

char* buffer = new char[8];

위 코드는 char 값 8개를 저장할 수 있는 연속된 메모리를 할당하고, 첫 번째 요소의 주소를 buffer에 저장합니다.

할당한 메모리를 0으로 채우려면 memset을 사용할 수 있습니다.

#include <cstring>
#include <iostream>

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

int main()
{
    char* buffer = new char[8];

    std::memset(buffer, 0, 8);

    buffer[0] = 'A';
    LOG(buffer[0]);

    delete[] buffer;
    buffer = nullptr;

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

new[]로 할당한 배열은 반드시 delete[]로 해제해야 합니다.

할당 방식 해제 방식
new int delete
new int[count] delete[]

new[]로 할당한 메모리를 delete로 해제하거나, new로 할당한 메모리를 delete[]로 해제하면 정의되지 않은 동작이 발생합니다.

할당한 메모리를 해제하지 않으면 더 이상 사용하지 않는 메모리가 계속 남는 메모리 누수(Memory Leak)가 발생할 수 있습니다.

이 예제는 포인터와 동적 할당의 원리를 이해하기 위한 코드입니다. 실제 C++ 프로젝트에서는 직접 new[]delete[]를 관리하기보다 std::vector, std::string, 스마트 포인터처럼 자원을 자동으로 관리하는 도구를 우선 사용하는 경우가 많습니다.


댕글링 포인터와 nullptr

동적으로 할당한 메모리를 해제해도 포인터 변수에 저장된 주소 값이 자동으로 nullptr가 되는 것은 아닙니다.

int* number = new int(10);

delete number;

number에는 이미 해제된 메모리의 주소가 남아 있습니다. 이처럼 더 이상 유효하지 않은 객체나 메모리를 가리키는 포인터를 댕글링 포인터(Dangling Pointer)라고 합니다.

int* number = new int(10);

delete number;

// std::cout << *number; // 정의되지 않은 동작

해제가 끝난 포인터를 실수로 다시 사용하지 않도록 nullptr를 저장할 수 있습니다.

delete number;
number = nullptr;

그러나 nullptr 대입만으로 모든 댕글링 포인터 문제가 해결되는 것은 아닙니다.

int* first = new int(10);
int* second = first;

delete first;
first = nullptr;

first에는 nullptr가 저장되지만, 같은 메모리를 가리키던 second에는 해제된 주소가 여전히 남아 있습니다.

여러 포인터가 같은 메모리를 가리키는 상황에서는 해당 메모리를 누가 소유하고 해제할지 명확하게 정해야 합니다.


더블 포인터란?

포인터 역시 메모리에 저장되는 변수이므로 자신만의 메모리 주소를 가집니다.

다른 포인터 변수의 주소를 저장하는 포인터를 더블 포인터(Double Pointer)라고 합니다.

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

각 변수의 관계는 다음과 같습니다.

표현식 타입 의미
number int 정수 값
pointer int* number의 주소
doublePointer int** pointer 변수의 주소
*doublePointer int* pointer에 저장된 주소
**doublePointer int number에 저장된 값

더블 포인터를 두 번 역참조하면 처음 포인터가 가리키는 값에 접근할 수 있습니다.

#include <iostream>

int main()
{
    int number = 10;
    int* pointer = &number;
    int** doublePointer = &pointer;

    **doublePointer = 20;

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

    return 0;
}

출력 결과

20

동적 배열과 더블 포인터

다음 코드에서 buffer는 동적으로 할당된 배열의 시작 주소를 저장하고, pointerbuffer 변수의 주소를 저장합니다.

#include <cstring>
#include <iostream>

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

int main()
{
    char* buffer = new char[8];
    std::memset(buffer, 0, 8);

    char** pointer = &buffer;

    (*pointer)[0] = 'A';
    LOG(buffer[0]);

    delete[] buffer;
    buffer = nullptr;

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

pointer의 타입은 char**입니다. 이를 한 번 역참조한 *pointerbuffer와 같은 char*입니다.

*pointer == buffer

(*pointer)[0]*pointer가 가리키는 배열의 첫 번째 요소에 접근합니다. 따라서 다음 표현식은 모두 같은 char 값에 접근합니다.

buffer[0]
(*pointer)[0]
**pointer

괄호를 작성하지 않은 *pointer[0]도 이 예제에서는 연산자 우선순위에 따라 *(pointer[0])으로 해석되어 결과적으로 같은 위치에 접근합니다. 그러나 더블 포인터가 가리키는 포인터를 먼저 얻어 배열처럼 사용한다는 의도를 나타낼 때는 (*pointer)[0]처럼 괄호를 작성하는 편이 의미를 파악하기 쉽습니다.


포인터를 사용할 때 주의할 점

포인터는 메모리에 직접 접근할 수 있는 강력한 기능이지만, 유효하지 않은 주소를 사용하면 정의되지 않은 동작이 발생할 수 있습니다.

초기화하지 않은 포인터를 사용하지 않는다

int* pointer = nullptr;

유효한 주소가 없다면 nullptr로 초기화합니다.

nullptr를 역참조하지 않는다

int* pointer = nullptr;

// *pointer = 10; // 정의되지 않은 동작

포인터가 유효한 주소를 가리키는지 확인한 뒤 사용해야 합니다.

if (pointer)
{
    *pointer = 10;
}

해제한 메모리에 다시 접근하지 않는다

int* pointer = new int(10);

delete pointer;
pointer = nullptr;

메모리를 해제한 뒤에는 해당 주소를 사용하지 않아야 합니다.

할당 방식과 해제 방식을 일치시킨다

int* number = new int;
delete number;

int* numbers = new int[10];
delete[] numbers;

newdelete, new[]delete[]를 각각 짝지어 사용해야 합니다.

메모리의 소유자를 명확하게 정한다

여러 포인터가 같은 메모리를 가리킬 수 있지만, 해당 메모리를 누가 해제할지는 명확해야 합니다. 같은 메모리를 두 번 해제하거나 아무도 해제하지 않는 문제를 방지하기 위해서입니다.

현대 C++에서는 직접 메모리를 관리해야 할 특별한 이유가 없다면 표준 컨테이너와 스마트 포인터를 활용하는 것이 좋습니다.


정리

  • 포인터는 다른 데이터가 위치한 메모리 주소를 저장하는 변수입니다.
  • 포인터 타입은 가리키는 데이터 타입과 *를 사용해 선언합니다.
  • 아직 유효한 주소를 저장하지 않은 포인터는 nullptr로 초기화하는 것이 좋습니다.
  • 주소 연산자 &를 사용하면 변수가 저장된 메모리 주소를 얻을 수 있습니다.
  • 역참조 연산자 *를 사용하면 포인터가 가리키는 값에 접근할 수 있습니다.
  • 역참조한 값을 변경하면 같은 메모리를 사용하는 원본 변수의 값도 변경됩니다.
  • 포인터의 타입은 메모리의 데이터를 해석하는 방법과 포인터 연산의 이동 단위를 결정합니다.
  • void*는 다양한 객체의 주소를 저장할 수 있지만, 데이터 타입을 알 수 없어 그대로 역참조할 수 없습니다.
  • 포인터의 크기는 가리키는 데이터 타입보다 실행 환경의 주소 체계에 따라 결정됩니다.
  • 포인터에 정수를 더하면 가리키는 데이터 타입의 크기를 기준으로 주소가 이동합니다.
  • new로 할당한 단일 객체는 delete로 해제해야 합니다.
  • new[]로 할당한 배열은 delete[]로 해제해야 합니다.
  • 동적으로 할당한 메모리를 해제하지 않으면 메모리 누수가 발생할 수 있습니다.
  • 해제된 메모리의 주소를 계속 가리키는 포인터를 댕글링 포인터라고 합니다.
  • 더블 포인터는 다른 포인터 변수의 주소를 저장합니다.
  • 더블 포인터를 한 번 역참조하면 원래 포인터에, 두 번 역참조하면 원래 포인터가 가리키는 값에 접근합니다.
  • 유효하지 않은 포인터를 역참조하면 정의되지 않은 동작이 발생할 수 있습니다.
  • 실제 C++ 프로젝트에서는 직접적인 동적 메모리 관리보다 표준 컨테이너와 스마트 포인터를 우선 사용하는 것이 좋습니다.

이전 글: C++ auto란? 타입 추론 방법과 const·참조 사용법

다음 글: C++ 참조란? 레퍼런스의 개념과 사용 방법

댓글 남기기

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